diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5a86cf1..f6f8bac 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report a reproducible problem on the supported Linux beta +description: Report a reproducible problem on the supported Linux or macOS beta title: "[Bug]: " labels: - bug @@ -18,8 +18,8 @@ body: - type: input id: platform attributes: - label: Linux distribution - description: Provide distribution, version, and architecture. macOS and non-systemd systems are unsupported. + label: Operating system + description: Provide Linux distribution or macOS version, architecture, and user-service manager. validations: required: true - type: textarea @@ -34,12 +34,12 @@ body: attributes: label: Affected area options: - - Dashboard or color - - Reply state + - The picker or session colors + - Session state (needs you, working, idle) - Open or move - Provider exit or reopen - - Kill confirmation - - Recovery or repair + - Closing a session + - Restore or repair - Cleanup - Install, update, rollback, or uninstall - Privacy or journals diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 8b3f3ca..80c0f1b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,5 +1,5 @@ name: Feature request -description: Suggest one focused improvement for the supported Linux beta +description: Suggest one focused improvement for the Linux and macOS beta title: "[Feature]: " labels: - enhancement diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 674e6bb..e208b86 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,9 +14,10 @@ Describe the user-visible result and the problem it solves. ## Session, privacy, and platform effects -Explain any effect on exact identity, dashboard state, provider exit, opening, -moving, kill confirmation, recovery, cleanup, logs, journals, supported Linux -versions, installation, update, rollback, or uninstall. Write `None` if none. +Explain any effect on exact identity, picker state, provider exit, opening, +moving, closing, restore, cleanup, logs, journals, supported Linux or macOS +versions, systemd or launchd behavior, installation, update, rollback, or +uninstall. Write `None` if none. ## Rollback diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bd58ad..8cced35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,14 @@ on: pull_request: concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # Branch runs share one group and supersede each other, which saves runner + # time on a busy branch. Default-branch runs get a group of their own per + # run, so they neither cancel nor queue behind anything: the badge reads + # the newest default-branch run, GitHub draws both cancelled AND queued- + # then-superseded runs as failing, and several pushes an hour apart left + # the project looking broken while every job that ran had passed. + group: ci-${{ github.workflow }}-${{ github.ref }}-${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.run_id || 'branch' }} + cancel-in-progress: ${{ github.ref != format('refs/heads/{0}', github.event.repository.default_branch) }} permissions: contents: read @@ -16,6 +22,7 @@ permissions: jobs: linux: name: Linux ${{ matrix.os }} / Python ${{ matrix.python }} + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -27,6 +34,14 @@ jobs: - "3.11" - "3.12" - "3.13" + exclude: + # Every 3.12.x build in the ubuntu-24.04 toolcache ships a _curses + # extension built against a newer CPython (undefined symbol + # _PySys_GetOptionalAttrString), which kills every TUI test; the + # ubuntu-22.04 3.12 build is healthy and keeps the version covered. + # Restore this cell when the 24.04 toolcache is repaired. + - os: ubuntu-24.04 + python: "3.12" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -37,18 +52,110 @@ jobs: python-version: ${{ matrix.python }} - name: Check Bash syntax run: | - bash -n install.sh - bash -n bin/* - bash -n bashrc/shpool.bashrc - bash -n deploy/session-kit-launcher - bash -n tests/run + mapfile -d '' shell_scripts < <( + while IFS= read -r -d '' path; do + if head -n 1 "$path" | grep -Eq \ + '^#![[:space:]]*(/usr/bin/env[[:space:]]+)?(/[^[:space:]]*/)?(ba|da|k)?sh([[:space:]]|$)'; then + printf '%s\0' "$path" + fi + done < <(git ls-files -z) + ) + # This sourced Bash startup file deliberately has no shebang. + bash -n "${shell_scripts[@]}" bashrc/shpool.bashrc + - name: Check scripts embedded in heredocs + run: tools/check-embedded-scripts - name: Check public working tree run: tools/public-scan . - name: Run tests run: tests/run + macos: + name: macOS ${{ matrix.arch }} / Python 3.13 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + arch: arm64 + - os: macos-15-intel + arch: x86_64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.13" + - name: Install supported Bash + run: | + # A runner image can arrive with a third-party tap Homebrew refuses + # to trust, and `brew install` then exits nonzero over a tap this + # job never asked for. Untrusted taps are removed first so the + # install speaks only about Bash. + for tap in $(brew tap 2>/dev/null); do + case "$tap" in homebrew/*) continue ;; esac + brew untap "$tap" 2>/dev/null || true + done + brew install bash || brew list bash + modern_bash="$(brew --prefix bash)/bin/bash" + test -x "$modern_bash" + printf 'SESSION_KIT_TEST_BASH=%s\n' "$modern_bash" >> "$GITHUB_ENV" + printf '%s\n' "${modern_bash%/*}" >> "$GITHUB_PATH" + - name: Verify native Darwin adapter + env: + EXPECTED_ARCH: ${{ matrix.arch }} + run: | + test "$(uname -m)" = "$EXPECTED_ARCH" + test -n "$(python3 lib/session_inventory.py platform boot-id)" + "$SESSION_KIT_TEST_BASH" -c ' + set -euo pipefail + info=$(python3 lib/session_inventory.py platform process-info $$) + generation=${info#*$'"'"'\t'"'"'} + test "$generation" -gt 0 + python3 lib/session_inventory.py platform process-is \ + $$ "$generation" bash + # Keep Bash from replacing itself with the final Python command; + # the process-is check must still observe the shell at this PID. + : + ' + - name: Check supported Bash syntax + run: | + mapfile -d '' shell_scripts < <( + while IFS= read -r -d '' path; do + if head -n 1 "$path" | grep -Eq \ + '^#![[:space:]]*(/usr/bin/env[[:space:]]+)?(/[^[:space:]]*/)?(ba|da|k)?sh([[:space:]]|$)'; then + printf '%s\0' "$path" + fi + done < <(git ls-files -z) + ) + # This sourced Bash startup file deliberately has no shebang. + "$SESSION_KIT_TEST_BASH" -n "${shell_scripts[@]}" bashrc/shpool.bashrc + - name: Check scripts embedded in heredocs + run: tools/check-embedded-scripts + - name: Run native and fixture-only macOS tests + run: | + python3 -m unittest -v \ + tests.test_macos_preview \ + tests.test_install.InstallerTests.test_macos_install_update_and_rollback_are_transactional \ + tests.test_login \ + tests.test_lifecycle_shell.ProviderExitShellTests \ + tests.test_lifecycle_shell.TimeoutPipelineTests \ + tests.test_provider_title_push.ClaudeAiTitleTests \ + tests.test_inventory.WorkerLaunchGateTests.test_claude_reopen_passes_hydrated_name_to_native_cli \ + tests.test_projects \ + tests.test_projects_context \ + tests.test_projects_identity \ + tests.test_projects_manifest \ + tests.test_public_export \ + tests.test_release_artifact \ + tests.test_public_scan \ + tests.test_doc_links + quality: name: Quality, privacy, and public export + timeout-minutes: 60 runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -65,44 +172,121 @@ jobs: python -m pip install --requirement requirements-dev.txt - name: Check shell run: | - shellcheck install.sh bin/* bashrc/shpool.bashrc \ - deploy/session-kit-launcher tests/run + mapfile -d '' shell_scripts < <( + while IFS= read -r -d '' path; do + if head -n 1 "$path" | grep -Eq \ + '^#![[:space:]]*(/usr/bin/env[[:space:]]+)?(/[^[:space:]]*/)?(ba|da|k)?sh([[:space:]]|$)'; then + printf '%s\0' "$path" + fi + done < <(git ls-files -z) + ) + # This sourced Bash startup file deliberately has no shebang. + shellcheck "${shell_scripts[@]}" bashrc/shpool.bashrc - name: Check Python env: RUFF_CACHE_DIR: ${{ runner.temp }}/ruff-cache run: | - ruff check . - ruff check tools/build-public-tree tools/build-release-artifact \ - tools/check-doc-links tools/public-scan + ruff --version + ruff check --output-format github . + ruff check --output-format github \ + tools/build-public-tree tools/build-release-artifact \ + tools/check-doc-links tools/check-embedded-scripts \ + tools/public-scan tools/render-readme-picker \ + deploy/session-kit-release ruff format --check lib/sessionkit_inventory/__init__.py \ + lib/sessionkit_inventory/collector.py \ + lib/sessionkit_inventory/closed_sessions.py \ + lib/sessionkit_inventory/colors.py \ lib/sessionkit_inventory/common.py \ + lib/sessionkit_inventory/lifecycle.py \ + lib/sessionkit_inventory/migration.py \ + lib/sessionkit_inventory/model.py \ + lib/sessionkit_inventory/names.py \ + lib/sessionkit_inventory/names_push.py \ + lib/sessionkit_inventory/origins.py \ + lib/sessionkit_inventory/private_store.py \ + lib/sessionkit_inventory/processes.py \ + lib/sessionkit_inventory/projects.py \ lib/sessionkit_inventory/providers.py \ + lib/sessionkit_inventory/providers_claude.py \ + lib/sessionkit_inventory/providers_codex.py \ + lib/sessionkit_inventory/reaper.py \ + lib/sessionkit_inventory/recovery.py \ + lib/sessionkit_inventory/render.py \ + lib/sessionkit_inventory/self_name.py \ + lib/sessionkit_inventory/snapshot.py \ + lib/sessionkit_inventory/terminal.py \ + lib/sessionkit_inventory/transcripts.py \ + lib/sessionkit_inventory/validation.py \ + lib/sessionkit_inventory/worker_model.py \ + lib/sessionkit_inventory/worktrees.py \ + lib/sessionkit_projects/__init__.py \ + lib/sessionkit_projects/cli.py \ + lib/sessionkit_projects/context.py \ + lib/sessionkit_projects/identity.py \ + lib/sessionkit_projects/launch.py \ + lib/sessionkit_projects/manifest.py \ tests/test_doc_links.py tests/test_facade.py \ + tests/test_projects.py \ + tests/test_projects_context.py \ + tests/test_projects_identity.py \ + tests/test_projects_manifest.py \ tests/test_public_export.py tests/test_public_scan.py \ - tests/test_release_artifact.py tests/support.py + tests/test_release_artifact.py tests/support.py \ + tests/test_sandbox_sweep.py \ + tests/shard.py \ + tests/test_readme_art.py tests/test_readme_keys.py \ + tools/readme_art_lib.py tools/readme_keys.py ruff format --check tools/build-public-tree tools/build-release-artifact \ - tools/check-doc-links tools/public-scan + tools/check-doc-links tools/check-embedded-scripts tools/public-scan \ + tools/readme_art_lib.py tools/readme_capture.py tools/readme_keys.py \ + tools/render-readme-figures tools/render-readme-picker \ + tools/render-readme-social python -m compileall -q lib tests python -m py_compile tools/build-public-tree \ - tools/build-release-artifact tools/check-doc-links tools/public-scan - - name: Report legacy type baseline - continue-on-error: true + tools/build-release-artifact tools/check-doc-links \ + tools/check-embedded-scripts tools/public-scan \ + tools/render-readme-figures tools/render-readme-picker \ + tools/render-readme-social deploy/session-kit-release + - name: Check types env: MYPY_CACHE_DIR: ${{ runner.temp }}/mypy-cache run: | mypy --ignore-missing-imports \ - lib/session_inventory.py lib/sessionkit_inventory + lib/session_inventory.py lib/sessionkit_inventory \ + lib/sessionkit_projects + # Extensionless scripts have no module name to infer, so they need + # --scripts-are-modules. tools/render-readme-picker is excluded: + # it imports the login test fixture, which pulls the whole test tree + # into the type check. + mypy --ignore-missing-imports --scripts-are-modules \ + tools/build-public-tree tools/build-release-artifact \ + tools/check-doc-links tools/check-embedded-scripts \ + tools/public-scan deploy/session-kit-release - name: Check documentation links run: tools/check-doc-links - name: Scan working tree and reachable history run: | tools/public-scan . if [[ -f SOURCE.json ]]; then - tools/public-scan . --git-history --private-markers + # Commits published before the private-marker rule existed cannot + # be rewritten out of a public history. tools/public-scan-history-baseline + # names the reviewed blobs; every other blob still fails. + tools/public-scan . --git-history --private-markers \ + --baseline tools/public-scan-history-baseline else tools/public-scan . --git-history fi - name: Build and test exact public export + env: + # One process per module, four at a time. This suite is bounded by + # wall clock rather than by processors -- most of its expensive + # modules sit in a pty waiting for a picker to repaint -- so four + # workers finish the same 2,728 tests in a quarter of the time on + # this runner's four cores just as they do on thirty-two. Measured + # 2026-08-18: 1,588 s serial, 395 s here, floor 393 s because + # test_watchdog alone takes that long. + SESSION_KIT_TEST_WORKERS: "4" run: | tools/build-public-tree \ --commit "$GITHUB_SHA" \ @@ -131,27 +315,49 @@ jobs: env: COVERAGE_FILE: ${{ runner.temp }}/coverage run: | - coverage run --branch --source=lib \ - -m unittest discover -s tests -t . + # Every worker gets its own data file (--parallel-mode inside the + # driver), and `combine` merges them into COVERAGE_FILE before the + # threshold is applied -- so the number below is the whole suite's, + # exactly as it was when one process walked every module. + python tests/shard.py --workers 4 --coverage + coverage combine coverage report --skip-empty --fail-under=70 shpool-patch: - name: Optional shpool patch applies and builds + name: Optional shpool patches apply, test, and build + timeout-minutes: 30 runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - name: Check patch against shpool 0.11.0 + - name: Install shpool test shells + run: | + sudo apt-get update + sudo apt-get install --yes zsh + - name: Check patches against shpool 0.11.0 run: | git clone --branch v0.11.0 --depth 1 \ https://github.com/shell-pool/shpool.git \ "$RUNNER_TEMP/shpool" test "$(git -C "$RUNNER_TEMP/shpool" rev-parse HEAD)" = \ fe2d11595ff255810523b0868159dec051e303f1 - git -C "$RUNNER_TEMP/shpool" apply --check \ - "$GITHUB_WORKSPACE/shpool-patch/0001-heartbeat-ack-timeout-is-not-fatal.patch" - git -C "$RUNNER_TEMP/shpool" apply \ - "$GITHUB_WORKSPACE/shpool-patch/0001-heartbeat-ack-timeout-is-not-fatal.patch" + for patch in \ + 0001-heartbeat-ack-timeout-is-not-fatal.patch \ + 0002-restore-input-modes-on-reattach.patch \ + 0003-kill-tolerates-an-already-dead-shell.patch \ + 0004-detach-must-not-hold-the-shells-lock.patch \ + 0005-attach-must-not-discard-the-shells-exit-status.patch \ + 0006-coalesce-client-resize-bursts.patch; do + git -C "$RUNNER_TEMP/shpool" apply --check \ + "$GITHUB_WORKSPACE/shpool-patch/$patch" + git -C "$RUNNER_TEMP/shpool" apply \ + "$GITHUB_WORKSPACE/shpool-patch/$patch" + done + cargo test --locked --manifest-path \ + "$RUNNER_TEMP/shpool/Cargo.toml" --workspace \ + || { echo "::warning::upstream shpool tests failed once; retrying"; \ + cargo test --locked --manifest-path \ + "$RUNNER_TEMP/shpool/Cargo.toml" --workspace; } cargo build --locked --release --manifest-path \ "$RUNNER_TEMP/shpool/Cargo.toml" --bin shpool diff --git a/.gitignore b/.gitignore index fb697ce..cc0f3af 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,25 @@ __pycache__/ venv/ .DS_Store *.log + +# Test sandboxes. The suite builds each one with +# `tempfile.TemporaryDirectory(dir=REPO)` so a test's fake HOME, state +# directory and unix sockets sit on the same filesystem as the code under +# test; roughly seventy prefixes do it (.commands-, .login-, .session-kit-…), +# every one a hidden directory in the repository root with a random suffix. An +# interrupted run leaves them behind, and one `git add -A` at the wrong moment +# commits a mocked home. Ignore the whole class rather than chase prefixes. +/.*/ +!/.github/ +# Overhaul work-order paperwork and its evidence: kept beside the branch for +# the lead to read, deliberately not tracked so the export manifest stays a +# statement about what the kit ships. +WORKORDER-REPORT.md +/WORKORDER-REPORT.md +/wsd-evidence/ +/WORKORDER-FLEET-SOCKET.diff + +# Per-branch review notes are working papers kept beside the worktree, not +# release or public-tree content. +/BUILD-REPORT.md +BUILD-REPORT.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 379afa5..e322219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,45 +1,349 @@ # Changelog -Session Kit follows [Semantic Versioning](https://semver.org/). +All notable changes to Session Kit are documented here. -## [Unreleased] +## [0.4.3] - 2026-08-19 + +### Fixed + +- A pressed key could vanish when the machine was busy. Bash's timed `read` + abandons input it has already taken from the kernel if its timer fires in + the middle of the read, so a keypress landing on the timer's edge was + consumed and never delivered: a `q` could disappear and the Enter behind it + act alone, or a lone Enter be swallowed outright. No read that consumes the + terminal carries a timer any more. The picker sleeps on a private descriptor + that never has data, asks `read -t 0` whether real input is ready, and only + then reads it, untimed. The idle beat is measured on the wall clock rather + than counted in ticks, so load cannot stretch it, and keys queued ahead of a + Ctrl-C are answered in the order they were typed. A source-scan test fails + any future timed read of the terminal. +- Losing the publishing-lock fence race no longer crashes. Retiring the + legacy `inventory.lock` replaces it with a read-only fence file, and a + second process that had just checked the fence then opened the fenced + inode for writing and escaped with a raw `PermissionError`. That open now + re-verifies the fence and retries through the new lock generation, while a + genuine permission problem still surfaces. CI caught the interleaving once + under coverage instrumentation; a deterministic test now pins it. +- `sp account sync-rules` skips a symlinked rulebook by name and skips a + rulebook that is not UTF-8 text, instead of aborting the whole run on the + first and crashing with a traceback on the second. The skipped file and the + reason appear in the report. +- The `sp` help table had lost the line naming what `sp account verify` does. +- Both pickers now agree with their own list about which sessions are waiting. + The 2026-08-15 ruling that a finished provider turn is `needs you` however + the vendor spells it lives in `labels.STATE_WORDS`, and the list column reads + it through `labels.session_state`. Neither the Needs-you screen, `g`, nor the + cursor picker's count did: they tested the raw `needs_you` and + `blocking_question` flags, which the ruling never touched. A Codex turn + ending in `task_complete` reports `agent_status: idle` with + `needs_you: false`, so its row printed `needs you` while the screen left it + out and `g` skipped past it. On a live estate of nine sessions the list + showed seven waiting and the screen said one. Both surfaces now ask + `labels.session_state`. The new test is a superset of the flags it replaces, + so no row that was listed before stops being listed. +- The documented install command could not run. `gh release download` without a + tag requires `--pattern` or `--archive`, so the version-free form printed a + flag error instead of downloading anything. The README and `docs/install.md` + now pass `--pattern 'session-kit-*'`. +- The README, `docs/install.md`, `SECURITY.md`, and `CONTRIBUTING.md` no longer + say beta releases are published as GitHub prereleases. They are ordinary + releases, so `releases/latest` resolves to the current beta and the install + no longer has to name a version. +- The README claimed Session Kit does not isolate work in a git worktree. It + does: a delegated session is given its own worktree on its own branch and + hands it back when it closes. The front page now documents that, and the + "does not do" list keeps only what is true. +- `tools/publish-release` checked version references under `docs/*.md`, which + never reached `docs/maintainers` or `docs/migrations`. The maintainer release + process had named `v0.4.0` ever since, while telling its reader to search + nested directories by hand. The check now walks them. +- `tools/check-doc-links` now resolves a `#fragment` against the headings of + the document it points into. A renamed heading used to leave every link to it + scrolling nowhere, with nothing failing. +- Two shipped documents were reachable from nothing. The legacy-install + migration is linked from `docs/install.md`, and the maintainer release + process from `CONTRIBUTING.md`. +- The bug-report and pull-request templates offered `Dashboard` and + `Kill confirmation`. `docs/voice.md` allows neither word, and the second + named a confirmation step that no longer exists. ### Changed -- Made the public beta Linux-only and fail closed on unsupported platforms. -- Changed terminal journals to opt-in and off by default. -- Kept the managed shpool terminal alive after Claude Code or Codex exits. -- Added a clear provider-exited state with exact reopen, keep, shell, and close - choices. -- Limited automatic close to exact provider-exited terminals that satisfy every - safety predicate continuously for 72 hours after timer enablement. -- Simplified dashboard wording and applied consistent semantic color categories. -- Removed internal IDs from normal rows while keeping them in detail, JSON, and - explicit search views. -- Made `k ` the documented kill shortcut with exact-ID confirmation. -- Reduced normal log data and documented private reporting requirements. - -### Release preparation - -- Added reachable-history secret scanning and local documentation link checks. -- Added public-export completeness checks. -- Added reproducible release archive, checksum, and provenance preparation. -- Added optional shpool patch apply and build checks. -- Added the Apache License 2.0 text for the shpool-derived patch. - -## [0.1.0] - Unreleased beta +- Two keys, named for what they do: inside a session, Ctrl-Q leaves it + running and Ctrl-D closes it; in the picker, Esc and Ctrl-D quit. Every + footer, hint, and page now uses that vocabulary, and `leave` no longer + appears where `quit` is meant. ### Added -- Unified local inventory for shpool, Claude Code, Codex, and shell sessions. -- SSH picker with boot-scoped terminal numbers and task-focused names. -- Structured `needs your reply` and optional-reply states. -- Exact provider resume, recovery, and fork operations. -- Optional local terminal journals, off by default. -- Guarded open, move, kill, repair, and cleanup actions. -- Immutable installed releases with update and rollback. -- Read-only install preflight, doctor, login enable and disable, and uninstall. -- Report-only local health checks. - -`v0.1.0` has not been tagged. Current `main` is the only supported pre-release -line. +- `sp account sync-rules [--check]`: one shared rulebook, written by hand in + one place, is rendered into every enrolled profile's rulebook between + markers. `--check` reports drift without writing. The doctor reports a + profile whose rulebook has drifted from the shared file. +- The sandbox sweep derives its temp-directory prefixes from the test sources + themselves instead of a hand-kept list of six, so a new test's sandboxes are + swept without anyone remembering to register them, and it refuses by name to + remove `.git`, `.github`, `.gitignore`, and `.shellcheckrc`. +- `CODE_OF_CONDUCT.md`, the Contributor Covenant 2.1, with reports routed + through GitHub so one about the maintainer still reaches someone. +- The README's pictures are generated from the running picker rather than + drawn. `tools/render-readme-picker` captures the session list and the + needs-you screen in one run of the real picker, `tools/render-readme-figures` + lays out the two diagrams, and `tools/render-readme-social` builds the + GitHub social card. A render is refused if any text escapes its box, if the + command bar offers a key the README does not document, or if the two picker + screens disagree about which sessions are waiting. +- `docs/assets/mark.svg`: the project mark, which is the picker's own prompt. + +## [0.4.2] - 2026-08-18 + +### Added + +- Short project directory names: a Claude session launched at the registered + root of a project shortcut now stores transcripts and auto memory under the + shortcut's alias (Claude Code 2.1.234's `CLAUDE_CODE_PROJECT_DIR_NAME`). + The export is proved per launch, launcher version, unambiguous registry, + valid alias, no directory conflict, and an existing munged directory is + renamed in one atomic move only while no other session of that profile runs + inside the root. `session-kit doctor` gained a `project-dir-names` check; + `SESSION_KIT_PROJECT_DIR_NAME=off` disables the feature. + +### Security / data safety + +- A second Unix account's shpool daemon no longer disables this account's + kit. Daemon selection now rules out any daemon owned by another account + before it asks who holds the listener: their `/proc//fd` is + unreadable, which the uniqueness rule read as "cannot establish", so + `daemon_generation` went null and every session became unprovable, +`sp new` produced an unresolved row and `sp go`/`sp close` refused. A + listener under `/run/user/` is mode 0700, so another account's daemon + was never a candidate. Census rows now carry the owning uid; a row without + one is still treated as a candidate. +- The project-directory migration closed three lose-work races found by a + post-install review lane: the liveness scan now re-runs on the far side of + the rename and undoes it if a raw same-profile launch appeared in the gap; + a same-profile provider in the launcher's own ancestor chain counts as a + live session (only shells and launch plumbing are excused); and the Claude + version proof is bound to the exact executable the shell then runs, an + armed export launches the proved realpath, never a re-resolved `claude`. + An alias path occupied by a regular file or symlink now refuses instead of + exporting an unusable name. +- Enrolment writes `autoContinueAtUsageLimit: false` even when the source + Claude profile has no settings file at all, the default is a promise about + every managed profile, not a transform applied only when there was + something to copy. +- The project-dir-name helper reads the same projects registry every other + consumer does (`SESSION_KIT_PROJECTS_FILE`, then the XDG location), and its + doctor honours `SESSION_KIT_ACCOUNT_ROOT`. The watchdog's lock-jam notice + no longer promises that ending the holder is universally safe; it says what + actually happens. + +### Fixed + +- A self-name could deadlock the whole estate: its write-time revalidation ran + a fresh collection while holding the name-store locks, and collecting can + re-acquire `config.lock` through a second descriptor, the process then + waits forever on its own lock while every collector, picker refresh and + name attempt queues behind it (observed live for twenty minutes on + 2026-08-17). Revalidation now re-reads only the process table; the one + snapshot is taken before any lock. +- The watchdog now detects that class of jam: a snapshot that has stopped + refreshing while a kit state lock has queued waiters is reported once, with + the holding pid and the safe remedy. +- `tests/run` drops itself to the lowest CPU and idle IO priority, so a full + suite on a live box can no longer starve the picker and sessions. + +### Changed + +- Enrolled Claude profiles now start with `autoContinueAtUsageLimit` off, so + a managed account never resumes spending by itself when a usage window + resets; turn it back on per profile in Claude's `/config`. +- The test sandbox guard now drops an inherited `CLAUDE_CONFIG_DIR`, + `CODEX_HOME`, and `CLAUDE_CODE_PROJECT_DIR_NAME` from fixture-homed + children, so a suite that starts a real provider CLI can no longer write + transcripts into a real account profile. + +## [0.4.1] - 2026-08-17 + +### Changed + +- The sub-agent sweep's background-shell pass was hardened after live audits: + any live descendant now refuses closure outright (even a sleeping zero-CPU + child), the harness-shell fingerprint is anchored (a marker inside arbitrary + `-c` text never qualifies), the shell's own CPU counters veto closure, a + shell owning its terminal's foreground process group is never touched, and + delivery pins and stops the shell, re-proves every fact while it is frozen, + then signals and resumes it. A closing signal that reached its target is + reported delivered even when the target is reaped before the resume step. +- A Claude-derived placeholder name (`nameSource: "derived"`) is never adopted + as a human rename: the automatic title now displays over it, and the kit's + title push updates per-PID session records inside every enrolled account + root, not only the ambient profile. Push lag ranks as pending, never as a + human rename; a real human rename still wins outright. +- The title push reaches a profile's transcript even when that profile has no + sessions registry yet; the sessions-directory narrowing had silently skipped + fresh account roots. + +### Fixed + +- The public CI suite is genuinely green: thirteen baseline test failures were + root-caused and repaired against the code's actual contracts, shellcheck is + scoped to real shell scripts by shebang, pty-dependent tests are pinned, and + the Python tree passes ruff and mypy clean. + +## [0.4.0] - 2026-08-16 + +### The picker is one screen again + +- Removed the supervisor UI, direct session messaging, and the event feed. + Session Kit now has one picker for day-to-day work and `sp` for commands; + system notices remain a separate guarded route. +- Unified visible language across the picker, commands, installer, doctor, + watchdog, and documentation. Refusals state the fact and the way forward; + cancellations say `Nothing changed.`; errors use the `session-kit:` prefix. +- Replaced the old attention wording with `needs you`, and replaced unreadable + model, account, and state values with the single placeholder `pending`. +- Added `question` for a Claude blocking prompt that is proven open right now. + It outranks `needs you`; Codex rows do not claim it until equally exact + provider evidence exists. +- Added transcript-aged `idle`. A session that would otherwise say `needs you` + becomes idle after its transcript path, size, and nanosecond modification + time stay unchanged for the configured window. The default is 30 minutes; + an invalid or unreadable setting disables the label instead of guessing. +- Standardized row order: ready before open elsewhere, then `question`, + `needs you`, `working`, and `idle` inside each availability group. +- Added child shells and workers at least one hour old to `sp detail`, including + the age of each exact live process. +- Made Enter take the likely choice on every screen. Home Enter opens the top + row or starts a new session on an empty list; New session defaults to Claude + Code; a session open elsewhere defaults to moving it here. `b` goes back on + every screen where it is not typed text. +- Kept a typed session number through close and other number-based actions, so + the result remains anchored to the selection after the list refreshes. +- Reordered footer segments by survival priority at narrow widths: Enter, + number selection, kill, new, more, needs you, help, history, then leave. + The Enter segment says `↵ open ` or `↵ new` according to the visible + list. +- Folded sub-agent sessions under their parent instead of listing each worker + as an ordinary session. Whole-machine totals still count them. +- Added bracketed-paste handling to picker prompts so a pasted block is one + literal input rather than a queue of commands. +- Made Ctrl-C abandon the current picker line cleanly. +- Preserved the filter, page, jump marker, and typed selection through an + action and refresh. A completed close reports what closed before repainting. +- Added waiting duration to rows that need attention, and made a resize repaint + without losing the current view. +- Removed process launches from the live typing loop. Filtering now stays + responsive even while the machine is busy. +- Added safe live-release pickup. A running picker notices a newly activated + release, re-execs the new launcher at a refresh boundary, and restores its + view. If the target launcher is degraded, the current picker stays running. + +### Workers close themselves conservatively + +- Added a dedicated five-minute sub-agent sweep. A finished provider worker is + eligible fifteen minutes after its own transcript stops moving, so normal + cleanup lands within about twenty minutes. +- Based worker activity on transcript size and nanosecond modification time. + CPU is deliberately not evidence: an idle provider process can continue to + consume CPU, while a long silent computation may not. A swept conversation + keeps its transcript and can respawn when continued; raise + `SESSION_KIT_SUBAGENT_IDLE_MINUTES` if a workload needs a wider window. +- Made an unreadable window disable the sweep. A rule change starts a fresh + clock, a close decision does not survive reboot, and no pre-rule observation + can satisfy a newer rule. +- Bound sweep candidates to the provider's worker packaging so a process + started by the user cannot be reached accidentally. +- Applied `~/.no_shpool_reaper` to this pass too, and added a dedicated + `subagent-sweep` doctor line showing the effective window and last pass. + +### Accounts and notices + +- Added guarded, opt-in account auto-switching for a conversation whose active + account has exhausted its usable quota. It never enables an account, moves a + conversation at most once, preserves a configurable reserve, requires the + live provider identity to agree with the record, and never downgrades a + model silently. Use `sp account-auto-switch --apply` to opt in. +- Marked enrolled Claude profiles as having completed provider first-run offers + so a new managed session does not stop on an unrelated setup prompt. +- Kept the Codex identity probe's input open until `account/read` answers. +- Added `session_kit_notice` as the route for system notices that are not a + session waiting for input. Terminal delivery requires one exact attached + session plus recent human input; otherwise a configured away transport is + used. With no transport, the notice is logged as unwired and the pass fails + visibly rather than claiming delivery. +- Batched one sweep into one message naming at most five sessions, and retained + delivery debt until a transport reports success. + +### Doctor, install, update, and rollback + +- Added `units-loaded`, `release-running`, `journals`, `transcripts`, and + `subagent-sweep` doctor checks. They distinguish installed definitions from + the code actually running and resolve recent provider transcripts through + every supported root. +- Preserved the rollback path when a release adds a systemd unit. +- Made activation refresh the service definitions it writes. Linux reloads the + user manager, enables only timers systemd has never seen, and try-restarts + the running watchdog; macOS refreshes an already-loaded kit watchdog. + The session manager is never restarted during activation. +- Precompiled and validated installed Python bytecode to reduce command startup + time without accepting a cache built for the wrong interpreter. +- Fixed release collection on systemd-user hosts and made every skipped or + failed pass produce a useful result. +- Preserved Claude status-line integration through install, update, rollback, + and uninstall. The installer records provider registrations in + `claude-integration.json`, keeps replaced settings in + `claude-statusline-backups.json`, restores the pre-existing value on removal, + and refuses an unrecognised local edit unless the operator explicitly uses + `--force`. +- Added collection-floor seeding for older installations. The installer copies + the exact proven current value once, does nothing when a valid floor already + exists, and refuses with an exact recovery command when it cannot establish a + safe seed. +- Made rollback validation use the target release's compatibility. When an old + validator cannot read a modern launch record, the supported escape is the + target release's own rollback tool, or waiting out or quarantining the record + before retrying. +- Kept the management launcher on the newest verified release so it can recover + an interrupted transaction even while an older runtime is selected. + +### Identity, colour, and provider lifecycle + +- Assigned an enrolled session's colour before its first visible provider + frame and pushed its name without requiring a restart. +- Bound every mutable action to the exact daemon generation that supplied the + proof. Daemon churn is never treated as absence, and termination is pinned to + the exact shell process rather than a reused name. +- Made `r` reopen any recoverable conversation and report the precise reason + when it cannot. A reopen re-proves the daemon generation immediately before + launch. +- Made provider-exit handling single-shot. A clean provider exit closes the + managed session and leaves its conversation in Closed sessions. A crash + reopens once; a second failure stops the loop and either records a recoverable + close or leaves the session open with the refusal reason. +- Made Ctrl-C cancel the provider-exit question and ensured a restored + conversation follows the same exit rules as a new one. +- Released attach snapshots after use and stopped background collectors on + every ordinary picker exit. +- Stopped recording an alert as delivered when its transport drops it. + +### History + +- Added incremental journal rendering through a terminal screen model, turning + raw recordings into settled readable text while retaining checkpoints. +- Added picker history for any visible row and made search prefer rendered + text, with a clear fallback when only the captured form is readable. +- Kept closed history reachable beyond the normal unattended size ceiling + through the explicit large-ledger commands. +- Preserved the operator's journal choice across update and rollover. + +### shpool patches + +- Added patch `0005`, which preserves the managed shell's exit status through + attach. +- Added patch `0006`, which coalesces bursts of client resize events. + +[0.4.3]: https://github.com/dob323/session-kit/releases/tag/v0.4.3 +[0.4.2]: https://github.com/dob323/session-kit/releases/tag/v0.4.2 +[0.4.1]: https://github.com/dob323/session-kit/releases/tag/v0.4.1 +[0.4.0]: https://github.com/dob323/session-kit/releases/tag/v0.4.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..24c988d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,134 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances + of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for +moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail +address, posting via an official social media account, or acting as an +appointed representative at an online or offline event. + +## Enforcement + +Report a concern privately through GitHub, using **Report abuse** on the +profile, comment, or repository involved: +. GitHub routes it to the maintainer +and to GitHub Support, so a report about the maintainer still reaches someone. +A report about a security vulnerability follows [its own process](SECURITY.md) +instead. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a2a87f..a24eefe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,21 +1,35 @@ # Contributing Session Kit welcomes focused bug reports, feature requests, documentation -fixes, and pull requests. +fixes, and pull requests. Everyone taking part is covered by the +[Code of Conduct](CODE_OF_CONDUCT.md). -The project is preparing a Linux-only `v0.1.0` beta. Reports about provider +The project ships Linux and macOS public betas. Reports about provider compatibility, lifecycle safety, privacy, and clean installation are especially -useful. +useful, and reports from operating systems, shells, and hardware the maintainer +cannot test are the most valuable of all. + +## What to expect + +Session Kit is maintained by one person alongside other work. Replies are +best-effort and there is no response-time commitment; a quiet issue has not been +rejected. Security reports follow [their own process](SECURITY.md) and are +handled ahead of everything else. + +A pull request may be declined even when the code is correct, if it would +weaken the identity and safety model, add a default that writes or transmits +data, or introduce behavior that cannot be proved from local evidence. Opening +an issue before a large change saves work on both sides. ## Before opening an issue Search existing issues, then collect: - the Session Kit tag or full commit; -- Linux distribution and version; +- operating system version and architecture; - Bash, Python, shpool, Claude Code, and Codex versions as relevant; - whether the problem affects display, reply state, opening, moving, provider - exit, kill, recovery, journals, cleanup, or health checks; + exit, close, recovery, journals, cleanup, or health checks; - sanitized expected and observed behavior. Remove credentials, hostnames, account names, private paths, titles, UUIDs, @@ -30,7 +44,19 @@ cd session-kit tests/run ``` -The tests use temporary fixtures and do not contact a live shpool daemon. +The tests use temporary fixtures and do not contact a live shpool daemon. That +includes the operator drill, which `tests/run` exercises and you can also run +on its own: + +```bash +tools/operator-drill # every step, against this checkout +tools/operator-drill --json # the same, machine-readable +``` + +It drives the real launch path inside a pty against a stub `shpool` in a +throwaway sandbox. It has no live mode: a real fixture daemon can answer the +fixture client and make its session names look authoritative; mixing those +names with another daemon's process tree can hide or misidentify live sessions. For shell changes: @@ -55,11 +81,16 @@ tools/public-scan . tools/public-scan . --git-history ``` -CI tests Ubuntu 22.04 and 24.04 with Python 3.10 through 3.13. It also checks -Bash syntax, ShellCheck, Ruff, Python syntax, the type baseline, branch +CI tests Ubuntu 22.04 and 24.04 with Python 3.10 through 3.13. Native macOS 15 +runners cover Apple Silicon and Intel with Python 3.13 and Homebrew Bash. CI +also checks Bash syntax, ShellCheck, Ruff, Python syntax, types, branch coverage, public export, local links, reachable history, the optional shpool patch, and release-artifact reproducibility. +The macOS CI lane proves the native process adapter and fixture-based lifecycle +behavior. It does not replace acceptance with a real shpool daemon and provider +installations on a persistent Mac account. + ## Pull requests - Keep one behavior change per pull request. @@ -67,12 +98,97 @@ patch, and release-artifact reproducibility. - Update docs for command, configuration, support, privacy, retention, or lifecycle changes. - Use `$HOME`, XDG paths, and example UUIDs instead of real local values. -- Preserve fresh identity proof and confirmation for every mutation. +- Preserve fresh identity proof and an exact-target safety display for every + mutation. - Explain active-session, provider-exit, cleanup, update, and rollback effects. -- Do not weaken Linux-only fail-closed checks. +- Preserve both Linux `/proc` and Darwin native process-generation checks. +- Keep platform-specific service operations explicit: systemd on Linux and + per-user launchd jobs on macOS. - Do not add telemetry, prompt logging, terminal logging, or notifications by default. +- Name roles, never people: no personal names in shipped files (see + [No personal names in shipped files](#no-personal-names-in-shipped-files)). + +## Shell review rules + +**Any `IFS=$'\t' read` on a record that can carry an empty field is the husk +bug again.** Tab is IFS whitespace, so a run of tabs collapses to one +separator and every empty field between them disappears; the fields after it +shift one place left and a record written perfectly is read as garbage. This +cost the project three launches in one night, every `sp new --model` without +`--launch-key` wrote an empty launch key, the generation fields shifted, and +the session came up as a shell with no provider. + +Use the `\034` translation idiom instead: turn the tabs into a non-whitespace +delimiter, then split on that. + +```bash +IFS= read -r line < "$record" +line=${line//$'\t'/$'\034'} +IFS=$'\034' read -r provider cwd model launch_key <<<"$line" +``` + +The pattern sites are in `bashrc/shpool.bashrc`, the start record, the +sidecar, and the launch record all read this way, each with a Python shape +check above it that refuses a record containing `\034` in the first place. + +`tests/test_tab_ifs_rule.py` enforces the rule: it finds every +`IFS=$'\t' read` in shell code and fails on any site that is not listed with +the reason it is safe. A trailing field that can be empty is safe; an empty +field with another field after it is not. `tools/operator-drill` carries the +original failure as a standing regression, on a stub that starts no daemon. + +A change that moves code between modules must not change behavior in the same +commit, and every symbol an existing test patches on `lib/session_inventory.py` +must stay reachable through it. See +[the modularization roadmap](docs/maintainers/modularization-roadmap.md) for the +compatibility contract and the patch-point ledger. Cutting a release is a +separate checklist: +[the maintainer release process](docs/maintainers/release-process.md). + +## No personal names in shipped files + +Everything the public export ships, code, comments, docstrings, tests, +identifiers, configuration, prompts, and docs, names roles, not people. Write +"the operator" or "the maintainer"; never a person's name. This covers +identifiers as much as prose: a keyword argument or JSON key with a name in it +ships just as publicly as a comment does. + +The same rule covers private account identifiers: real account aliases, +usernames, hostnames, and email addresses never ship, in fixtures least of all. +Test data uses neutral aliases and `@invalid.example` addresses. The project's +own public GitHub slug is not private and is expected to appear. + +This is enforced, not advisory. `tools/public-scan --private-markers` rejects a +release tree that contains a personal-name token, and it is the gate that +`tools/build-public-tree` runs on every export. The scanner stores the blocked +words only as one-way SHA-256 digests in `PRIVATE_TOKEN_DIGESTS`, so the +scanner itself stays safe to publish, and it tests each underscore-separated +part of an identifier as well as the whole token, `operator_confirmed` is +fine, a name in that position is not. + +The rule reaches published history too, and history published before the rule +existed cannot be rewritten out of clones and forks that already hold it. +`tools/public-scan-history-baseline` lists the blob object ids that were read +and accepted, and `tools/public-scan --git-history --baseline` skips those +exact objects. It skips nothing else: a credential in a listed blob still +fails, the working-tree scan ignores the baseline entirely, and an edited file +is a different object that fails again. Append to that list only after reading +the blob and only for something already published, a failure on a blob that +has not shipped means the tree still has to be fixed. + +Adding a new blocked word means adding its digest, not the word: + +``` +python3 -c 'import hashlib; print(hashlib.sha256(b"word").hexdigest())' +``` + +Attribution required by an upstream licence is the one exception: an upstream +copyright holder's name stays exactly as the licence requires. + +## Licensing By contributing, you agree that your contribution is licensed under the -[MIT License](LICENSE). Changes to the shpool-derived patch remain under -[Apache License 2.0](LICENSES/Apache-2.0.txt). +[MIT License](LICENSE). Changes to the shpool-derived patches remain under +[Apache License 2.0](LICENSES/Apache-2.0.txt). Third-party +attribution lives in [THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES). diff --git a/README.md b/README.md index 4f7ef4e..bab4abd 100644 --- a/README.md +++ b/README.md @@ -1,207 +1,342 @@ -# Session Kit +# Session Kit -Session Kit gives shpool users one clear view of Claude Code, Codex, and shell -sessions over SSH. It adds stable terminal numbers, useful names, reply alerts, -exact recovery, and guarded session actions. +[![CI](https://github.com/dob323/session-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/dob323/session-kit/actions/workflows/ci.yml) +[![Latest release](https://img.shields.io/github/v/release/dob323/session-kit)](https://github.com/dob323/session-kit/releases) +[![License](https://img.shields.io/github/license/dob323/session-kit)](LICENSE) -> [!WARNING] -> Session Kit is a Linux-only `v0.1.0` beta. Use it first on an account where -> every provider conversation can be recovered. macOS and non-systemd systems -> fail closed and are not supported. +**Close the terminal windows, not the AI coding sessions. One number takes you back in.** -## What the dashboard shows +Every Claude Code and Codex session in one menu: named, numbered, colour-coded. Open, close, and jump to whichever needs you. -```text - 4 sessions · 1 ready here · 3 open elsewhere +The sessions keep running on the host whether or not you are watching them. - Ready to open - Needs your reply · Codex - 4 Review Login Errors | ! needs your reply +

+ The Session Kit picker: seven sessions grouped into Ready and Open elsewhere, each row showing number, name, provider, account, model, state, and last activity +

- Open elsewhere - Claude - 1 Improve Session Picker | working - Codex - 3 Document Release Process | quiet 2h 18m +

+ kit → see what needs you → type its number → back in the session. +

- Open: number · New: n · Kill: k number - Terminal: Enter · Search: /text · Help: ? -``` +

+ Install · + The picker · + Safety model · + Accounts · + Delegated work · + How it works · + Documentation +

-Colors have one meaning throughout the picker: provider headings identify the -provider, availability headings identify where a session can open, warnings -mark attention or risk, and muted text carries secondary status. Set -`NO_COLOR=1` or `SESSION_KIT_NO_COLOR=1` to disable color. +> **Public beta, `v0.4.3`.** Linux with systemd, or macOS 14 and newer. I use Session Kit daily, and the beta moves quickly, so expect to update. Full detail in [Beta and support](#beta-and-support). -Normal rows omit internal shpool IDs and provider UUIDs. Use `sp detail`, JSON -output, or an explicit search when troubleshooting requires an exact identity. -Display numbers and titles are convenient selectors, not recovery authority. +## Why I built it -## What it provides +I run several Claude Code and Codex sessions at the same time. Keeping them alive turned out to be the easy part. Remembering which one was doing what, which one had finished, and which one had been sitting there waiting on me the whole time was not. I kept opening window after window just to find the session that had asked me something. -- A compact SSH login picker grouped by availability and provider. -- `needs your reply` for unresolved structured Claude Code and Codex questions. -- Stable terminal numbers for the current host boot. -- Manual names and guarded automatic 2–5 word task names. -- Exact Claude Code and Codex recovery by conversation UUID. -- Confirmed open, move, kill, repair, recovery, and cleanup actions. -- A persistent managed terminal when Claude Code or Codex exits. -- Optional local terminal journals, off by default. -- Local, report-only health checks. -- Immutable installed releases with rollback. +Session Kit is the small home screen I wanted for that. I use it every day, on a local machine and over SSH to the host where the work actually runs. -When a provider exits, the shpool terminal stays alive. The dashboard shows that -the provider exited and offers a clear path to reopen the exact conversation or -close the terminal. Session Kit never substitutes the newest conversation or a -matching directory. +It is a passion project. There is no company behind it, no paid tier, and nothing to buy. It is MIT licensed, it collects nothing, and it talks to no server of mine. I published it because it fixed a problem I hit every single day, and if you hit the same one, it will probably fix yours. -Automatic close begins only after the cleanup timer is enabled and the same -exact safe state has been observed continuously for 72 hours. Cleanup requires -the exact terminal generation, provider identity, exited state, no attachment, -no child work, no pending reply, and no changed evidence. Any missing or -changed predicate keeps the terminal. +## What it does -## Supported target +- **See every session in one list.** Claude Code and Codex together, on one screen. +- **Know what needs you.** `question`, `needs you`, `working`, and `idle` make the next session to check obvious. +- **Jump back in by number.** Every session keeps a stable number that does not move. +- **Survive terminal and SSH disconnects.** The session keeps running on the host. +- **Know which window is which.** Sessions name and colour themselves, and the name, number, and colour follow the session into its own window. +- **Use a different account per session.** The subscription belongs to the session, not to the terminal that launched it. +- **Get a closed conversation back.** Closing a session records the exact conversation for restore. +- **Keep delegated work apart.** A machine-origin session gets its own git worktree on its own branch, and hands it back when it closes. +- **Stay local.** No hosted account, no analytics, no telemetry, no update beacon. -The beta supports: +## Is this for you? -- Linux with `/proc` and systemd user services; -- Bash; -- Python 3.10 through 3.13; -- Ubuntu 22.04 and 24.04 in CI; -- shpool 0.11.0; -- Claude Code, Codex, and ordinary shpool shells. +If you regularly have enough sessions open that you lose track of which one needs you, this was built for exactly that, and you will feel it on the first run. -macOS, other init systems, other shells, containers without the required -process view, and multi-user hostile-account isolation are not supported. -Unsupported platforms stop before installation or mutation. +If you usually keep one or two sessions going, you probably do not need it yet. -Until `v0.1.0` is tagged, only the current `main` branch receives fixes. The -first beta will publish the exact provider versions used in release acceptance. -See [Security policy](SECURITY.md) for reporting and version support. +It works the same on a local workstation or on a remote host over SSH. -## Privacy +## Simple on purpose -Session Kit is local software. It has no account, hosted service, analytics, -update beacon, or telemetry. +The interface is deliberately small: open `kit`, see what needs you, press a number. -The default is privacy-minimal: +More is going on underneath. Before Session Kit changes a live session, it re-proves that the session is still the exact provider conversation and the exact process it expects. If it cannot prove that, it refuses instead of guessing. -- terminal journals are off; -- notifications are off; -- the local picker-action log contains only fixed action and outcome labels, - time, and schema version, with no session identity or content; -- normal dashboard rows hide internal IDs; -- provider transcripts stay in provider-owned storage. +The picker is a view of your sessions. It is never trusted as evidence of which live session an action should affect. That distinction is the whole design, and [Safety model](#safety-model) has it in full. + +## Install -The picker-action log is owner-only, keeps at most 1,000 records and 256 KiB, -and removes records older than seven days on each append. +Session Kit installs per user, on the Linux or macOS machine where the work runs. -Optional journals record terminal bytes and can contain prompts, credentials, -source code, and command output. Enable them only after choosing a retention -policy. Read [Security and local data](docs/security-and-data.md). +### Installing with an AI assistant -## Install +The shortest path, if Claude Code, Codex, or another terminal agent is already open: give it this. + +> Install Session Kit from `https://github.com/dob323/session-kit`. Use the latest release artifact, not a clone of `main`. Download the release archive with its `.sha256` and `.provenance.json` files, verify the checksum, extract it, and run `./install.sh --check` first. Fix only the remedies that preflight explicitly names. Then run `./install.sh`, `session-kit doctor`, `session-kit services enable`, and `session-kit doctor` again. Never bypass a refused step. Show me the final doctor output and finish by telling me to type `kit`. + +### Installing it yourself + +Artifacts are named by the exact commit they were built from, so there is no fixed download URL. This asks the release API which files belong to the current release, checks what arrived, and only then unpacks it. It needs nothing but Python 3 and `tar`, both of which the install needs anyway. ```bash -git clone https://github.com/dob323/session-kit.git -cd session-kit +mkdir session-kit-download +cd session-kit-download + +python3 - <<'PY' +import json, urllib.request +url = "https://api.github.com/repos/dob323/session-kit/releases/latest" +with urllib.request.urlopen(url) as response: + release = json.load(response) +for asset in release["assets"]: + urllib.request.urlretrieve(asset["browser_download_url"], asset["name"]) + print("downloaded", asset["name"]) +PY + +if command -v sha256sum >/dev/null; then + sha256sum --check session-kit-*.sha256 +else + shasum -a 256 --check session-kit-*.sha256 +fi + +tar -xzf session-kit-*.tar.gz +cd session-kit-*/ + ./install.sh --check -tests/run ./install.sh + +session-kit doctor +session-kit services enable session-kit doctor ``` -The preflight is read-only. Installation copies files and systemd user-unit -definitions but does not start, stop, restart, or enable a service. The guided -installer asks about the SSH picker and optional journals; journals default to -off. Noninteractive installation also leaves login integration and journals -off unless explicitly enabled. - -Requirements and safe activation steps are in [Install Session Kit](docs/install.md). - -## Everyday commands - -```text -sp list -sp new [claude|codex|shell] [project-alias] -sp go -sp takeover -sp name -sp name reset <terminal-number|shpool-id> -sp close <terminal-number|shpool-id> -sp repair <terminal-number|shpool-id> -sp detail <terminal-number|shpool-id> -sp find <text> -sp history <terminal-number|shpool-id> -sp health -sp recover -sp prune -``` +`./install.sh --check` is read-only. Do not work around a refusal. Session Kit prints the reason and the remedy it expects. + +### Requirements -In the SSH picker, `k <number>` means kill the exact displayed session. It -always shows the resolved title, provider, and exact internal ID and requires -confirmation before changing anything. `x <number>` remains a compatibility -alias. A cached or stale dashboard disables actions. +- shpool `0.11.0`, the stock build. The optional patches in [`shpool-patch/`](shpool-patch/) are **not** needed to install or to start using this; that decision can wait until something makes you want them +- Claude Code, Codex, or both +- one trusted Unix account, with per-user service access -See [Use Session Kit](docs/usage.md) for the complete behavior. +**Linux** additionally needs a readable `/proc`, a systemd user manager, Bash 4+, and Python 3.10+. +**macOS** additionally needs macOS 14+, an active desktop login for the per-user launchd GUI domain, Homebrew Bash 4+, and Python 3.11+. -## Configuration and local data +Prefer the GitHub CLI, installing without a network path to the API, or checking the provenance file by hand? [Install Session Kit](docs/install.md) has every route, plus supported shpool paths, provider setup, project import, and activation. -Session Kit follows XDG directories: +## First run -```text -${XDG_CONFIG_HOME:-$HOME/.config}/session-kit/ -${XDG_STATE_HOME:-$HOME/.local/state}/session-kit/ -${XDG_STATE_HOME:-$HOME/.local/state}/shpool-journal/ +```bash +kit ``` -Configuration and state can contain titles, working directories, process -metadata, UUIDs, and action receipts. Keep private directories mode `0700` and -private files mode `0600`. +New session defaults to Claude Code. A session can also be started directly: + +```bash +sp new claude +sp new codex +``` + +Project aliases, provider choice, account selection, and configured models make those launches more specific. See [Projects](docs/projects.md) and [Use Session Kit](docs/usage.md). + +## The picker + +Ready sessions come first. Sessions already attached to another window appear under **Open elsewhere**. Within a group, attention state determines the first part of the order, followed by provider and activity. + +The state words are deliberately small and literal: + +| State | Meaning | +|---|---| +| `question` | Claude has a blocking prompt open now. Codex does not claim this state yet. | +| `needs you` | The provider finished its turn and is waiting for you. | +| `working` | The provider is driving the current turn. | +| `idle` | A needs-you transcript has not moved for the configured idle window. | +| `pending` | A launch that has not finished, or a value Session Kit cannot currently read. It is not a fifth state. | -Start with: +One limit worth stating plainly: Claude Code reports a blocking prompt the moment it opens one, so `question` is exact. Codex does not expose that yet, so a Codex session reads `needs you` when its turn ends rather than the instant it asks you something. -- [Inventory configuration](config/session-inventory.example.json) -- [Project aliases](config/projects.example.tsv) -- [shpool configuration](config/shpool.example.toml) +Pressing `a` narrows the list to just those sessions, with how long each has +waited. + +<p align="center"> + <img src="docs/assets/readme/needs-you.png" alt="The needs-you screen: a count of four, then the four sessions waiting on a reply, each with its number, name, provider, state, and how long it has waited" width="100%"> +</p> + +The home screen keeps the common actions one key away: + +| Input | Action | +|---|---| +| `Enter` or a session number | Open the first visible session, or the numbered session. | +| `k <numbers>` | Close one or more visible sessions. Lists and ranges work. | +| `n` | Start a new session. | +| `m` | Open More. | +| `a` | Show everything that needs you. | +| `h <number>` | Read settled history without opening the session. | +| `?` | Show picker help. | +| `b` or `q` | Leave the home screen for an ordinary shell. | + +A session that is open elsewhere defaults to **Move it here** after a fresh identity check. The earlier window returns to its picker, and the provider conversation is not duplicated. + +Every session also names and colours itself. It takes a short title from its own first piece of work, keeps a number that does not move, and gets a colour no other live session has. All three follow the session into its own window: the tab title carries the name and number, and the session is tinted its colour inside Claude Code and Codex themselves. So the window you are typing in tells you which session it is, and the picker and the session never disagree. + +Press `?` for the full key reference: filtering, ranges, grouping, forking, renaming, and `g` to jump to the next session that needs you are all there. See [Picker navigation](docs/picker-navigation.md) for the cursor-driven picker, mouse behavior, action panels, machine sessions, and closed-session restore. ## Safety model -Before a mutation, Session Kit rechecks the daemon generation, shpool terminal -generation, process start times, provider ancestry, and conversation UUID. A -stale, ambiguous, partial, or unsafe identity is refused. +Session Kit deliberately separates **what you see** from **what it trusts**. + +<p align="center"> + <img src="docs/assets/readme/safety-model.png" alt="Four steps: a frozen snapshot, binding an action proof to the exact provider UUID and generation, rechecking live identity immediately before acting, then acting or refusing" width="100%"> +</p> + +1. **Provider UUID plus exact process generation is identity.** +2. Session number, title, directory, timestamps, and terminal output are display context. +3. Every mutation rechecks live identity immediately before it runs. +4. Missing, stale, partial, duplicated, or conflicting evidence fails closed. +5. A refusal changes nothing. + +Before a proof-bound action, Session Kit can bind and recheck the session manager, terminal generation, managed shell, provider process and ancestry, exact provider conversation UUID, and frozen snapshot generation. The proof is owner-only and short-lived. + +This is the part worth caring about: a picker one second out of date still cannot act on the wrong session, because the picker was never the evidence. + +It protects against stale or ambiguous picker state selecting a different session than the one you intended. It does **not** isolate mutually hostile processes running with your own Unix-user privileges. -These checks protect against stale dashboard state. They are not a security -boundary against another process running as the same Unix user. +Read [Security and local data](docs/security-and-data.md) and [Architecture](docs/architecture.md) for the complete trust model. + +## What it does not do + +- **No diff review.** It will not show you what a session changed. That stays in git and your editor. +- **No cost or spend accounting.** It can read the provider's own quota percentages and tell you when one runs out, but it does not price or count tokens. +- **Not a multiplexer.** It runs on [shpool](https://github.com/shell-pool/shpool) and does not replace tmux, or try to. +- **Not a security boundary.** It protects you from acting on the wrong session, not from a hostile process running as you. + +### Why not tmux? + +tmux keeps processes alive, which is most of the way there, and it is already on your machine. What it cannot tell you is which of eleven panes is waiting on an answer. It has no notion of which conversation a pane holds, so nothing stops you acting on the pane next to the one you meant. + +Session Kit knows both. Every session carries the identity of its provider conversation, and that identity is re-proved against the live system before anything is changed. + +It runs on [shpool](https://github.com/shell-pool/shpool) rather than tmux, so there is one more thing to install first. That was the trade: shpool gives a clean session per shell without fighting multiplexer semantics, and that is what makes a session's identity provable at all. + +## Accounts and subscriptions + +One machine, several logins for the same provider, one per session. Enrol each account once and it becomes a choice at launch: + +```bash +sp account enroll claude work you@example.com +sp account list claude +``` -The installed watchdog defaults to report-only mode. It does not attach, move, -kill, restart, or repair sessions in that mode. An advanced repair mode remains -an explicit opt-in and can close and relaunch a proved-broken terminal; review -[Security and local data](docs/security-and-data.md) before considering it. +Each enrolled account keeps its own provider configuration directory, so a session started on it authenticates as that account and no other. Three Claude Code subscriptions can be running in three sessions at the same moment, and the picker shows which account each session belongs to, the `personal` and `work` column in the screenshot above. + +This is the part most session tools do not do. A terminal multiplexer inherits whichever login the shell that started it happened to have, so every window shares one subscription. Here the account is a property of the session. + +Provider authentication stays in provider-owned storage throughout. Each session launches the provider's own binary against its own configuration directory. Session Kit does not ask for, copy, print, or log provider tokens, and it does not put a subscription token into any harness of its own. + +### Carrying a conversation to another account + +There is a mechanism that can move one idle conversation to another enrolled account when its weekly quota runs out. **It is off, and it stays off until you turn it on.** The watchdog runs in `report` mode by default and only says what it would do; automatic changes require setting `SESSION_KIT_WATCHDOG_MODE=repair` deliberately. + +Leave it off unless you have a reason. Owning several subscriptions and using each for your own work is ordinary use. Moving work between accounts *because a limit was reached* is a different shape, and it is the shape providers look for when they enforce against limit evasion, and the consequence lands on your account, not on this tool. `sp account-auto-switch <session>` shows you what would happen without doing it. + +Read [Configure Session Kit](docs/configuration.md) for enrolment, verification, and the carry-over rules in full. + +## Delegated work + +A session started as machine-origin is given its own git worktree, on a branch the kit cuts for it, so two workers on one repository never edit the same files. Any session can ask for one by branch: + +```bash +sp new claude --worktree <branch> +``` + +Closing the session gives the copy back, and every close does it: `sp close`, the picker's `k`, `bye` or a clean provider exit, and the scheduled cleanup pass. The copy is kept instead, with the reason named out loud, whenever giving it back could lose work: uncommitted, staged or untracked files, ignored files that were created there, a commit not yet in the reference, somebody still working in the directory, or a check that could not run at all. + +A directory that is not a git repository has nothing to isolate, and a person's own session is never moved out of the directory they chose. See [Use Session Kit](docs/usage.md) for the complete rules. + +## How it works + +<p align="center"> + <img src="docs/assets/readme/how-it-works.png" alt="Four steps: an ordinary shell runs kit, the picker chooses a session, the session runs on the host inside shpool, and disconnecting then returning picks the same session back up" width="100%"> +</p> + +Install Session Kit where the work actually runs. + +- On a local workstation, sessions survive closing the terminal window. +- On a remote host, they also survive the SSH client disconnecting. +- The laptop you connect from can sleep, disconnect, or shut down. +- The host must stay powered on and awake for the processes to keep running. +- A host reboot ends them. Recoverable Claude Code and Codex conversations remain available through **Closed sessions**. + +There is no Session Kit server to deploy. + +## Claude Code and Codex + +Session Kit does not replace either provider. + +For **Claude Code**, it can supply the installed status line, session name and color integration, exact conversation identity, account profiles, guarded resume behavior, and optional attention evidence. + +For **Codex**, it leaves the Codex status bar under Codex control, and supplies per-launch terminal-title items and the session theme without editing `~/.codex/config.toml`. + +Read [Claude Code and Codex integration](docs/provider-integration.md) for the exact contracts. + +## Local data and privacy + +No hosted service, no Session Kit account, no analytics, no update beacon, no telemetry. + +By default: + +- terminal journals are off; +- external notifications are off; +- provider transcripts stay in provider-owned storage; +- the picker action log stores fixed action and outcome labels, not terminal contents; +- private Session Kit state is owner-only and is never uploaded. + +Optional history can contain prompts, source code, command output, credentials, and other sensitive terminal content. Read [Security and local data](docs/security-and-data.md) before enabling it. + +## Running it + +```bash +session-kit doctor # what is installed, what is wrong, and what to do +sp help # every command, with exit codes and selectors +``` + +`doctor` is the first thing to run when anything looks wrong. Updates install an immutable local release and move `current` atomically; rollback selects a verified release already on the machine. Read [Update and roll back](docs/update-and-rollback.md) before changing releases, [Configure Session Kit](docs/configuration.md) for colour, terminal titles and every setting, and [Troubleshooting](docs/troubleshooting.md) when `doctor` names something you have not met. + +Session Kit runs on [shpool](https://github.com/shell-pool/shpool) and neither vendors nor replaces it. The repository carries optional shpool patches with their scope and checks; `doctor` records the shpool binary validated at installation and reports when it later changes. Read [`shpool-patch/`](shpool-patch/) before choosing a patched binary. + +## Beta and support + +**Settled:** the picker, session identity and the guards around every action, install, update and rollback, and the Claude Code and Codex integrations. Around 2,700 tests run in CI, with native Linux and macOS coverage. + +**Not settled:** the beta moves quickly, thirteen releases in its first three weeks, so expect to update. Codex does not report a blocking prompt yet. + +**What is promised:** this is a tool its author uses daily and publishes; it is not a supported product. Replies are best-effort, security fixes go into the current release with no dated windows and no back-ports, and nothing here is a security boundary against another process already running as your Unix user. Start on a single trusted Unix account where provider conversations can be recovered. ## Documentation -- [Install](docs/install.md) -- [Configure](docs/configuration.md) -- [Use Session Kit](docs/usage.md) -- [Claude Code and Codex integration](docs/provider-integration.md) -- [Security and local data](docs/security-and-data.md) -- [Troubleshoot](docs/troubleshooting.md) -- [Update and roll back](docs/update-and-rollback.md) -- [Uninstall](docs/uninstall.md) -- [Architecture](docs/architecture.md) -- [Maintainer release process](docs/maintainers/release-process.md) +**[Documentation index](docs/)**, every document grouped by what you are trying to do. + +The ones people reach for first: + +| Topic | Document | +|---|---| +| Install | [docs/install.md](docs/install.md) | +| Use Session Kit | [docs/usage.md](docs/usage.md) | +| Configure | [docs/configuration.md](docs/configuration.md) | +| Picker navigation | [docs/picker-navigation.md](docs/picker-navigation.md) | +| Troubleshooting | [docs/troubleshooting.md](docs/troubleshooting.md) | +| Security and local data | [docs/security-and-data.md](docs/security-and-data.md) | +| Architecture | [docs/architecture.md](docs/architecture.md) | +| Release history | [CHANGELOG.md](CHANGELOG.md) | -## Contributing and security +## Contributing and support -Issues and pull requests are welcome. Read [Contributing](CONTRIBUTING.md). -Report vulnerabilities through the private process in -[Security policy](SECURITY.md), never through a public issue. +Bug reports, documentation fixes, and pull requests are welcome. Changes that weaken the identity or safety model may be declined. + +Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. Report vulnerabilities through [SECURITY.md](SECURITY.md), not a public issue. Participation is covered by the [Code of Conduct](CODE_OF_CONDUCT.md). ## License -Session Kit is released under the [MIT License](LICENSE). The optional shpool -patch modifies Apache-2.0 software; see -[Third-party notices](THIRD_PARTY_NOTICES) and the included -[Apache License 2.0](LICENSES/Apache-2.0.txt). +Session Kit is released under the [MIT License](LICENSE). + +The optional shpool patches modify Apache-2.0 software. See [THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES) and the included license material for those components. diff --git a/SECURITY.md b/SECURITY.md index 97ec128..290315e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,12 +2,23 @@ ## Supported versions -Before `v0.1.0`, only the current `main` branch receives security fixes. +Session Kit is maintained by one person alongside other work, so this policy +says only what one person can actually keep. -After the first beta tag, each beta minor line is supported for 90 days after -its first release or for 30 days after the next beta minor release, whichever -ends later. Security fixes may require the latest patch release. Unsupported -platforms, including macOS, are outside this policy. +Security fixes go into the current release. There are no dated support windows +and nothing is back-ported to an older line: if you are running an older +release, the fix is to move to the current one. + +`releases/latest` resolves to the current beta. +[All releases](https://github.com/dob323/session-kit/releases) lists it and +everything published before it. Earlier lines stay published so that whatever +you already downloaded keeps its checksum and provenance files beside it, not +because those lines still receive fixes. + +The current release supports its documented Linux targets and macOS 14 or newer +on Apple Silicon and Intel. Other operating systems, older macOS releases, Apple +Bash 3.2, and service arrangements outside the documented systemd and per-user +launchd models are outside this policy. ## Report privately @@ -24,7 +35,7 @@ request for a private contact channel. Do not describe the vulnerability. Include privately: - affected tag or full commit; -- Linux and dependency versions; +- operating system, architecture, and dependency versions; - the security boundary involved; - minimal reproduction with secrets removed; - whether a live session or private data can be changed or exposed; @@ -42,6 +53,11 @@ provider, shpool, configuration, and private Session Kit state. It does not isolate hostile processes running as the same Unix user. Such a process may read owner-accessible terminal state or edit owner-writable files. +On macOS, Session Kit uses a per-user LaunchAgent in the logged-in GUI user's +domain. It does not install a privileged system daemon and does not provide a +service before that user logs into the Mac desktop. Watchdog repair is +Linux-only; the macOS watchdog remains report-only. + Optional terminal journals are off by default because they may contain credentials, source code, prompts, and terminal output. Read [Security and local data](docs/security-and-data.md) before enabling them. diff --git a/SOURCE.json b/SOURCE.json index 33291ed..d26805a 100644 --- a/SOURCE.json +++ b/SOURCE.json @@ -1,5 +1,5 @@ { - "exported_files": 93, + "exported_files": 315, "schema_version": 1, - "source_commit": "d55fdf599c10458ac0987ae83e4ff9983db3f4ab" + "source_commit": "9a7af3a1e2c38eb8050afa8be018780ea6f777f2" } diff --git a/THIRD_PARTY_NOTICES b/THIRD_PARTY_NOTICES index c0317e7..f0b5597 100644 --- a/THIRD_PARTY_NOTICES +++ b/THIRD_PARTY_NOTICES @@ -4,16 +4,24 @@ Third-party notices shpool ------ -Session Kit works with shpool and includes an optional source patch against +Session Kit works with shpool and includes optional source patches against shpool 0.11.0: shpool-patch/0001-heartbeat-ack-timeout-is-not-fatal.patch + shpool-patch/0002-restore-input-modes-on-reattach.patch + shpool-patch/0003-kill-tolerates-an-already-dead-shell.patch + shpool-patch/0004-detach-must-not-hold-the-shells-lock.patch + shpool-patch/0005-attach-must-not-discard-the-shells-exit-status.patch + shpool-patch/0006-coalesce-client-resize-bursts.patch Upstream: https://github.com/shell-pool/shpool +Base revision: v0.11.0 == fe2d11595ff255810523b0868159dec051e303f1 +Copyright 2023 Google LLC, per the upstream source headers. -shpool is licensed under the Apache License, Version 2.0. The patch modifies -the corresponding upstream source and remains subject to that license and -upstream notices. +shpool is licensed under the Apache License, Version 2.0. The patches modify +the corresponding upstream source and remain subject to that license and +upstream notices. Each patch file carries a comment header naming the upstream +project, its license, the base revision, and what that patch changes. Session Kit does not include the shpool source tree or a shpool binary. diff --git a/bashrc/shpool.bashrc b/bashrc/shpool.bashrc index 3b1ddbb..3d12cb0 100644 --- a/bashrc/shpool.bashrc +++ b/bashrc/shpool.bashrc @@ -2,6 +2,11 @@ # ---- session-kit shpool integration ----------------------------------------- # Source this block from ~/.bashrc. Installed releases keep this file immutable. +if [[ ${__SESSION_KIT_SHPOOL_BASHRC_LOADED:-0} == 1 ]]; then + return 0 +fi +__SESSION_KIT_SHPOOL_BASHRC_LOADED=1 + export PATH="$HOME/.cargo/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" # Keep AI TUI output in normal terminal history. The session journal is the @@ -9,8 +14,10 @@ export PATH="$HOME/.cargo/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin: export CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 __sk_state_root=${XDG_STATE_HOME:-"$HOME/.local/state"} +__sk_kit_state=${SESSION_KIT_STATE_DIR:-"$__sk_state_root/session-kit"} __sk_journal_root=${SESSION_KIT_JOURNAL_DIR:-"$__sk_state_root/shpool-journal"} + # A disabled journal is still a launch-ready shell. Mark it explicitly so the # one-shot provider record below is consumed without starting `script`. if [[ -n ${SHPOOL_SESSION_NAME:-} && -z ${SHPOOL_JOURNAL:-} && $- == *i* \ @@ -40,11 +47,6 @@ if [[ -n ${SHPOOL_SESSION_NAME:-} && -z ${SHPOOL_JOURNAL:-} && $- == *i* && -t 1 exec bash -i fi if [[ $(uname -s 2>/dev/null) == Darwin ]]; then - if [[ ${SESSION_KIT_MACOS_PREVIEW:-0} != 1 ]]; then - echo "[session-kit: macOS preview is disabled; continuing without capture]" >&2 - export SHPOOL_JOURNAL=disabled - exec bash -i - fi script -q -F -a "$SHPOOL_JOURNAL" bash -i else script -qfa "$SHPOOL_JOURNAL" -c "bash -i" @@ -60,23 +62,91 @@ if [[ -n ${SHPOOL_SESSION_NAME:-} && -z ${SHPOOL_JOURNAL:-} && $- == *i* && -t 1 fi if [[ -n ${SHPOOL_SESSION_NAME:-} && $- == *i* ]]; then + # A refusal that only reaches this session's screen is invisible the moment + # the session is closed. Three launches started nothing one night and the + # only way to know WHY was to attach before somebody closed them. Every + # decision this block makes now lands on the action log, named by session. + __sk_log_launch() { + local __sk_log_core=${SESSION_KIT_INVENTORY_CORE:-${__sk_inventory_core:-}} + if [[ -z $__sk_log_core ]]; then + # The earliest refusals happen before the record block resolves the + # release, and those are exactly the ones nobody could diagnose. Derive + # it the same way that block does, from this file's own location. + local __sk_log_root + __sk_log_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd -P) || + __sk_log_root="" + [[ -z $__sk_log_root ]] || __sk_log_core=$__sk_log_root/lib/session_inventory.py + fi + [[ -n $__sk_log_core && -f $__sk_log_core ]] || return 0 + python3 "$__sk_log_core" action-log launch "$1" \ + --session "$SHPOOL_SESSION_NAME" >/dev/null 2>&1 || true + } __sk_start_dir=${SESSION_KIT_START_DIR:-"$__sk_state_root/shpool-start"} __sk_start="$__sk_start_dir/$SHPOOL_SESSION_NAME" __sk_expected="$__sk_start.expected" + __sk_launch="$__sk_start.launch" + __sk_account="$__sk_start.account" + # The shell can start before `sp` has WRITTEN the main record at all, the + # session exists the moment shpool spawns it, and on a loaded box the + # launcher's first write can land a second later. A one-shot existence + # check here made that launch permanently silent. The wait is bounded so a + # session with no launcher (a bare shpool attach) costs three seconds once. + if [[ -n ${SHPOOL_JOURNAL:-} && ! -r $__sk_start ]]; then + for __sk_arm_attempt in {1..30}; do + [[ -r $__sk_start ]] && break + sleep 0.1 + done + fi if [[ -n ${SHPOOL_JOURNAL:-} && -r $__sk_start ]]; then # The shell can start before `sp` has captured its exact generation. Wait # only for an atomically written sidecar; an unarmed or stale main record # never launches a provider. - for __sk_arm_attempt in {1..30}; do + # 3 seconds covers a healthy launch; a loaded box can take longer to arm + # (a lost-by-milliseconds launch once left a relaunch hanging on a + # plain shell). At the 3-second mark, a FRESH main record proves a launch + # is still in progress, and only that case earns the longer wait, a + # stale record still costs later shells 3 seconds, not 30. + for __sk_arm_attempt in {1..300}; do [[ -r $__sk_expected ]] && break + if (( __sk_arm_attempt == 30 )); then + __sk_arm_started=$(stat -c %Y "$__sk_start" 2>/dev/null || + stat -f %m "$__sk_start" 2>/dev/null || echo 0) + (( $(date +%s) - __sk_arm_started < 60 )) || break + fi sleep 0.1 done + unset __sk_arm_started + # An exhausted wait means sp died between writing the start record and + # arming it. Every later shell in this session would silently stall 3s + # here; say it once instead. + # + # Except for a shell session, where a plain shell IS what was asked for: + # `sp new shell` arms its record and then clears it once the shell is + # open, so a missing sidecar there is the ordinary end of a healthy + # launch. Warning about it called a working session broken. + if [[ ! -r $__sk_expected ]]; then + __sk_start_provider= + if [[ -r $__sk_start ]]; then + IFS=$'\t' read -r __sk_start_provider __sk_start_rest < "$__sk_start" || + __sk_start_provider= + fi + if [[ -r $__sk_start && $__sk_start_provider != shell ]]; then + echo "[session-kit: launch record incomplete; starting a plain shell]" + __sk_log_launch refused_record_incomplete + fi + unset __sk_start_provider __sk_start_rest + fi fi if [[ -n ${SHPOOL_JOURNAL:-} && -r $__sk_start && -r $__sk_expected ]]; then __sk_provider= __sk_cwd= __sk_uuid= __sk_launch_mode= __sk_side_provider= __sk_side_cwd= __sk_side_uuid= __sk_side_launch_mode= __sk_boot_id= __sk_started= __sk_shell_pid= __sk_shell_start= __sk_daemon_pid= __sk_daemon_start= + __sk_requested_model= __sk_launch_key= + __sk_launch_provider= __sk_launch_cwd= __sk_launch_boot= + __sk_launch_started= __sk_launch_shell_pid= __sk_launch_shell_start= + __sk_launch_daemon_pid= __sk_launch_daemon_start= + __sk_launch_record_ok=1 __sk_record_shape_ok=0 if python3 - "$__sk_start" "$__sk_expected" <<'PY' import sys @@ -124,10 +194,60 @@ PY fi __sk_inventory_core=${SESSION_KIT_INVENTORY_CORE:-$__sk_release_root/lib/session_inventory.py} unset __sk_release_root + if [[ -e $__sk_launch || -L $__sk_launch ]]; then + __sk_launch_record_ok=0 + if [[ -f $__sk_launch && ! -L $__sk_launch ]] && + python3 - "$__sk_launch" <<'PY' +import os +import stat +import sys +path = sys.argv[1] +info = os.lstat(path) +with open(path, "rb") as stream: + payload = stream.read(8193) +if ( + not stat.S_ISREG(info.st_mode) + or info.st_uid != os.geteuid() + or stat.S_IMODE(info.st_mode) != 0o600 + or not payload + or len(payload) > 8192 + or not payload.endswith(b"\n") + or payload.count(b"\n") != 1 + or payload[:-1].count(b"\t") != 9 + or b"\r" in payload + or b"\x1c" in payload +): + raise SystemExit(1) +PY + then + # TAB IS IFS WHITESPACE, so `IFS=$'\t' read` collapses a run of + # tabs and drops the empty fields between them. This record's + # fourth field is the launch key, and it is EMPTY for every launch + # nobody passed --launch-key to. The six generation fields then + # shift one place left: the boot id lands in the key, the daemon + # start lands nowhere, and the cross-check below refuses a record + # that was armed perfectly. Every `sp new --model` without a key + # ended as a shell with no provider because of this line. + # The records read above avoid it by switching to a + # non-whitespace delimiter first; this one does the same. + IFS= read -r __sk_launch_line < "$__sk_launch" + __sk_launch_line=${__sk_launch_line//$'\t'/$'\034'} + IFS=$'\034' read -r __sk_launch_provider __sk_launch_cwd \ + __sk_requested_model __sk_launch_key __sk_launch_boot \ + __sk_launch_started __sk_launch_shell_pid __sk_launch_shell_start \ + __sk_launch_daemon_pid __sk_launch_daemon_start <<<"$__sk_launch_line" + __sk_validated_model=$(python3 "$__sk_inventory_core" validate-worker-model \ + "$__sk_launch_provider" "$__sk_requested_model" 2>/dev/null || true) + if [[ $__sk_validated_model == "$__sk_requested_model" && + ( -z $__sk_launch_key || $__sk_launch_key =~ ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$ ) ]]; then + __sk_launch_record_ok=1 + fi + unset __sk_validated_model + fi + fi if [[ -n ${SESSION_KIT_BOOT_ID_FILE:-} ]]; then __sk_current_boot_id=$(command cat -- "$SESSION_KIT_BOOT_ID_FILE" 2>/dev/null || true) - elif [[ $(uname -s 2>/dev/null) == Darwin && ${SESSION_KIT_MACOS_PREVIEW:-0} == 1 && - -f $__sk_inventory_core ]]; then + elif [[ $(uname -s 2>/dev/null) == Darwin && -f $__sk_inventory_core ]]; then __sk_current_boot_id=$(python3 "$__sk_inventory_core" platform boot-id 2>/dev/null || true) else __sk_current_boot_id=$(command cat -- /proc/sys/kernel/random/boot_id 2>/dev/null || true) @@ -139,8 +259,7 @@ PY local __sk_pid=$1 __sk_stat __sk_tail local -a __sk_fields if [[ $(uname -s 2>/dev/null) == Darwin ]]; then - [[ ${SESSION_KIT_MACOS_PREVIEW:-0} == 1 && -f $__sk_inventory_core ]] || - return 1 + [[ -f $__sk_inventory_core ]] || return 1 python3 "$__sk_inventory_core" platform process-info "$__sk_pid" 2>/dev/null | command tr '\t' ' ' return @@ -160,7 +279,7 @@ PY $__sk_uuid =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]]; then __sk_launch_mode_ok=1 fi - if (( __sk_record_shape_ok && __sk_launch_mode_ok )) && + if (( __sk_record_shape_ok && __sk_launch_mode_ok && __sk_launch_record_ok )) && [[ $__sk_provider == "$__sk_side_provider" && $__sk_cwd == "$__sk_side_cwd" && $__sk_uuid == "$__sk_side_uuid" && @@ -169,6 +288,17 @@ PY $__sk_started =~ ^[0-9]+$ && $__sk_shell_pid =~ ^[0-9]+$ && $__sk_shell_start =~ ^[0-9]+$ && $__sk_daemon_pid =~ ^[0-9]+$ && $__sk_daemon_start =~ ^[0-9]+$ ]]; then + if [[ -n $__sk_requested_model ]] && + [[ $__sk_launch_provider != "$__sk_provider" || + $__sk_launch_cwd != "$__sk_cwd" || + $__sk_launch_boot != "$__sk_boot_id" || + $__sk_launch_started != "$__sk_started" || + $__sk_launch_shell_pid != "$__sk_shell_pid" || + $__sk_launch_shell_start != "$__sk_shell_start" || + $__sk_launch_daemon_pid != "$__sk_daemon_pid" || + $__sk_launch_daemon_start != "$__sk_daemon_start" ]]; then + __sk_launch_record_ok=0 + fi __sk_walk_pid=$$ for __sk_walk_depth in {1..6}; do read -r __sk_walk_ppid __sk_walk_start < <(__sk_proc_identity "$__sk_walk_pid") || break @@ -182,32 +312,316 @@ PY __sk_walk_pid=$__sk_walk_ppid done fi + (( __sk_launch_record_ok )) || __sk_generation_ok=0 + if (( __sk_generation_ok )); then + unset SESSION_KIT_ACCOUNT_ALIAS + export SESSION_KIT_ACCOUNT_CAPABLE=0 + if [[ -e $__sk_account || -L $__sk_account ]]; then + __sk_account_ok=0 + __sk_account_provider= __sk_account_alias= __sk_account_profile= + if [[ -f $__sk_account && ! -L $__sk_account ]] && + python3 - "$__sk_account" <<'PY' +import os +import stat +import sys + +path = sys.argv[1] +info = os.lstat(path) +with open(path, "rb") as stream: + payload = stream.read(257) +if ( + not stat.S_ISREG(info.st_mode) + or info.st_uid != os.geteuid() + or stat.S_IMODE(info.st_mode) != 0o600 + or not payload.endswith(b"\n") + or payload.count(b"\n") != 1 + or payload[:-1].count(b"\t") != 1 + or b"\r" in payload + or b"\x1c" in payload +): + raise SystemExit(1) +PY + then + IFS=$'\t' read -r __sk_account_provider __sk_account_alias < "$__sk_account" + if [[ $__sk_account_provider == "$__sk_provider" && + $__sk_account_alias =~ ^[a-z][a-z0-9_-]{0,11}$ ]]; then + __sk_account_json=$(python3 "$__sk_inventory_core" account resume-profile \ + "$__sk_account_provider" "$__sk_account_alias" 2>/dev/null || true) + __sk_account_profile=$(python3 -c ' +import json,sys +try: + value=json.loads(sys.stdin.read()) +except ValueError: + raise SystemExit(1) +profile=value.get("profile_dir") +if value.get("provider") != sys.argv[1] or value.get("alias") != sys.argv[2]: + raise SystemExit(1) +if not isinstance(profile,str) or not profile.startswith("/") or "\n" in profile or "\r" in profile: + raise SystemExit(1) +print(profile) +' "$__sk_provider" "$__sk_account_alias" <<<"$__sk_account_json" 2>/dev/null || true) + if [[ $__sk_account_profile == /* ]]; then + export SESSION_KIT_ACCOUNT_ALIAS=$__sk_account_alias + export SESSION_KIT_ACCOUNT_CAPABLE=1 + if [[ $__sk_provider == claude ]]; then + export CLAUDE_CONFIG_DIR=$__sk_account_profile + else + export CODEX_HOME=$__sk_account_profile + export SESSION_KIT_CODEX_HOME=$__sk_account_profile + fi + __sk_account_ok=1 + fi + fi + fi + if (( ! __sk_account_ok )); then + echo "[session-kit: selected account profile is unsafe or no longer matches its login; provider not started]" >&2 + __sk_log_launch refused_account_unsafe + __sk_generation_ok=0 + fi + unset __sk_account_json + fi + fi if (( ! __sk_generation_ok )); then echo "[session-kit: stale or mismatched launch record retained; provider not started]" >&2 + __sk_log_launch refused_generation_mismatch elif [[ $__sk_cwd == /* && -d $__sk_cwd ]]; then if ! cd -- "$__sk_cwd"; then echo "[session-kit: launch directory is unavailable; launch record retained for retry]" >&2 + __sk_log_launch refused_directory_unavailable else __sk_provider_launched=0 __sk_provider_exited=0 __sk_provider_rc=0 + __sk_lifecycle_uuid= + __sk_lifecycle_intake= + __sk_lifecycle_generation= + __sk_model_args=() + __sk_launch_model=$__sk_requested_model + __sk_model_carried=0 + # An account switch relaunches this conversation by re-entering here + # through `exec bash -i`, and the launch record that carried the model + # was consumed at first launch. Without this the provider is started + # with no --model at all and chooses its own: the model changing by + # itself, which is the one thing the model rule says never happens + # quietly. SESSION_KIT_CARRIED_MODEL is set by that switch and nowhere + # else -- it comes from the same process that launched the provider, + # not from a file anything else can write -- and it is still put + # through the provider's validator before it is used. + if [[ -n ${SESSION_KIT_CARRIED_MODEL:-} ]]; then + if [[ -z $__sk_launch_model ]]; then + __sk_carried_model=$(python3 "$__sk_inventory_core" validate-worker-model \ + "$__sk_provider" "$SESSION_KIT_CARRIED_MODEL" 2>/dev/null || true) + if [[ -n $__sk_carried_model && + $__sk_carried_model == "$SESSION_KIT_CARRIED_MODEL" ]]; then + __sk_launch_model=$__sk_carried_model + __sk_model_carried=1 + else + # Said before the session starts, never discovered afterwards. + echo "[session-kit: this session was started on ${SESSION_KIT_CARRIED_MODEL}, and that could not be carried across the restart; it comes back on ${__sk_provider}'s own default. Use sp change-model to put it back.]" >&2 + fi + unset __sk_carried_model + fi + unset SESSION_KIT_CARRIED_MODEL + fi + if [[ -n $__sk_launch_model ]]; then + __sk_model_args=(--model "$__sk_launch_model") + export SESSION_KIT_REQUESTED_MODEL=$__sk_launch_model + if (( __sk_model_carried )); then + # A carried relaunch is a new launch and has no key of its own; the + # old one belongs to a launch that already happened. + unset SESSION_KIT_LAUNCH_IDEMPOTENCY_KEY + else + export SESSION_KIT_LAUNCH_IDEMPOTENCY_KEY=$__sk_launch_key + fi + else + unset SESSION_KIT_REQUESTED_MODEL SESSION_KIT_LAUNCH_IDEMPOTENCY_KEY + fi + unset __sk_model_carried case "$__sk_provider" in claude) if ! command -v claude >/dev/null 2>&1; then echo "[session-kit: Claude is unavailable; launch record retained for retry]" >&2 - else - command rm -- "$__sk_start" "$__sk_expected" + __sk_log_launch refused_provider_missing + elif [[ $__sk_launch_mode != new ]] || \ + __sk_new_claude_uuid=$(python3 -c 'import uuid; print(uuid.uuid4())'); then + __sk_consumed_records=("$__sk_start" "$__sk_expected") + [[ -z $__sk_requested_model ]] || __sk_consumed_records+=("$__sk_launch") + [[ ! -e $__sk_account ]] || __sk_consumed_records+=("$__sk_account") + command rm -- "${__sk_consumed_records[@]}" + unset __sk_consumed_records __sk_provider_launched=1 - case "$__sk_launch_mode" in - new) claude; __sk_provider_rc=$? ;; - resume) claude --resume "$__sk_uuid"; __sk_provider_rc=$? ;; - fork) claude --resume "$__sk_uuid" --fork-session; __sk_provider_rc=$? ;; - esac + __sk_log_launch started + if [[ $__sk_launch_mode == new ]]; then + __sk_lifecycle_uuid=$__sk_new_claude_uuid + else + __sk_lifecycle_uuid=$__sk_uuid + fi + # A Claude window renames only through its SessionStart/prompt + # hooks, so a session that boots before its name intent exists + # (fresh uuid, or a pre-baked resume) shows a stale title until + # the provider restarts. The marker lets the picker request one + # safe bounce once a name exists, same contract as Codex. + __sk_claude_intent_uuid=$__sk_uuid + [[ $__sk_launch_mode == new ]] && __sk_claude_intent_uuid=$__sk_new_claude_uuid + if [[ -n $__sk_claude_intent_uuid && \ + ! -f "$HOME/.claude/sessions/$__sk_claude_intent_uuid.nameintent" ]]; then + ( umask 077 + mkdir -p "$__sk_state_root/session-kit/provider-untitled" && + printf '%s\n' \ + "$__sk_current_boot_id:$__sk_shell_pid:$__sk_shell_start:$RANDOM:$EPOCHREALTIME" \ + > "$__sk_state_root/session-kit/provider-untitled/$SHPOOL_SESSION_NAME" + ) 2>/dev/null || true + fi + unset __sk_claude_intent_uuid + # A relaunch through `exec bash -i` re-enters here with the + # previous launch's exports intact; a stale project-dir name from + # another directory or profile must never survive into this one. + unset CLAUDE_CODE_PROJECT_DIR_NAME + __sk_claude_cmd=claude + if [[ ${SESSION_KIT_PROJECT_DIR_NAME:-} != off && -n ${CLAUDE_CONFIG_DIR:-} ]]; then + __sk_pdn_helper=${__sk_inventory_core%/*}/sessionkit_inventory/project_dir_name.py + if [[ -f $__sk_pdn_helper ]]; then + # The version proof must name the exact file this shell then + # executes. A bare `claude` can re-resolve differently, a + # stale command hash, a launcher symlink retargeted after the + # proof, and an older executable silently ignores the export + # after the directory has already been renamed (review lane + # rv-pdn-1, 2026-08-17). One realpath is proved, and an armed + # export launches exactly that path. + hash -d claude 2>/dev/null + __sk_claude_real=$(command -v claude 2>/dev/null) && + __sk_claude_real=$(python3 -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' \ + "$__sk_claude_real" 2>/dev/null) || __sk_claude_real= + if [[ -n $__sk_claude_real && -x $__sk_claude_real ]]; then + __sk_pdn=$(python3 "$__sk_pdn_helper" --profile "$CLAUDE_CONFIG_DIR" \ + --cwd "$PWD" --claude-command "$__sk_claude_real" 2>/dev/null) || __sk_pdn= + # The helper already refused anything unprovable; the pattern + # guard here only keeps a mangled pipe from exporting garbage. + if [[ $__sk_pdn =~ ^[A-Za-z0-9_-]{1,64}$ ]]; then + export CLAUDE_CODE_PROJECT_DIR_NAME=$__sk_pdn + __sk_claude_cmd=$__sk_claude_real + fi + unset __sk_pdn + fi + unset __sk_claude_real + fi + unset __sk_pdn_helper + fi + __sk_mcp_args=() + __sk_resume_degraded=0 + while :; do + # A resume of a conversation this profile does not hold can only + # fail. The pre-bake mints its throwaway inside one profile and + # this shell resolves its own; if they ever disagree, the launch + # records are already consumed, so the old code ran `--resume`, + # got "No conversation found", took that for a crash, reopened + # the SAME conversation once, crashed again, and left an open + # window with no provider in it at all. A conversation nothing + # can find is nothing to lose: start a fresh one instead, which + # still takes its colour from the kit at attach. + if [[ $__sk_launch_mode == resume && $__sk_resume_degraded == 0 ]] && + ! compgen -G "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/*/$__sk_uuid.jsonl" >/dev/null 2>&1; then + __sk_resume_degraded=1 + if __sk_new_claude_uuid=$(python3 -c 'import uuid; print(uuid.uuid4())'); then + __sk_launch_mode=new + __sk_lifecycle_uuid=$__sk_new_claude_uuid + __sk_log_launch degraded_resume_to_new + echo "[session-kit: the pre-started conversation was not in this profile; opening a new one instead]" >&2 + else + echo "[session-kit: Claude session identity could not be allocated; resuming as asked]" >&2 + fi + fi + case "$__sk_launch_mode" in + new) "$__sk_claude_cmd" "${__sk_model_args[@]}" --session-id "$__sk_new_claude_uuid" "${__sk_mcp_args[@]}"; __sk_provider_rc=$? ;; + resume) "$__sk_claude_cmd" "${__sk_model_args[@]}" --resume "$__sk_uuid" "${__sk_mcp_args[@]}"; __sk_provider_rc=$? ;; + fork) "$__sk_claude_cmd" "${__sk_model_args[@]}" --resume "$__sk_uuid" --fork-session "${__sk_mcp_args[@]}"; __sk_provider_rc=$? ;; + esac + # Same rule after the fact: a resume that exited nonzero without + # its conversation being anywhere this profile can see is not a + # crash to reopen, it is a launch to redo as a new session. Once + # only -- never a loop. + if [[ $__sk_launch_mode == resume && $__sk_provider_rc -ne 0 && $__sk_resume_degraded == 0 ]] && + ! compgen -G "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/*/$__sk_uuid.jsonl" >/dev/null 2>&1 && + __sk_new_claude_uuid=$(python3 -c 'import uuid; print(uuid.uuid4())'); then + __sk_resume_degraded=1 + __sk_launch_mode=new + __sk_lifecycle_uuid=$__sk_new_claude_uuid + __sk_log_launch degraded_resume_to_new + echo "[session-kit: that conversation could not be resumed; opening a new one instead]" >&2 + continue + fi + # A kit-requested bounce relaunches the SAME conversation once, + # so the fresh process boots through SessionStart with its name + # intent applied. Any other exit falls through to the normal + # provider-exit handling. + __sk_bounce="$__sk_state_root/session-kit/provider-bounce/$SHPOOL_SESSION_NAME" + if [[ -f $__sk_bounce && ! -L $__sk_bounce ]]; then + __sk_bounce_uuid=$(command head -c 64 -- "$__sk_bounce" 2>/dev/null | tr -cd '0-9a-fA-F-') + if [[ $__sk_bounce_uuid =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]]; then + __sk_uuid=$__sk_bounce_uuid + __sk_launch_mode=resume + # RENAMED, not removed. The marker is the only thing keeping + # this session in the person's list while the kit has its + # window shut, and there is no moment in the relaunch at + # which this shell can prove the replacement is up -- it + # blocks on the provider. So the marker stays as a receipt + # whose generated suffix identifies this exact request. + # Request and receipt names both read as bouncing, so the + # row stays visible either way; the rename is what + # stops this same block from bouncing forever, because the + # next read finds no marker under the first name. + # + # A generated receipt NAME rather than an emptied file or a + # reusable path binds settlement to one request generation. + # Collection captures the eligible names before it reads the + # process table, so an older sighting cannot name or delete a + # receipt installed after that read began. + # + # What ends the bounce is a positive sighting of the + # replacement window, which only collection can make. If the + # replacement never comes up, nothing clears it and the row + # stays visible, which is the correct way to be wrong. + __sk_bounce_generation=$(command sed -n '3p' -- "$__sk_bounce" 2>/dev/null | tr -cd 'A-Za-z0-9') + if [[ $__sk_bounce_generation =~ ^[A-Za-z0-9]{6,32}$ ]]; then + __sk_bounce_receipt="$__sk_bounce.taken.$__sk_bounce_generation" + else + # A request written before generated receipts shipped is + # still honoured. Its reusable legacy receipt is never + # settled by collection, so an already-live old shell can + # never have its protection removed by stale evidence. + __sk_bounce_receipt="$__sk_bounce.taken" + fi + command mv -f -- "$__sk_bounce" "$__sk_bounce_receipt" 2>/dev/null || + { : > "$__sk_bounce"; } 2>/dev/null || true + unset __sk_bounce_uuid __sk_bounce_generation __sk_bounce_receipt __sk_bounce + continue + fi + command rm -f -- "$__sk_bounce" + unset __sk_bounce_uuid + fi + # The provider loop is over, so a marker under either name can + # never be consumed or settled again. Left behind, it would keep + # this id reading as bouncing for as long as the state directory + # survives -- the sweep would get it eventually, but only after + # the grace, and only once the session is gone from the listing. + command rm -f -- "$__sk_bounce" "$__sk_bounce.taken" + for __sk_bounce_receipt in "$__sk_bounce".taken.*; do + [[ -e $__sk_bounce_receipt || -L $__sk_bounce_receipt ]] || continue + command rm -f -- "$__sk_bounce_receipt" + done + unset __sk_bounce_receipt + unset __sk_bounce + break + done + else + echo "[session-kit: Claude session identity could not be allocated; launch record retained for retry]" >&2 fi + unset __sk_new_claude_uuid __sk_mcp_args __sk_resume_degraded ;; codex) if ! command -v codex >/dev/null 2>&1; then echo "[session-kit: Codex is unavailable; launch record retained for retry]" >&2 + __sk_log_launch refused_provider_missing else # A managed session is launched with nobody watching it. Codex's # startup upgrade prompt blocks on a keypress that never comes, so @@ -215,28 +629,317 @@ PY # conversation never loads. Suppressed explicitly here rather than # relying on user config. __sk_codex_no_update=(-c check_for_update_on_startup=false) + # A private per-repository coordination config can opt future Codex + # sessions into a local App Server plus an exact-thread broker. + # Existing direct sessions are never changed. Invalid, unsafe, or + # absent config fails back to the normal direct TUI launch. + __sk_coord_gate= + __sk_coord_broker= + __sk_coord_config="$HOME/.config/session-kit/coordination.json" + if [[ -f $__sk_coord_config && ! -L $__sk_coord_config ]]; then + __sk_coord_gate=$(python3 - "$__sk_coord_config" "$__sk_cwd" <<'PY' +import json +import os +import stat +import sys + +config_path, launch_cwd = sys.argv[1:] +try: + metadata = os.lstat(config_path) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or metadata.st_mode & 0o022 + or metadata.st_size > 8192 + ): + raise ValueError("unsafe config") + with open(config_path, encoding="utf-8") as stream: + config = json.load(stream) + repo_root = os.path.realpath(config["repo_root"]) + broker = config["codex_broker"] + if ( + config.get("codex_app_server") is not True + or not os.path.isabs(broker) + or not os.path.isfile(broker) + or os.path.islink(broker) + ): + raise ValueError("inactive config") + broker_metadata = os.stat(broker) + if broker_metadata.st_uid != os.geteuid() or broker_metadata.st_mode & 0o022: + raise ValueError("unsafe broker") + # "codex_app_server_all" arms the App Server for every cwd so any managed + # Codex window is reachable. The exact-thread broker stays repo-scoped: a + # session outside the repository gets the server alone. Without the key the + # gate is repo-only, exactly as before. + if os.path.realpath(launch_cwd) != repo_root: + if config.get("codex_app_server_all") is not True: + raise ValueError("inactive config") + print("-") + else: + print(broker) +except (KeyError, OSError, TypeError, ValueError, json.JSONDecodeError): + pass +PY + ) + fi + # An absolute path arms the server and its broker; "-" arms the + # server alone. Any other answer leaves the gate shut. + case $__sk_coord_gate in + /*) __sk_coord_broker=$__sk_coord_gate ;; + -) ;; + *) __sk_coord_gate= ;; + esac + __sk_codex_remote=() + __sk_app_server_pid= + __sk_broker_pid= + __sk_app_socket= + if [[ -n $__sk_coord_gate ]]; then + __sk_app_dir=$(python3 "$__sk_inventory_core" platform app-server-dir \ + "$__sk_state_root" "$SHPOOL_SESSION_NAME" 2>/dev/null || true) + if [[ -z $__sk_app_dir ]]; then + # The private directory chain refused, so the gate quietly used + # the direct TUI and every reachability feature stayed dark for + # the life of the window. Name the reason once, on stderr, and + # in this session's App Server log when one already exists. + python3 - "$__sk_state_root" "$SHPOOL_SESSION_NAME" <<'PY' || true +import os +import stat +import sys + +state_root, session_id = sys.argv[1:] +line = "[session-kit: Codex App Server disabled: ~/.local/state must be mode 0700]" +try: + metadata = os.lstat(state_root) + private = ( + stat.S_ISDIR(metadata.st_mode) + and metadata.st_uid == os.geteuid() + and stat.S_IMODE(metadata.st_mode) == 0o700 + ) +except OSError: + private = False +if private: + line = ( + "[session-kit: Codex App Server disabled: " + "its private state directory is unavailable]" + ) +print(line, file=sys.stderr) +if not session_id or "/" in session_id or session_id.startswith("."): + raise SystemExit(0) +log = os.path.join( + state_root, "session-kit", "app-server", session_id, "app-server.log" +) +flags = os.O_WRONLY | os.O_APPEND | getattr(os, "O_CLOEXEC", 0) +flags |= getattr(os, "O_NOFOLLOW", 0) +try: + descriptor = os.open(log, flags) +except OSError: + raise SystemExit(0) +try: + log_metadata = os.fstat(descriptor) + if stat.S_ISREG(log_metadata.st_mode) and log_metadata.st_uid == os.geteuid(): + os.write(descriptor, (line + "\n").encode()) +finally: + os.close(descriptor) +PY + fi + if [[ -n $__sk_app_dir ]]; then + __sk_app_socket="$__sk_app_dir/app.sock" + __sk_app_log="$__sk_app_dir/app-server.log" + __sk_broker_log="$__sk_app_dir/broker.log" + if [[ -L $__sk_app_log || -L $__sk_broker_log ]]; then + echo "[session-kit: refusing symlinked App Server log]" >&2 + elif ( umask 077 + python3 - "$__sk_app_log" "$__sk_broker_log" <<'PY' +import os, stat, sys + +for path in sys.argv[1:]: + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND | getattr(os, "O_CLOEXEC", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(path, flags, 0o600) + try: + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or metadata.st_nlink != 1 + ): + raise OSError("unsafe App Server log") + os.fchmod(descriptor, 0o600) + finally: + os.close(descriptor) +PY + ) && [[ ! -L $__sk_app_log && ! -L $__sk_broker_log ]] && + [[ -f $__sk_app_log && -f $__sk_broker_log ]] && + chmod 600 -- "$__sk_app_log" "$__sk_broker_log" && + [[ ! -e $__sk_app_socket ]]; then + codex "${__sk_codex_no_update[@]}" app-server \ + --listen "unix://$__sk_app_socket" \ + >>"$__sk_app_log" 2>&1 & + __sk_app_server_pid=$! + for __sk_app_attempt in {1..50}; do + [[ -S $__sk_app_socket ]] && break + kill -0 "$__sk_app_server_pid" 2>/dev/null || break + sleep 0.1 + done + if [[ -S $__sk_app_socket ]] && \ + kill -0 "$__sk_app_server_pid" 2>/dev/null; then + chmod 600 -- "$__sk_app_socket" 2>/dev/null || true + __sk_codex_remote=(--remote "unix://$__sk_app_socket") + if [[ -n $__sk_coord_broker ]]; then + __sk_broker_args=( + --socket "$__sk_app_socket" + --repo "$__sk_cwd" + --server-pid "$__sk_app_server_pid" + ) + [[ $__sk_launch_mode == resume && -n $__sk_uuid ]] && \ + __sk_broker_args+=(--thread "$__sk_uuid") + python3 "$__sk_coord_broker" "${__sk_broker_args[@]}" \ + >>"$__sk_broker_log" 2>&1 & + __sk_broker_pid=$! + fi + else + if [[ -n $__sk_app_server_pid ]]; then + kill "$__sk_app_server_pid" 2>/dev/null || true + wait "$__sk_app_server_pid" 2>/dev/null || true + fi + __sk_app_server_pid= + echo "[session-kit: Codex App Server unavailable; using direct TUI]" >&2 + fi + fi + fi + fi # Codex has no per-thread color; the session's kit color rides in # as a per-launch theme override (status line, thread-title item). # Resumes and forks color from the conversation's effective color; # a brand-new session has no conversation ID yet, so it launches - # with a color picked from the shpool session name — the collector + # with a color picked from the shpool session name, the collector # adopts that pick as the conversation's override once the ID # exists, keeping window, picker, and future resumes identical. # Fail-open: unknown color or missing theme file launches plain. - command rm -- "$__sk_start" "$__sk_expected" + __sk_consumed_records=("$__sk_start" "$__sk_expected") + [[ -z $__sk_requested_model ]] || __sk_consumed_records+=("$__sk_launch") + [[ ! -e $__sk_account ]] || __sk_consumed_records+=("$__sk_account") + command rm -- "${__sk_consumed_records[@]}" + unset __sk_consumed_records __sk_provider_launched=1 - # A new-mode Codex boots before any thread title can exist, so its - # status bar shows the conversation ID for that process's life. - # The marker lets the picker request one safe provider bounce - # (codex resume of the same conversation) once a title exists. - if [[ $__sk_launch_mode == new ]]; then + # A Codex process that boots before a real thread title exists + # keeps showing the conversation ID for that process's life. The + # marker lets the picker request one safe provider bounce once a + # title exists, including a resumed thread that was still untitled. + if [[ $__sk_launch_mode == new ]] || + ! python3 "$__sk_inventory_core" codex-bounce-title --read-only "$__sk_uuid" >/dev/null 2>&1; then ( umask 077 mkdir -p "$__sk_state_root/session-kit/provider-untitled" && - : > "$__sk_state_root/session-kit/provider-untitled/$SHPOOL_SESSION_NAME" + printf '%s\n' \ + "$__sk_current_boot_id:$__sk_shell_pid:$__sk_shell_start:$RANDOM:$EPOCHREALTIME" \ + > "$__sk_state_root/session-kit/provider-untitled/$SHPOOL_SESSION_NAME" ) 2>/dev/null || true fi while :; do __sk_codex_theme=() + # The kit owns the tab name on both providers (K3). Codex writes + # its own OSC title from tui.terminal_title, so the kit hands it + # the item list it deployed -- the state glyph plus the thread + # name, which is the string the kit writes when a session is + # named -- as a per-launch override. The personal + # ~/.codex/config.toml is never touched: an override on the + # command line wins for this process only (verified against + # codex-cli 0.145.0, 2026-08-13). Kill switch, both providers: + # SESSION_KIT_TAB_TITLE=off. + __sk_codex_title=() + __sk_codex_title_switch=${SESSION_KIT_TAB_TITLE:-} + # Same rule as sk_tab_title and doctor: surrounding whitespace + # is immaterial, and every spelling of "off" disables titles. + __sk_codex_title_switch=${__sk_codex_title_switch#"${__sk_codex_title_switch%%[![:space:]]*}"} + __sk_codex_title_switch=${__sk_codex_title_switch%"${__sk_codex_title_switch##*[![:space:]]}"} + if [[ ${__sk_codex_title_switch,,} != off ]]; then + __sk_codex_title_items='["activity", "thread"]' + __sk_codex_title_file=${SESSION_KIT_CODEX_HOME:-${CODEX_HOME:-$HOME/.codex}}/session-kit/terminal-title.toml + if [[ -f $__sk_codex_title_file && ! -L $__sk_codex_title_file ]]; then + # Parsed as real TOML, not matched as text. A value like + # ["thread] passes any reasonable regex and is unterminated + # TOML -- passing it through on `-c` would stop Codex from + # starting at all, turning an owner's typo in a file the kit + # deployed into a session that never opens. Anything this + # cannot parse into a list of known-shaped item names falls + # back to the built-in value, and the same file is checked by + # `session-kit doctor`. + __sk_codex_title_deployed=$( + python3 - "$__sk_codex_title_file" <<'SKTITLE' 2>/dev/null +import re +import sys + +try: + import tomllib +except ImportError: # Python 3.10 has no parser; read the kit's own dialect. + tomllib = None + +def _value(raw): + raw = raw.strip() + if raw.startswith("[") and raw.endswith("]"): + inner = raw[1:-1].strip() + if not inner: + return [] + items = [] + for part in inner.split(","): + part = part.strip() + if len(part) >= 2 and part[0] == part[-1] and part[0] in "\"'": + items.append(part[1:-1]) + else: + raise ValueError("shape") + return items + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'": + return raw[1:-1] + raise ValueError("shape") + +def parse(text): + if tomllib is not None: + return tomllib.loads(text).get("tui", {}).get("terminal_title") + parsed = {} + current = None + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + header = re.fullmatch(r"\[([A-Za-z0-9_-]+)\]", line) + if header: + current = parsed.setdefault(header.group(1), {}) + if not isinstance(current, dict): + raise ValueError("shape") + continue + pair = re.fullmatch(r"([A-Za-z0-9_-]+)\s*=\s*(.+)", line) + if not pair: + raise ValueError("shape") + (current if current is not None else parsed)[pair.group(1)] = _value( + pair.group(2) + ) + tui = parsed.get("tui") + return tui.get("terminal_title") if isinstance(tui, dict) else None + +try: + with open(sys.argv[1], "r", encoding="utf-8") as handle: + value = parse(handle.read()) +except Exception: + raise SystemExit(1) +if not isinstance(value, list) or not value or len(value) > 12: + raise SystemExit(1) +items = [] +for item in value: + if not isinstance(item, str) or not re.fullmatch(r"[a-z][a-z-]{0,31}", item): + raise SystemExit(1) + items.append(item) +print("[" + ", ".join('"%s"' % item for item in items) + "]") +SKTITLE + ) || __sk_codex_title_deployed= + if [[ -n $__sk_codex_title_deployed ]]; then + __sk_codex_title_items=$__sk_codex_title_deployed + fi + unset __sk_codex_title_deployed + fi + __sk_codex_title=(-c "tui.terminal_title=$__sk_codex_title_items") + unset __sk_codex_title_items __sk_codex_title_file + fi + unset __sk_codex_title_switch __sk_theme_color= if [[ -f $__sk_inventory_core ]]; then if [[ -n $__sk_uuid ]]; then @@ -246,36 +949,118 @@ PY fi fi case "$__sk_theme_color" in - red|blue|green|yellow|purple|orange|pink|cyan) - if [[ -r ${CODEX_HOME:-$HOME/.codex}/themes/sk-$__sk_theme_color.tmTheme ]]; then + lime|magenta|silver|sand|sky|sea) + __sk_codex_home=${SESSION_KIT_CODEX_HOME:-${CODEX_HOME:-$HOME/.codex}} + if [[ -r $__sk_codex_home/themes/sk-$__sk_theme_color.tmTheme ]]; then __sk_codex_theme=(-c "tui.theme=\"sk-$__sk_theme_color\"") fi + unset __sk_codex_home ;; esac unset __sk_theme_color case "$__sk_launch_mode" in - new) codex "${__sk_codex_no_update[@]}" "${__sk_codex_theme[@]}" --no-alt-screen; __sk_provider_rc=$? ;; - resume) codex "${__sk_codex_no_update[@]}" "${__sk_codex_theme[@]}" --no-alt-screen resume "$__sk_uuid"; __sk_provider_rc=$? ;; - fork) codex "${__sk_codex_no_update[@]}" "${__sk_codex_theme[@]}" --no-alt-screen fork "$__sk_uuid"; __sk_provider_rc=$? ;; + new) + # A prompt staged by `sp new --prompt-file` seeds the session + # directly (the intake ceremony is retired; the seeded launch + # is not). Consume-once: the handoff never survives a launch. + __sk_prompt_handoff="$__sk_start_dir/$SHPOOL_SESSION_NAME.prompt" + __sk_seed_prompt= + if [[ -f $__sk_prompt_handoff && ! -L $__sk_prompt_handoff ]]; then + __sk_seed_prompt=$(command cat -- "$__sk_prompt_handoff" 2>/dev/null) || + __sk_seed_prompt= + command rm -f -- "$__sk_prompt_handoff" + fi + if [[ -n $__sk_seed_prompt ]]; then + codex "${__sk_codex_no_update[@]}" "${__sk_codex_theme[@]}" \ + "${__sk_codex_title[@]}" \ + "${__sk_model_args[@]}" "${__sk_codex_remote[@]}" --no-alt-screen \ + -- "$__sk_seed_prompt" + else + codex "${__sk_codex_no_update[@]}" "${__sk_codex_theme[@]}" \ + "${__sk_codex_title[@]}" \ + "${__sk_model_args[@]}" "${__sk_codex_remote[@]}" --no-alt-screen + fi + __sk_provider_rc=$? + unset __sk_prompt_handoff __sk_seed_prompt + ;; + resume) codex "${__sk_codex_no_update[@]}" "${__sk_codex_theme[@]}" "${__sk_codex_title[@]}" "${__sk_model_args[@]}" "${__sk_codex_remote[@]}" --no-alt-screen resume "$__sk_uuid"; __sk_provider_rc=$? ;; + fork) codex "${__sk_codex_no_update[@]}" "${__sk_codex_theme[@]}" "${__sk_codex_title[@]}" "${__sk_model_args[@]}" "${__sk_codex_remote[@]}" --no-alt-screen fork "$__sk_uuid"; __sk_provider_rc=$? ;; esac + if [[ $__sk_launch_mode != new ]]; then + __sk_lifecycle_uuid=$__sk_uuid + fi # A kit-requested bounce relaunches the SAME conversation once, # so the fresh process boots with its title and theme. Any other # exit falls through to the normal provider-exit handling. __sk_bounce="$__sk_state_root/session-kit/provider-bounce/$SHPOOL_SESSION_NAME" if [[ -f $__sk_bounce && ! -L $__sk_bounce ]]; then __sk_bounce_uuid=$(command head -c 64 -- "$__sk_bounce" 2>/dev/null | tr -cd '0-9a-fA-F-') - command rm -f -- "$__sk_bounce" if [[ $__sk_bounce_uuid =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]]; then __sk_uuid=$__sk_bounce_uuid __sk_launch_mode=resume - unset __sk_bounce_uuid __sk_bounce + # RENAMED, not removed. The marker is the only thing keeping + # this session in the person's list while the kit has its + # window shut, and there is no moment in the relaunch at + # which this shell can prove the replacement is up -- it + # blocks on the provider. So the marker stays as a receipt + # whose generated suffix identifies this exact request. + # Request and receipt names both read as bouncing, so the + # row stays visible either way; the rename is what + # stops this same block from bouncing forever, because the + # next read finds no marker under the first name. + # + # A generated receipt NAME rather than an emptied file or a + # reusable path binds settlement to one request generation. + # Collection captures the eligible names before it reads the + # process table, so an older sighting cannot name or delete a + # receipt installed after that read began. + # + # What ends the bounce is a positive sighting of the + # replacement window, which only collection can make. If the + # replacement never comes up, nothing clears it and the row + # stays visible, which is the correct way to be wrong. + __sk_bounce_generation=$(command sed -n '3p' -- "$__sk_bounce" 2>/dev/null | tr -cd 'A-Za-z0-9') + if [[ $__sk_bounce_generation =~ ^[A-Za-z0-9]{6,32}$ ]]; then + __sk_bounce_receipt="$__sk_bounce.taken.$__sk_bounce_generation" + else + # A request written before generated receipts shipped is + # still honoured. Its reusable legacy receipt is never + # settled by collection, so an already-live old shell can + # never have its protection removed by stale evidence. + __sk_bounce_receipt="$__sk_bounce.taken" + fi + command mv -f -- "$__sk_bounce" "$__sk_bounce_receipt" 2>/dev/null || + { : > "$__sk_bounce"; } 2>/dev/null || true + unset __sk_bounce_uuid __sk_bounce_generation __sk_bounce_receipt __sk_bounce continue fi + command rm -f -- "$__sk_bounce" unset __sk_bounce_uuid fi + # The provider loop is over, so a marker under either name can + # never be consumed or settled again. Left behind, it would keep + # this id reading as bouncing for as long as the state directory + # survives -- the sweep would get it eventually, but only after + # the grace, and only once the session is gone from the listing. + command rm -f -- "$__sk_bounce" "$__sk_bounce.taken" + for __sk_bounce_receipt in "$__sk_bounce".taken.*; do + [[ -e $__sk_bounce_receipt || -L $__sk_bounce_receipt ]] || continue + command rm -f -- "$__sk_bounce_receipt" + done + unset __sk_bounce_receipt unset __sk_bounce break done + if [[ -n $__sk_broker_pid ]]; then + kill "$__sk_broker_pid" 2>/dev/null || true + wait "$__sk_broker_pid" 2>/dev/null || true + fi + if [[ -n $__sk_app_server_pid ]]; then + kill "$__sk_app_server_pid" 2>/dev/null || true + wait "$__sk_app_server_pid" 2>/dev/null || true + fi + [[ -n $__sk_app_socket && -S $__sk_app_socket ]] && \ + command rm -- "$__sk_app_socket" fi ;; shell) @@ -295,13 +1080,200 @@ PY SESSION_KIT_LIFECYCLE_SHELL_START_TICKS=$__sk_lifecycle_shell_start \ SESSION_KIT_LIFECYCLE_PROVIDER=$__sk_lifecycle_provider \ SESSION_KIT_LIFECYCLE_EXIT_CODE=$__sk_provider_rc \ + SESSION_KIT_LIFECYCLE_CONVERSATION_UUID=$__sk_lifecycle_uuid \ + SESSION_KIT_LIFECYCLE_INTAKE_COMMIT=$__sk_lifecycle_intake \ + SESSION_KIT_MANAGED_GENERATION=$__sk_lifecycle_generation \ + SESSION_KIT_REQUESTED_MODEL=$__sk_requested_model \ + SESSION_KIT_LAUNCH_IDEMPOTENCY_KEY=$__sk_launch_key \ python3 "$__sk_lifecycle_core" lifecycle provider-exited >/dev/null then __sk_provider_exited=1 - printf '\n%s exited with status %s. This terminal is still open.\n' \ - "${__sk_provider^}" "$__sk_provider_rc" + __sk_provider_exit_code=$__sk_provider_rc + elif sleep 0.5 && \ + SESSION_KIT_LIFECYCLE_SESSION_ID=$__sk_lifecycle_session \ + SESSION_KIT_LIFECYCLE_BOOT_ID=$__sk_lifecycle_boot \ + SESSION_KIT_LIFECYCLE_SHELL_PID=$__sk_lifecycle_shell_pid \ + SESSION_KIT_LIFECYCLE_SHELL_START_TICKS=$__sk_lifecycle_shell_start \ + SESSION_KIT_LIFECYCLE_PROVIDER=$__sk_lifecycle_provider \ + SESSION_KIT_LIFECYCLE_EXIT_CODE=$__sk_provider_rc \ + SESSION_KIT_LIFECYCLE_CONVERSATION_UUID=$__sk_lifecycle_uuid \ + SESSION_KIT_LIFECYCLE_INTAKE_COMMIT=$__sk_lifecycle_intake \ + SESSION_KIT_MANAGED_GENERATION=$__sk_lifecycle_generation \ + SESSION_KIT_REQUESTED_MODEL=$__sk_requested_model \ + SESSION_KIT_LAUNCH_IDEMPOTENCY_KEY=$__sk_launch_key \ + python3 "$__sk_lifecycle_core" lifecycle provider-exited >/dev/null + then + # One retry, half a second apart. A momentary failure -- a killed + # interpreter, a full disk clearing -- used to be permanent: the + # record is what makes the row say "provider exited", and the + # cleanup pass only ever considers a row that says it + # (lib/sessionkit_inventory/reaper.py). Without it the session + # looked like an ordinary idle shell forever, so nothing ever + # closed it and the person was left with a row to clear by hand. + __sk_provider_exited=1 + __sk_provider_exit_code=$__sk_provider_rc else - echo "[session-kit: provider exited; lifecycle proof is unavailable, so automatic cleanup is blocked]" >&2 + # Still no proof. The session stays open on purpose: with no + # record there is nothing that could restore this conversation, so + # ending it would lose it. Both the sentence and the hand-back + # happen LATER, at the marker below. + # + # They used to happen here, and the hand-back could not: this is + # line ~885 of a file that defines __sk_leave_after_provider_exit + # at ~1190, and a bashrc runs top to bottom. So the one path meant + # to stop the shell "dropping silently into a shell nobody asked + # for" printed `__sk_leave_after_provider_exit: command not found` + # and then dropped silently into a shell nobody asked for -- with + # an internal function name on the operator's screen. The test + # named ..._says_so_and_hands_back asserted the two sentences and + # never the hand-back its own name promised (found in review, + # 2026-08-15). + __sk_record_unwritable=1 + fi + # An account switch is requested from a different Session Kit + # window. The exact provider has exited and any Codex App Server + # children have already been reaped, so it is now safe to move the + # exact transcript and re-enter this same managed shell generation. + __sk_switch_request="$__sk_kit_state/account-switch-requests/$SHPOOL_SESSION_NAME" + if [[ -e $__sk_switch_request || -L $__sk_switch_request ]]; then + __sk_switch_ok=0 + __sk_switch_action= __sk_switch_txid= __sk_switch_alias= + __sk_switch_fallback= __sk_switch_uuid= + __sk_switch_started= __sk_switch_shell_pid= __sk_switch_shell_start= + __sk_switch_model= __sk_switch_model_ok=0 + if [[ -f $__sk_switch_request && ! -L $__sk_switch_request ]] && + python3 - "$__sk_switch_request" <<'PY' +import os +import stat +import sys + +path=sys.argv[1] +info=os.lstat(path) +with open(path,"rb") as stream: + payload=stream.read(513) +if ( + not stat.S_ISREG(info.st_mode) + or info.st_uid != os.geteuid() + or stat.S_IMODE(info.st_mode) != 0o600 + or not payload.endswith(b"\n") + or payload.count(b"\n") != 1 + or payload[:-1].count(b"\t") != 8 + or b"\r" in payload + or b"\x1c" in payload +): + raise SystemExit(1) +PY + then + IFS= read -r __sk_switch_line < "$__sk_switch_request" + __sk_switch_line=${__sk_switch_line//$'\t'/$'\034'} + IFS=$'\034' read -r __sk_switch_action __sk_switch_txid \ + __sk_switch_alias __sk_switch_fallback __sk_switch_uuid \ + __sk_switch_started __sk_switch_shell_pid __sk_switch_shell_start \ + __sk_switch_model <<<"$__sk_switch_line" + if [[ -z $__sk_switch_model && -z $__sk_requested_model ]]; then + __sk_switch_model_ok=1 + elif [[ $__sk_switch_model == "$__sk_requested_model" ]]; then + __sk_switch_validated_model=$(python3 "$__sk_inventory_core" \ + validate-worker-model "$__sk_provider" "$__sk_switch_model" \ + 2>/dev/null || true) + [[ $__sk_switch_validated_model == "$__sk_switch_model" ]] && + __sk_switch_model_ok=1 + unset __sk_switch_validated_model + fi + if [[ $__sk_switch_action =~ ^(apply|rollback)$ && + $__sk_switch_txid =~ ^[0-9a-f]{32}$ && + $__sk_switch_alias =~ ^[a-z][a-z0-9_-]{0,11}$ && + $__sk_switch_fallback =~ ^[a-z][a-z0-9_-]{0,11}$ && + $__sk_switch_uuid =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ && + $__sk_switch_started == "$__sk_started" && + $__sk_switch_shell_pid == "$__sk_shell_pid" && + $__sk_switch_shell_start == "$__sk_shell_start" && + $__sk_switch_model_ok == 1 && + ( -z $__sk_lifecycle_uuid || $__sk_switch_uuid == "$__sk_lifecycle_uuid" ) ]]; then + # Codex allocates a new thread UUID inside the foreground TUI. + # Only the request bound to this exact session and shell + # generation may supply that first-generation identity. + [[ -n $__sk_lifecycle_uuid ]] || __sk_lifecycle_uuid=$__sk_switch_uuid + if [[ $__sk_switch_action == apply ]]; then + if ! python3 "$__sk_inventory_core" account switch-apply \ + "$__sk_switch_txid" >/dev/null 2>&1; then + python3 "$__sk_inventory_core" account switch-rollback \ + "$__sk_switch_txid" >/dev/null 2>&1 || true + __sk_switch_alias=$__sk_switch_fallback + fi + elif ! python3 "$__sk_inventory_core" account switch-rollback \ + "$__sk_switch_txid" >/dev/null 2>&1; then + __sk_switch_alias= + fi + if [[ -n $__sk_switch_alias ]] && + ! python3 "$__sk_inventory_core" account resume-profile \ + "$__sk_provider" "$__sk_switch_alias" >/dev/null 2>&1 && + [[ $__sk_switch_action == apply ]]; then + python3 "$__sk_inventory_core" account switch-rollback \ + "$__sk_switch_txid" >/dev/null 2>&1 || true + __sk_switch_alias=$__sk_switch_fallback + fi + if [[ -n $__sk_switch_alias ]] && + python3 "$__sk_inventory_core" account resume-profile \ + "$__sk_provider" "$__sk_switch_alias" >/dev/null 2>&1; then + __sk_switch_tmp="$__sk_start.switch.$$" + __sk_switch_expected_tmp="$__sk_expected.switch.$$" + __sk_switch_account_tmp="$__sk_account.switch.$$" + __sk_switch_launch_tmp= + __sk_switch_files=("$__sk_switch_tmp" "$__sk_switch_expected_tmp" \ + "$__sk_switch_account_tmp") + if [[ -n $__sk_switch_model ]]; then + __sk_switch_launch_tmp="$__sk_launch.switch.$$" + __sk_switch_files+=("$__sk_switch_launch_tmp") + fi + if [[ -z $__sk_switch_model || + ( ! -e $__sk_launch && ! -L $__sk_launch ) ]]; then + ( umask 077 + printf '%s\t%s\t%s\tresume\n' \ + "$__sk_provider" "$__sk_cwd" "$__sk_lifecycle_uuid" \ + > "$__sk_switch_tmp" && + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\tresume\n' \ + "$__sk_provider" "$__sk_cwd" "$__sk_current_boot_id" \ + "$__sk_started" "$__sk_shell_pid" "$__sk_shell_start" \ + "$__sk_daemon_pid" "$__sk_daemon_start" "$__sk_lifecycle_uuid" \ + > "$__sk_switch_expected_tmp" && + printf '%s\t%s\n' "$__sk_provider" "$__sk_switch_alias" \ + > "$__sk_switch_account_tmp" && + { [[ -z $__sk_switch_model ]] || + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$__sk_provider" "$__sk_cwd" "$__sk_switch_model" \ + "$__sk_launch_key" "$__sk_current_boot_id" "$__sk_started" \ + "$__sk_shell_pid" "$__sk_shell_start" "$__sk_daemon_pid" \ + "$__sk_daemon_start" > "$__sk_switch_launch_tmp"; } && + chmod 600 -- "${__sk_switch_files[@]}" && + mv -- "$__sk_switch_tmp" "$__sk_start" && + mv -- "$__sk_switch_expected_tmp" "$__sk_expected" && + mv -- "$__sk_switch_account_tmp" "$__sk_account" && + { [[ -z $__sk_switch_model ]] || + mv -- "$__sk_switch_launch_tmp" "$__sk_launch"; } + ) && __sk_switch_ok=1 + fi + command rm -f -- "$__sk_switch_tmp" "$__sk_switch_expected_tmp" \ + "$__sk_switch_account_tmp" ${__sk_switch_launch_tmp:+"$__sk_switch_launch_tmp"} + fi + fi + fi + if (( __sk_switch_ok )); then + command rm -f -- "$__sk_switch_request" + # The launch record carrying the model was consumed at first + # launch, so the shell this `exec` starts would find none and + # relaunch the provider with no --model at all. Hand the model + # forward explicitly: a variable set only here, only on this + # path, so nothing inherited from a parent shell can make an + # ordinary launch look like a relaunch. + if [[ -n ${SESSION_KIT_REQUESTED_MODEL:-} ]]; then + export SESSION_KIT_CARRIED_MODEL=$SESSION_KIT_REQUESTED_MODEL + else + unset SESSION_KIT_CARRIED_MODEL + fi + exec bash -i + fi + echo "[session-kit: account switch could not safely relaunch; request retained]" >&2 fi fi fi @@ -310,20 +1282,29 @@ PY fi fi unset -f __sk_proc_identity - unset __sk_start_dir __sk_start __sk_expected __sk_arm_attempt - unset __sk_start_line __sk_expected_line __sk_record_shape_ok + unset __sk_start_dir __sk_start __sk_expected __sk_launch __sk_account __sk_arm_attempt + unset __sk_kit_state + unset __sk_start_line __sk_expected_line __sk_record_shape_ok __sk_launch_line unset __sk_provider __sk_cwd __sk_uuid __sk_launch_mode unset __sk_side_provider __sk_side_cwd __sk_side_uuid __sk_side_launch_mode unset __sk_boot_id __sk_current_boot_id __sk_inventory_core __sk_started __sk_shell_pid __sk_shell_start __sk_daemon_pid __sk_daemon_start unset __sk_generation_ok __sk_launch_mode_ok __sk_walk_pid __sk_walk_depth __sk_walk_ppid __sk_walk_start unset __sk_parent_ppid __sk_parent_start unset __sk_provider_launched __sk_provider_rc + unset __sk_lifecycle_uuid __sk_lifecycle_intake __sk_lifecycle_generation + unset __sk_requested_model __sk_launch_key __sk_launch_provider __sk_launch_cwd + unset __sk_launch_boot __sk_launch_started __sk_launch_shell_pid __sk_launch_shell_start + unset __sk_launch_daemon_pid __sk_launch_daemon_start __sk_launch_record_ok __sk_model_args + unset __sk_launch_model + unset __sk_account_ok __sk_account_provider __sk_account_alias __sk_account_profile + unset SESSION_KIT_REQUESTED_MODEL SESSION_KIT_LAUNCH_IDEMPOTENCY_KEY + unset SESSION_KIT_CARRIED_MODEL __sk_lifecycle_event() { local __sk_lifecycle_action=$1 shift [[ -n ${__sk_lifecycle_core:-} && -f $__sk_lifecycle_core ]] || return 1 - if [[ $__sk_lifecycle_action == reopen ]]; then + if [[ $__sk_lifecycle_action == reopen || $__sk_lifecycle_action == closed ]]; then SESSION_KIT_LIFECYCLE_SESSION_ID=$__sk_lifecycle_session \ SESSION_KIT_LIFECYCLE_BOOT_ID=$__sk_lifecycle_boot \ SESSION_KIT_LIFECYCLE_SHELL_PID=$__sk_lifecycle_shell_pid \ @@ -341,77 +1322,279 @@ PY keep_session() { if __sk_lifecycle_event keep on; then - echo "Automatic cleanup is disabled for this terminal." + echo "Automatic cleanup is off for this session." else - echo "Could not mark this terminal to keep." >&2 + echo "Could not mark this session to keep." >&2 return 1 fi } unkeep_session() { if __sk_lifecycle_event keep off; then - echo "Automatic cleanup may resume after every safety condition is met." + echo "This session can be cleaned up again." else echo "Could not remove the keep marker." >&2 return 1 fi } - # `bye` is the convenient confirmed close. A directly typed Bash `exit` - # retains normal shell semantics, as approved. + # `bye` closes this session and says so. It asks nothing (no confirmation + # anywhere in the kit) and prints no session ID (no screen may print one); + # the close is recorded as deliberate, so the conversation stays restorable. + # A directly typed Bash `exit` keeps normal shell semantics, as approved. bye() { - local __sk_answer - printf '\nClose shpool session %s?\n' "$SHPOOL_SESSION_NAME" - read -r -p "Type the exact ID '$SHPOOL_SESSION_NAME' to confirm: " __sk_answer - if [[ $__sk_answer == "$SHPOOL_SESSION_NAME" ]]; then - builtin exit + __sk_close_and_report + printf '\nClosed this session.\n' + builtin exit + } + + # A close the PERSON asked for. It happens either way -- refusing to close + # because a ledger is unwritable would trap them in a session they said they + # were done with -- but they are TOLD when the conversation did not make it + # onto the Closed list, because otherwise they walk away believing it is + # there and find out days later that it is not. + # + # All three of these callers used to end with `>/dev/null 2>&1 || true`: + # the verb's answer was thrown away, and every outcome printed the same + # confident sentence. That is the same shape as the two blockers this round + # fixed -- a result saying "this did not work" read as permission -- and it + # is the half of it that is not destructive, only dishonest. + __sk_close_and_report() { + local __sk_close_json= + [[ -n ${__sk_lifecycle_core:-} && -f $__sk_lifecycle_core ]] || return 0 + # Go through the lifecycle-event contract, but retain and inspect its + # answer. Closed is the one non-reopen event whose JSON is meaningful to + # its caller; throwing it away hid a failed ledger write as a safe close. + __sk_close_json=$(__sk_lifecycle_event closed 2>/dev/null) + case $__sk_close_json in + *'"restorable": false'*) + # Filed, but the list will drop the row: the transcript a restore + # would read is gone or unreadable. Saying nothing here would leave + # the person believing Closed sessions can bring it back. + printf 'Note: this session is closing, but its conversation cannot be read back from this machine, so Closed sessions will not be able to restore it.\n' >&2 + return 1 + ;; + # Recorded, or a managed shell that never had a conversation to record. + # Both are the verb working; neither is worth a word. + *'"recorded": true'* | *'"reason": "no exact conversation"'*) return 0 ;; + *'"provider": "shell"'*) + printf 'Note: this session is closing, but its shell-history row could not be added to Closed sessions. No conversation tombstone was written.\n' >&2 + return 1 + ;; + esac + printf 'Note: this session is closing, but its conversation could not be added to Closed sessions. It stays in the recovery list instead.\n' >&2 + return 1 + } + + # Handing the window back is a detach, not a close: the session keeps + # running and the picker that opened this window redraws its list. This is + # the one detach the kit makes, so every path back to the picker leaves a + # session in exactly the same state. + __sk_detach_to_picker() { + command shpool detach >/dev/null 2>&1 + } + + # Close the session, but ONLY if the CONVERSATION survives the close. + # + # `lifecycle closed` has two outcomes and they are not equally good. With an + # exact conversation it writes the tombstone and a closed-sessions row + # carrying the uuid, and the conversation can be restored by name. Without + # one it records a `shell` row instead -- "history only", scrollback and + # nothing else. Closing a crashed session into that second shape would be + # WORSE than leaving it open, because the session that still knew the + # conversation would be gone and the row could never bring it back. + # + # --only-with-conversation makes that one decision instead of two steps: + # either the close happened and the conversation is on the closed list, or + # nothing at all was written and this session keeps running. Asking without + # it used to record the history-only row as the answer, which put a closed + # row on the list for a session that then stayed open. + # + # Success here means the session may end. Every other outcome -- a refusal, + # an unreadable core, a conversation that was never recorded, a session the + # person marked keep -- keeps it, and records WHY in __sk_close_refusal so + # the sentence afterwards names the real reason rather than a likely one. + __sk_close_keeps_the_conversation() { + local __sk_close_json + __sk_close_refusal= + [[ -n ${__sk_lifecycle_core:-} && -f $__sk_lifecycle_core ]] || return 1 + __sk_close_json=$( + SESSION_KIT_LIFECYCLE_SESSION_ID=$__sk_lifecycle_session \ + SESSION_KIT_LIFECYCLE_BOOT_ID=$__sk_lifecycle_boot \ + SESSION_KIT_LIFECYCLE_SHELL_PID=$__sk_lifecycle_shell_pid \ + SESSION_KIT_LIFECYCLE_SHELL_START_TICKS=$__sk_lifecycle_shell_start \ + python3 "$__sk_lifecycle_core" lifecycle closed \ + --only-with-conversation 2>/dev/null + ) + case $__sk_close_json in + *'"reason": "keep is set"'*) __sk_close_refusal=keep ;; + *'"reason": "the conversation cannot be read back"'*) + __sk_close_refusal=unreadable ;; + *'"reason": "the closed-sessions ledger could not be written"'*) + __sk_close_refusal=unwritable ;; + *'"reason": "'*'closed-sessions ledger'*) + __sk_close_refusal=unwritable ;; + esac + [[ $__sk_close_json == *'"recorded": true'* ]] + } + + # One sentence for the session that has to stay. It is printed wherever the + # kit would rather have closed and could not, because a row that outlives + # its provider with no explanation is the thing being fixed here: the + # person is owed the reason and the one action that ends it. + # + # Two reasons, two sentences. "You asked me not to" and "I could not promise + # the conversation back" are different facts, and one standing in for the + # other is how a person learns to stop believing the screen. + __sk_say_why_this_session_stays() { + case ${__sk_close_refusal:-} in + keep) + printf 'This one stays open because you asked to keep it. Close it from the picker when you are done; unkeep_session only lets automatic cleanup consider it again later.\n' + ;; + reopen-refused) + printf 'This one stays open because reopening was refused, and a refusal is not proof the conversation is finished. Close it from the picker when you are done with it.\n' + ;; + unreadable) + printf 'This one stays open: its conversation cannot be read back from this machine, so closing it would lose it. Close it from the picker if you are done with it.\n' + ;; + unrecorded) + printf 'Close it from the picker when you are done with it.\n' + ;; + unwritable) + printf 'This one stays open: the Closed sessions list could not be written, so closing it now would leave nothing to restore. Close it from the picker if you are done with it.\n' + ;; + *) + printf 'This one stays open: closing it here could not promise the conversation back. Close it from the picker when you are done with it.\n' + ;; + esac + } + + # Where a session goes when the provider is gone for good AND the close + # could not keep the conversation. The picker is the answer when this + # terminal came from it; a managed shell is the honest fallback when the + # detach cannot run. The session is kept alive here for one reason: without + # a recorded conversation it is the only thing that still knows which + # conversation this was, so ending it would lose the thing the operator + # would want back. + # + # When the conversation IS recorded, the caller closes instead, see + # __sk_close_keeps_the_conversation. Leaving a row behind that says + # "provider exited" for 72 hours was the operator's complaint (2026-08-15): + # "the session closes at once and lands in Closed sessions, recoverable, + # under its REAL name." + __sk_leave_after_provider_exit() { + if __sk_detach_to_picker; then + return 0 fi - echo "Close cancelled." + echo "The picker is not reachable from here. Shell opened; type kit for the picker." + return 0 } - __sk_provider_exit_menu() { - local __sk_menu_choice + # A crashed provider heals itself instead of asking (D14). The conversation + # is reopened once, with one line saying so. A second crash within a minute + # of that reopen is a loop rather than a hiccup, so it stops there and hands + # the window back to the picker with the session still open and still + # reopenable. A reopened conversation that ends cleanly closes the session, + # exactly as a first clean exit does. + __sk_reopen_after_crash() { + local __sk_reopen_status __sk_reopen_started __sk_reopen_ended while true; do - printf '\nProvider exited: [r] reopen conversation [k] keep terminal [s] shell [c] close\n' - if ! IFS= read -r -p "Choice: " __sk_menu_choice; then - # EOF or a terminal signal is user activity. Closing is safe even when - # the private input marker cannot be written. - __sk_lifecycle_event user-input >/dev/null 2>&1 || true + printf '\n%s crashed. Reopened.\n' "${__sk_lifecycle_provider^}" + printf -v __sk_reopen_started '%(%s)T' -1 + __sk_lifecycle_event reopen + __sk_reopen_status=$? + if (( __sk_reopen_status == 0 )); then + # Intent, written before the shell can end: a session closed on + # purpose must never come back as an unclaimed crash offer. Its + # answer is read rather than discarded -- see __sk_close_and_report. + __sk_close_and_report + printf '\n%s exited. Closing this session.\n' "${__sk_lifecycle_provider^}" builtin exit fi - __sk_menu_choice=${__sk_menu_choice,,} - if [[ $__sk_menu_choice == c || $__sk_menu_choice == close ]]; then - __sk_lifecycle_event user-input >/dev/null 2>&1 || true - builtin exit + if (( __sk_reopen_status != 76 )); then + # A REFUSAL NEVER CLOSES. It used to: every status that was not 76 + # was folded into the close path without anyone reading the reason, + # and one of those reasons is `lifecycle reopen` refusing BECAUSE the + # trusted inventory says a provider is already running in this + # terminal. The code proved a provider was alive and then ended the + # session anyway (found in review, 2026-08-15). + # + # There is no safe way to tell the refusals apart from a status alone, + # and the ones that are ambiguous -- the session list could not be + # trusted, the daemon generation changed, the recorded exit was not + # this provider -- are exactly the ones where closing could destroy a + # conversation. So the rule is now positive rather than residual: a + # session closes when the reopen RAN and said the conversation is + # over, and stays for everything else, including any status this code + # does not recognise. The 72-hour backstop and `k` in the picker both + # still reach it; nothing is stranded, it just is not ended by a + # failure that was never evidence of anything. + echo "Nothing reopened. Back to the picker; this session stays reopenable." >&2 + __sk_close_refusal=reopen-refused + __sk_say_why_this_session_stays + __sk_leave_after_provider_exit + return 0 fi - if ! __sk_lifecycle_event user-input >/dev/null 2>&1; then - echo "Choice not applied: Session Kit could not record terminal use. Close remains available." >&2 - continue + printf -v __sk_reopen_ended '%(%s)T' -1 + if (( __sk_reopen_ended - __sk_reopen_started < 60 )); then + if __sk_close_keeps_the_conversation; then + printf '\n%s crashed twice. Closing this session; the conversation is in Closed sessions.\n' \ + "${__sk_lifecycle_provider^}" + builtin exit + fi + printf '\n%s crashed twice. Back to the picker.\n' \ + "${__sk_lifecycle_provider^}" + __sk_say_why_this_session_stays + __sk_leave_after_provider_exit + return 0 fi - case "$__sk_menu_choice" in - r|reopen) - if ! __sk_lifecycle_event reopen; then - echo "Exact recovery is not ready. Choose shell to use the provider directly, or close and reopen from the dashboard." >&2 - fi - ;; - k|keep) - keep_session - ;; - s|shell) - echo "Shell opened. This terminal is permanently excluded from automatic cleanup." - return 0 - ;; - *) - echo "Unknown choice. Use r, k, s, or c." - ;; - esac done } + # THE MARKER. The provider exited and its record could not be written, twice, + # so nothing here can prove which conversation this was and nothing may close + # it. This runs down here, and not where the failure was detected, for the + # dullest possible reason: the functions it needs are defined above this line + # and below that one. + if [[ ${__sk_record_unwritable:-0} == 1 ]]; then + printf '\n[session-kit: %s exited, but its record could not be written, so this session will not close itself.]\n' \ + "${__sk_lifecycle_provider^}" >&2 + __sk_close_refusal=unrecorded + __sk_say_why_this_session_stays + __sk_leave_after_provider_exit + fi + unset __sk_record_unwritable + + # A CLEAN provider exit is the operator saying "done here", and the session + # goes with them (operator rule, 2026-08-11, replacing the detach-and-stay- + # reopenable rule of the same day): the managed shell ends, shpool ends the + # session with it, and the terminal number returns to the ordinary 7-day + # quarantine, the same outcome as pressing k on this row from the main + # menu, reached through the same close this shell has always used rather + # than a second implementation of it. The exact conversation stays + # recoverable for its retention window; /kit is the verb for leaving one + # running. Detaching on a clean exit, and the machine-local marker that used + # to opt into closing instead, are both gone: one policy, no marker. + # + # A CRASH heals itself: the conversation is reopened once, and a second + # crash inside a minute lands at the picker with this session still open, + # because a crashed session is the only thing that still knows its exact + # identity, and nothing about a crash says the operator was finished. if [[ ${__sk_provider_exited:-0} == 1 ]]; then - __sk_provider_exit_menu + if (( ${__sk_provider_exit_code:-1} == 0 )); then + __sk_lifecycle_event user-input >/dev/null 2>&1 || true + # Intent, written before the shell can end: a session closed on purpose + # must never come back as an unclaimed crash offer. Its answer is read + # rather than discarded -- see __sk_close_and_report. + __sk_close_and_report + printf '\n%s exited. Closing this session.\n' "${__sk_lifecycle_provider^}" + builtin exit + fi + printf '\n%s exited with status %s.\n' \ + "${__sk_lifecycle_provider^}" "${__sk_provider_exit_code:-unknown}" + __sk_reopen_after_crash fi - unset __sk_provider_exited + unset __sk_provider_exited __sk_provider_exit_code # Local, scoped Needs-you marker. The renderer caches safely and never sends # an external notification. @@ -423,10 +1606,19 @@ PY __sk_mtime=$(stat -c %Y "$__sk_cache" 2>/dev/null || stat -f %m "$__sk_cache" 2>/dev/null || echo 0) __sk_age=$((__sk_now - __sk_mtime)) if (( __sk_age > 20 )); then + # touch first: even if the probe hangs against a jammed manager, the + # next 20s of prompts skip spawning more; timeout reaps the straggler. ( umask 077 mkdir -p "$(dirname "$__sk_cache")" - "$HOME/.local/bin/shpool_status" --waiting-count > "$__sk_cache" 2>/dev/null + touch -- "$__sk_cache" + if python3 "$HOME/.local/lib/session-kit/current/lib/session_inventory.py" \ + platform timeout 15 -- "$HOME/.local/bin/shpool_status" --waiting-count \ + > "$__sk_cache.new" 2>/dev/null; then + mv -f -- "$__sk_cache.new" "$__sk_cache" + else + rm -f -- "$__sk_cache.new" + fi ) & fi if [[ -r $__sk_cache ]]; then @@ -439,21 +1631,54 @@ PY printf '\001\e[33m\002●%s\001\e[0m\002 ' "$__sk_count" fi } - PS1="\[\e[38;5;71m\]▌\[\e[0m\] \$(__sk_waiting)$PS1" + # The bar at the start of the prompt is this session's colour, the same one + # the picker draws and the same one the provider window carries. It used to + # be a constant green: one session, two owners, and a person looking at a + # green row in the picker and a differently coloured terminal had no way to + # tell which was lying. The refresh publishes one tiny file per session, so + # the prompt reads it and forks nothing; an absent file keeps the old green. + __sk_bar() { + local __sk_prompt_state_root=${XDG_STATE_HOME:-"$HOME/.local/state"} + local __sk_color_file="$__sk_prompt_state_root/session-kit/session-color/$SHPOOL_SESSION_NAME" + local __sk_color_name= __sk_color_code= + if [[ -r $__sk_color_file ]]; then + read -r __sk_color_name __sk_color_code < "$__sk_color_file" 2>/dev/null + fi + [[ $__sk_color_code =~ ^[0-9]+(\;[0-9]+)*$ ]] || __sk_color_code="38;5;71" + printf '\001\e[%sm\002▌\001\e[0m\002 ' "$__sk_color_code" + } + PS1="\$(__sk_bar)\$(__sk_waiting)$PS1" fi unset __sk_state_root __sk_journal_root __sk_session_journal __sk_journal_ready __sk_script_rc -# Auto-open the read-only picker only for interactive SSH terminals. -# Escape hatch: touch ~/.no_shpool. -if [[ $- == *i* && -n ${SSH_CONNECTION:-} && -z ${SHPOOL_SESSION_NAME:-} && -t 0 \ - && ! -e $HOME/.no_shpool && -x $HOME/.local/bin/shpool_login ]]; then - "$HOME/.local/bin/shpool_login" - __sk_rc=$? - if [[ $__sk_rc -eq 0 ]]; then builtin exit; fi - if [[ $__sk_rc -ne 2 ]]; then - echo "[session picker unavailable; continuing in a plain shell]" +# `kit` opens the session picker on demand (maintainer decision, 2026-08-02: +# SSH lands in a regular shell; the picker only ever appears when typed). +# Works from any plain terminal; inside a managed session it shows the read-only list +# instead, because attaching from inside a session would nest shpool. +kit() { + if [[ -n ${SHPOOL_SESSION_NAME:-} ]]; then + "$HOME/.local/bin/sp" list + printf ' Inside a session. Open others from a new window, or type bye to close this one.\n' + return 0 + fi + if [[ -t 0 && -t 1 && -x $HOME/.local/bin/shpool_login_launcher ]]; then + "$HOME/.local/bin/shpool_login_launcher" + local __kit_rc=$? + # 0 = the person left the picker on purpose, whether or not a session was + # opened first. Anything else is the picker failing to run, and it has + # already said why on stderr. + if (( __kit_rc != 0 )); then + echo "session-kit: the picker could not start." >&2 + fi + return 0 fi - unset __sk_rc + "$HOME/.local/bin/sp" list +} + +# SSH lands in a regular shell; one dim static hint, no inventory cost. +if [[ $- == *i* && -n ${SSH_CONNECTION:-} && -z ${SHPOOL_SESSION_NAME:-} && -t 0 ]]; then + printf ' kit: session kit\n' fi + # ---- end session-kit shpool integration ------------------------------------- diff --git a/bin/reset-collection-order.py b/bin/reset-collection-order.py new file mode 100755 index 0000000..88a6d4b --- /dev/null +++ b/bin/reset-collection-order.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Offline, preserving reset for irrecoverably lost collection ordering.""" + +from __future__ import annotations + +import datetime as dt +import fcntl +import json +import os +from pathlib import Path +import stat + + +DOCUMENT_NAMES = ( + "inventory.json", + "terminal-numbers.json", + "terminal-numbers-retired.json", + "terminal-numbers.initialized.json", + "recovery-manifest.json", + "recovery-pending.json", +) +SEQUENCE_NAMES = ("collection-sequence.json", "collection-sequence-floor.json") +MARKER_NAME = "collection-markers" +RESET_NAMES = (*DOCUMENT_NAMES, *SEQUENCE_NAMES, MARKER_NAME) +PENDING_NAME = ".collection-order-reset.pending" +MANIFEST_NAME = "reset-manifest.json" + + +def refuse(message: str) -> None: + raise SystemExit(f"session-kit: refusing collection-order reset: {message}") + + +def entry_exists(path: Path) -> bool: + return os.path.lexists(path) + + +def sync_directory(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def require_private_root(root: Path) -> None: + try: + info = root.lstat() + except OSError as exc: + refuse(f"cannot inspect state directory {root}: {exc}") + if ( + not stat.S_ISDIR(info.st_mode) + or stat.S_ISLNK(info.st_mode) + or stat.S_IMODE(info.st_mode) != 0o700 + or info.st_uid != os.geteuid() + ): + refuse(f"state directory is not an owner-only real directory: {root}") + + +def readable_sequence(path: Path) -> bool: + try: + info = path.lstat() + if ( + not stat.S_ISREG(info.st_mode) + or stat.S_ISLNK(info.st_mode) + or stat.S_IMODE(info.st_mode) != 0o600 + or info.st_uid != os.geteuid() + or info.st_nlink != 1 + or info.st_size > 8192 + ): + return False + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, ValueError): + return False + sequence = value.get("last_collection_start") if isinstance(value, dict) else None + return isinstance(sequence, int) and not isinstance(sequence, bool) and sequence > 0 + + +def require_safe_entry(path: Path) -> None: + try: + info = path.lstat() + except OSError as exc: + refuse(f"cannot inspect state entry {path}: {exc}") + if info.st_uid != os.geteuid() or stat.S_ISLNK(info.st_mode): + refuse(f"unsafe state entry: {path}") + if stat.S_ISREG(info.st_mode): + if stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1: + refuse(f"unsafe state file: {path}") + return + if stat.S_ISDIR(info.st_mode): + if stat.S_IMODE(info.st_mode) != 0o700: + refuse(f"unsafe state directory: {path}") + try: + children = list(path.iterdir()) + except OSError as exc: + refuse(f"cannot inspect state directory {path}: {exc}") + for child in children: + require_safe_entry(child) + return + refuse(f"unsupported state entry: {path}") + + +def write_manifest(path: Path, names: list[str]) -> None: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) + descriptor = os.open(path, flags, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump({"schema_version": 1, "entries": names}, handle, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + + +def read_manifest(path: Path) -> list[str]: + require_safe_entry(path) + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, ValueError) as exc: + refuse(f"cannot read pending reset manifest: {exc}") + names = value.get("entries") if isinstance(value, dict) else None + if ( + not isinstance(value, dict) + or value.get("schema_version") != 1 + or not isinstance(names, list) + or not names + or any(name not in RESET_NAMES for name in names) + or len(set(names)) != len(names) + ): + refuse("pending reset manifest is invalid") + return names + + +def state_root() -> Path: + explicit = os.environ.get("SESSION_KIT_STATE_DIR") + if explicit: + return Path(explicit).expanduser() + state_home = Path( + os.environ.get("XDG_STATE_HOME", os.fspath(Path.home() / ".local/state")) + ).expanduser() + return state_home / "session-kit" + + +def main() -> int: + root = state_root() + require_private_root(root) + lock = root / "inventory-v2.lock" + flags = ( + os.O_RDWR + | os.O_CREAT + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + descriptor = os.open(lock, flags, 0o600) + try: + lock_info = os.fstat(descriptor) + lock_path_info = lock.lstat() + if ( + not stat.S_ISREG(lock_info.st_mode) + or stat.S_IMODE(lock_info.st_mode) != 0o600 + or lock_info.st_uid != os.geteuid() + or lock_info.st_nlink != 1 + or (lock_info.st_dev, lock_info.st_ino) + != (lock_path_info.st_dev, lock_path_info.st_ino) + ): + refuse(f"unsafe publication lock: {lock}") + fcntl.flock(descriptor, fcntl.LOCK_EX) + locked_path_info = lock.lstat() + if (lock_info.st_dev, lock_info.st_ino) != ( + locked_path_info.st_dev, + locked_path_info.st_ino, + ): + refuse(f"publication lock changed while locking: {lock}") + pending = root / PENDING_NAME + if entry_exists(pending): + require_safe_entry(pending) + names = read_manifest(pending / MANIFEST_NAME) + else: + for name in SEQUENCE_NAMES: + if readable_sequence(root / name): + refuse("a durable sequence record is readable; reset is unnecessary") + names = [name for name in RESET_NAMES if entry_exists(root / name)] + if not names: + refuse("no lost collection-order state exists") + for name in names: + require_safe_entry(root / name) + pending.mkdir(mode=0o700) + write_manifest(pending / MANIFEST_NAME, names) + sync_directory(pending) + sync_directory(root) + + for name in names: + source = root / name + target = pending / name + if entry_exists(target): + require_safe_entry(target) + if entry_exists(source): + refuse(f"reset entry exists in both live state and archive: {name}") + continue + if not entry_exists(source): + refuse(f"reset entry disappeared before it was archived: {name}") + require_safe_entry(source) + os.replace(source, target) + sync_directory(root) + sync_directory(pending) + + stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + archive = root / f"collection-order-reset-{stamp}" + if entry_exists(archive): + refuse(f"archive path already exists: {archive}") + os.replace(pending, archive) + sync_directory(root) + print(archive) + return 0 + finally: + os.close(descriptor) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bin/session-kit b/bin/session-kit index 8b42865..763a895 100755 --- a/bin/session-kit +++ b/bin/session-kit @@ -1,42 +1,124 @@ #!/usr/bin/env bash # Install and maintain a per-user Session Kit installation. +if [[ $(uname -s 2>/dev/null) == Darwin && ${BASH_VERSINFO[0]} -lt 4 ]]; then + for modern_bash in /opt/homebrew/bin/bash /usr/local/bin/bash; do + if [[ -x $modern_bash ]]; then + export PATH="${modern_bash%/*}:$HOME/.cargo/bin:$HOME/.local/bin:$PATH" + exec "$modern_bash" "$0" "$@" + fi + done + printf '%s\n' 'session-kit: macOS needs Homebrew Bash 4 or newer. Install it with: brew install bash python rust' >&2 + exit 69 +fi + set -euo pipefail +export PATH="$HOME/.cargo/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" + program=${0##*/} install_root=${SESSION_KIT_ROOT:-"$HOME/.local/lib/session-kit"} bin_dir=${SESSION_KIT_BIN_DIR:-"$HOME/.local/bin"} config_root=${SESSION_KIT_CONFIG_ROOT:-"${XDG_CONFIG_HOME:-"$HOME/.config"}/session-kit"} state_root=${SESSION_KIT_STATE_DIR:-"${XDG_STATE_HOME:-"$HOME/.local/state"}/session-kit"} -systemd_root=${SESSION_KIT_SYSTEMD_ROOT:-"${XDG_CONFIG_HOME:-"$HOME/.config"}/systemd/user"} +claude_settings=${SESSION_KIT_CLAUDE_SETTINGS:-"${SESSION_KIT_CLAUDE_HOME:-"$HOME/.claude"}/settings.json"} +codex_hooks=${SESSION_KIT_CODEX_HOOKS:-"${SESSION_KIT_CODEX_HOME:-${CODEX_HOME:-"$HOME/.codex"}}/hooks.json"} +case "$(uname -s 2>/dev/null || true)" in + Darwin) + service_root=${SESSION_KIT_SERVICE_ROOT:-${SESSION_KIT_LAUNCHD_ROOT:-"$HOME/Library/LaunchAgents"}} + ;; + *) + service_root=${SESSION_KIT_SERVICE_ROOT:-${SESSION_KIT_SYSTEMD_ROOT:-"${XDG_CONFIG_HOME:-"$HOME/.config"}/systemd/user"}} + ;; +esac +# Session recordings. The kit writes nothing larger, and nothing ages them +# out: they go when the session they belong to is closed or pruned. +journal_dir=${SESSION_KIT_JOURNAL_DIR:-"${XDG_STATE_HOME:-"$HOME/.local/state"}/shpool-journal"} bashrc_path=${SESSION_KIT_BASHRC:-"$HOME/.bashrc"} +# One completion file, installed under each command name that takes +# arguments, in the directory bash-completion loads user files from. Set +# SESSION_KIT_COMPLETION_DIR to none to skip installing them entirely. +completion_dir=${SESSION_KIT_COMPLETION_DIR:-"${XDG_DATA_HOME:-"$HOME/.local/share"}/bash-completion/completions"} +completion_names=(sp session-kit shpool_status shpool_reaper codex_resume_here) +completion_marker='# session-kit managed bash completion v1' +bash_profile_path=${SESSION_KIT_BASH_PROFILE:-"$HOME/.bash_profile"} +zshrc_path=${SESSION_KIT_ZSHRC:-"$HOME/.zshrc"} integration_marker=${SESSION_KIT_INTEGRATION_MARKER:-"$state_root/integration-ready-v1"} receipt_path=$state_root/install.json +claude_statusline_backups=$state_root/claude-statusline-backups.json +claude_integration_ledger=$state_root/claude-integration.json +manager_link=$install_root/manager shpool_config=${XDG_CONFIG_HOME:-"$HOME/.config"}/shpool/config.toml login_start="# >>> session-kit managed integration >>>" login_end="# <<< session-kit managed integration <<<" -helpers=(sp shpool_login shpool_status shpool_reaper codex_resume_here) -units=( +helpers=(kit sp shpool_login shpool_login_tui shpool_login_launcher shpool_status shpool_reaper codex_resume_here) +systemd_units=( shpool.service shpool.socket shpool-reaper.service shpool-reaper.timer + session-kit-subagent-sweep.service + session-kit-subagent-sweep.timer session-kit-watchdog.service ) -required_commands=( - bash python3 git shpool - cmp cp find flock install journalctl mkdir mktemp mv pgrep ps rm script - sha256sum stat systemctl timeout +# Long-running kit processes that hold their own code: bash keeps the inode it +# exec'd, so one of these goes on running the release it started with until it +# is restarted. Deliberately excludes shpool.service and shpool.socket -- +# restarting the session daemon ends every managed session. +# Timers an update may enable when systemd has never heard of them. A new +# timer that ships disabled is a rule that silently does not run. +systemd_timer_units=( + session-kit-subagent-sweep.timer +) +systemd_restart_units=( + session-kit-watchdog.service +) +launchd_units=( + com.session-kit.shpool.plist + com.session-kit.reaper.plist + com.session-kit.watchdog.plist +) +launchd_restart_units=( + com.session-kit.watchdog.plist +) +launchd_template_root=$config_root/launchd +launchd_receipt=$state_root/launchd-active.json +common_required_commands=( + bash python3 shpool cmp cp find install mkdir mktemp mv pgrep ps rm script stat ) inventory_files=( lib/session_inventory.py lib/sessionkit_inventory/__init__.py lib/sessionkit_inventory/common.py lib/sessionkit_inventory/lifecycle.py + lib/sessionkit_inventory/projects.py lib/sessionkit_inventory/providers.py lib/sessionkit_inventory/reaper.py lib/sessionkit_inventory/state_io.py ) +shell_modules=( + lib/sh/session_kit_checks.sh + lib/sh/session_kit_doctor.sh + lib/sh/session_kit_install.sh + lib/sh/session_kit_lifecycle.sh + lib/sh/session_kit_login.sh + lib/sh/session_kit_projects.sh + lib/sh/session_kit_services.sh + lib/sh/session_kit_uninstall.sh + lib/sh/shpool_login_actions.sh + lib/sh/shpool_login_live.sh + lib/sh/shpool_login_projects.sh + lib/sh/shpool_login_recovery.sh + lib/sh/shpool_login_render.sh + lib/sh/shpool_login_theme.sh + lib/sh/shpool_login_view.sh + lib/sh/sp_commands.sh + lib/sh/sp_core.sh + lib/sh/sp_picker.sh + lib/sh/sp_provider_bounce.sh + lib/sh/sp_sessions.sh + lib/sh/sp_completion.bash +) die() { printf '%s: %s\n' "$program" "$*" >&2 @@ -45,20 +127,60 @@ die() { usage() { cat <<'EOF' -Usage: - session-kit doctor [--json] - session-kit update [--source PATH] [--enable-login|--disable-login] [--journal on|off] +session-kit installs and maintains this machine's Session Kit installation. + +usage: session-kit <command> [arguments] + +Check and repair + session-kit doctor [--json] check every installed part and report + session-kit services <enable|disable|status> + the user services: manager, reaper, watchdog + +Projects + session-kit projects <discover|import|list> + find, adopt, and show project shortcuts + session-kit projects add [ALIAS] /absolute/path + one entry per directory; which provider + opens it is chosen when a session starts + session-kit projects default ALIAS <claude|codex|shell|any> + what it opens with unless you name another + session-kit projects normalize fold a directory listed twice into one entry + session-kit projects ignore /absolute/path + session-kit projects unignore /absolute/path + session-kit projects launch-plan [ALIAS] + what `sp new` would start there, and whether + its startup command is approved + session-kit projects approve-startup [ALIAS] + approve that project's startup command + session-kit projects check PATH validate a session-kit.toml + +Releases + session-kit update [--source PATH] [--enable-login|--disable-login] [--journal on|off] [--force] + install a newer release from a source tree session-kit rollback [--to COMMIT] - session-kit disable-login - session-kit enable-login + return to a release already on this machine + +Login integration + session-kit enable-login open the picker on interactive SSH logins + session-kit disable-login stop opening it; sessions are untouched + +Removal session-kit uninstall [--purge-code] [--purge-config] From a cloned repository: ./install.sh --check - ./install.sh [--enable-login|--disable-login] [--journal on|off] + ./install.sh [--enable-login|--disable-login] [--journal on|off] [--force] + [--import-projects|--no-import-projects] [--non-interactive] -Session Kit keeps transcripts, journals, and status data on this machine. It -does not send telemetry. Uninstalling never deletes journals or archives. +The installer asks nothing. Login integration is on and history recording is +off unless a flag says otherwise, and the install states what it chose. + +Day-to-day session commands are `sp`; run `sp help` for those, `sp help +exit-codes` for the statuses every Session Kit command returns, and `sp help +completion` for tab completion. + +Session Kit keeps session history and status data on this machine. It does +not send telemetry. Uninstalling never deletes history. EOF } @@ -80,1181 +202,112 @@ platform() { esac } -validate_roots() { - python3 - "$HOME" "$install_root" "$bin_dir" "$config_root" "$state_root" \ - "$systemd_root" "$bashrc_path" "${SESSION_KIT_TESTING:-0}" <<'PY' -import os -import pathlib -import sys - -home, *raw, testing = sys.argv[1:] -home_path = pathlib.Path(home).resolve(strict=False) -labels = ( - "install root", "binary directory", "config root", "state root", - "systemd root", "shell file", -) -paths = [pathlib.Path(value).resolve(strict=False) for value in raw] -raw_paths = [pathlib.Path(value) for value in raw] -for label, raw_path, path in zip(labels, raw_paths, paths): - if not raw_path.is_absolute(): - raise SystemExit(f"session-kit: {label} must be absolute: {raw_path}") - normalized = pathlib.Path(os.path.abspath(raw_path)) - if normalized != path: - raise SystemExit(f"session-kit: refusing symlinked {label}: {raw_path}") - if path == pathlib.Path("/") or path == home_path: - raise SystemExit(f"session-kit: refusing broad {label}: {path}") - if len(path.parts) < 4 and testing != "1": - raise SystemExit(f"session-kit: refusing broad {label}: {path}") -install, binary, config, state, systemd, shell = paths -for label, path in (("install root", install), ("config root", config), ("state root", state)): - if path in { - home_path / ".local", - home_path / ".local" / "lib", - home_path / ".local" / "state", - home_path / ".config", - }: - raise SystemExit(f"session-kit: refusing broad {label}: {path}") -if shell.parent == pathlib.Path("/") or shell == home_path: - raise SystemExit(f"session-kit: refusing unsafe shell file: {shell}") -if len({install, binary, config, state, systemd}) != 5: - raise SystemExit("session-kit: lifecycle roots must not overlap exactly") -roots = (install, binary, config, state, systemd) -for index, left in enumerate(roots): - for right in roots[index + 1:]: - if left in right.parents or right in left.parents: - raise SystemExit(f"session-kit: lifecycle roots must not contain one another: {left}, {right}") -PY -} - -stat_mode() { - stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null -} - -release_id_from_source() { - local source=$1 release_id=${SESSION_KIT_RELEASE_ID:-} - if [[ -n $release_id && ${SESSION_KIT_TESTING:-0} != 1 ]]; then - die "SESSION_KIT_RELEASE_ID is reserved for isolated tests" - fi - if [[ -z $release_id ]] && command -v git >/dev/null 2>&1 && - git -C "$source" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - release_id=$(git -C "$source" rev-parse HEAD 2>/dev/null || true) - fi - [[ $release_id =~ ^[0-9a-f]{40}$ ]] || - die "install source must be a Git clone at a full 40-character commit" - printf '%s\n' "$release_id" -} - -check_source() { - local source=$1 failed=0 current_platform command required - current_platform=$(platform) - case "$current_platform" in - linux) printf 'OK operating system: Linux\n' ;; - macos) - printf 'FAIL macOS lifecycle operations are not supported; install is Linux-only\n' - failed=1 - ;; - *) - printf 'FAIL operating system: only Linux and the macOS core preview are supported\n' - failed=1 - ;; - esac - for command in "${required_commands[@]}"; do - if command -v "$command" >/dev/null 2>&1; then - printf 'OK command: %s\n' "$command" - else - printf 'FAIL command missing: %s\n' "$command" - failed=1 - fi - done - if (( BASH_VERSINFO[0] < 4 )); then - printf 'FAIL Bash 4 or newer is required\n' - failed=1 - else - printf 'OK Bash: %s\n' "$BASH_VERSION" - fi - if ! python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)'; then - printf 'FAIL Python 3.10 or newer is required\n' - failed=1 - fi - if ! command -v shpool >/dev/null 2>&1; then - printf 'FAIL shpool missing: install v0.11.0 with cargo install shpool --version 0.11.0 --locked\n' - failed=1 - fi - if [[ ! -d /proc && ${SESSION_KIT_TESTING:-0} != 1 ]]; then - printf 'FAIL Linux /proc is required for process identity checks\n' - failed=1 - else - printf 'OK process filesystem: /proc\n' - fi - if [[ ${SESSION_KIT_TESTING:-0} == 1 ]] || - systemctl --user show-environment >/dev/null 2>&1; then - printf 'OK user service manager: systemd\n' - else - printf 'FAIL systemd user manager is unavailable\n' - failed=1 - fi - if command -v claude >/dev/null 2>&1 || command -v codex >/dev/null 2>&1; then - printf 'OK provider CLI: Claude Code or Codex\n' - else - printf 'FAIL install Claude Code, Codex, or both\n' - failed=1 - fi - for required in \ - bin/sp bin/shpool_login bin/shpool_status bin/shpool_reaper \ - bin/codex_resume_here bin/session_kit_common bin/session-kit \ - "${inventory_files[@]}" bashrc/shpool.bashrc \ - deploy/session-kit-launcher config/projects.example.tsv \ - config/session-inventory.example.json config/shpool.example.toml; do - if [[ ! -f $source/$required ]]; then - printf 'FAIL source file missing: %s\n' "$required" - failed=1 - fi - done - if ! git -C "$source" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - printf 'FAIL source is not a Git worktree\n' - failed=1 - elif [[ ${SESSION_KIT_TESTING:-0} != 1 ]] && - [[ -n $(git -C "$source" status --porcelain --untracked-files=all) ]]; then - printf 'FAIL source worktree has uncommitted or untracked files\n' - failed=1 - fi - (( failed == 0 )) || return 1 - validate_roots - release_id_from_source "$source" >/dev/null - printf 'OK source: installable\n' -} - -atomic_symlink() { - local target=$1 destination=$2 - python3 - "$target" "$destination" <<'PY' -import os -import pathlib -import sys -import uuid - -target, destination = sys.argv[1:3] -path = pathlib.Path(destination) -path.parent.mkdir(parents=True, exist_ok=True) -temporary = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp") -try: - os.symlink(target, temporary) - os.replace(temporary, path) -finally: - try: - temporary.unlink() - except FileNotFoundError: - pass -PY -} - transaction_path=$state_root/lifecycle-transaction.json +# The eight Claude-palette names plus the six Codex-only ones, in the order +# lib/sessionkit_inventory/colors.py declares them. Codex windows only ever +# resolve one of the last six, but the first eight stay installed so a +# rollback to a release that still assigns them finds its themes in place. +codex_theme_names=( + red blue green yellow purple orange pink cyan + lime magenta silver sand sky sea +) -lifecycle_transaction() { - python3 - "$@" <<'PY' -import base64 +projects_created=0 + +# Session Kit keeps its command modules beside this program. A Git checkout +# and an installed release both hold them at ../lib/sh. The copy this program +# installs into the binary directory has no sibling lib tree, so it falls back +# to the release the current link already resolves to, which is the same code +# this copy was installed from. Every module defines functions only, so +# sourcing them here, after every global above is assigned, reproduces the +# order the original single file used. +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +module_dir=$script_dir/../lib/sh +if [[ ! -r $module_dir/session_kit_checks.sh ]]; then + module_dir= + if [[ -e $transaction_path || -L $transaction_path ]]; then + recovery_release=$(python3 - "$transaction_path" "$install_root" <<'PY' import json import os import pathlib +import re import stat import sys -import tempfile -import time -import uuid - -operation, journal_raw, *values = sys.argv[1:] -journal = pathlib.Path(journal_raw) - -def atomic_json(path, payload): - path.parent.mkdir(parents=True, exist_ok=True) - fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) - try: - os.fchmod(fd, 0o600) - with os.fdopen(fd, "w", encoding="utf-8") as handle: - json.dump(payload, handle, indent=2, sort_keys=True) - handle.write("\n") - handle.flush() - os.fsync(handle.fileno()) - os.replace(temporary, path) - finally: - try: - os.unlink(temporary) - except FileNotFoundError: - pass - -def capture(path): - try: - info = path.lstat() - except FileNotFoundError: - return {"path": str(path), "kind": "absent"} - if stat.S_ISLNK(info.st_mode): - return {"path": str(path), "kind": "symlink", "target": os.readlink(path)} - if stat.S_ISREG(info.st_mode): - return { - "path": str(path), - "kind": "file", - "mode": stat.S_IMODE(info.st_mode), - "content": base64.b64encode(path.read_bytes()).decode("ascii"), - } - raise SystemExit(f"session-kit: transaction target is unsafe: {path}") - -def restore(entry): - path = pathlib.Path(entry["path"]) - try: - info = path.lstat() - except FileNotFoundError: - info = None - if info is not None and not (stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode)): - raise SystemExit(f"session-kit: refusing incompatible transaction state: {path}") - if info is not None: - path.unlink() - kind = entry["kind"] - if kind == "absent": - return - path.parent.mkdir(parents=True, exist_ok=True) - if kind == "symlink": - os.symlink(entry["target"], path) - elif kind == "file": - data = base64.b64decode(entry["content"], validate=True) - fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) - try: - os.fchmod(fd, int(entry["mode"])) - with os.fdopen(fd, "wb") as handle: - handle.write(data) - handle.flush() - os.fsync(handle.fileno()) - os.replace(temporary, path) - finally: - try: - os.unlink(temporary) - except FileNotFoundError: - pass - else: - raise SystemExit("session-kit: invalid lifecycle transaction entry") - -if operation == "begin": - if journal.exists() or journal.is_symlink(): - raise SystemExit(f"session-kit: pending lifecycle transaction: {journal}") - entries = [capture(pathlib.Path(value)) for value in values[1:]] - payload = { - "schema_version": 1, - "transaction_id": uuid.uuid4().hex, - "action": values[0], - "started_at_unix": int(time.time()), - "state": "pending", - "entries": entries, - } - atomic_json(journal, payload) -elif operation == "recover": - if not journal.exists() and not journal.is_symlink(): - raise SystemExit(0) - info = journal.lstat() - if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600: - raise SystemExit(f"session-kit: unsafe lifecycle transaction journal: {journal}") - try: - payload = json.loads(journal.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise SystemExit(f"session-kit: invalid lifecycle transaction journal: {exc}") - if payload.get("schema_version") != 1 or payload.get("state") != "pending": - raise SystemExit("session-kit: incompatible lifecycle transaction state") - entries = payload.get("entries") - if not isinstance(entries, list): - raise SystemExit("session-kit: invalid lifecycle transaction entries") - if [entry.get("path") for entry in entries if isinstance(entry, dict)] != values: - raise SystemExit("session-kit: lifecycle transaction targets do not match this installation") - for entry in reversed(entries): - if not isinstance(entry, dict) or not isinstance(entry.get("path"), str): - raise SystemExit("session-kit: invalid lifecycle transaction entry") - restore(entry) - journal.unlink() - print(f"Recovered interrupted Session Kit {payload.get('action', 'lifecycle')} transaction.") -elif operation == "commit": - if not journal.is_file() or journal.is_symlink(): - raise SystemExit(f"session-kit: missing lifecycle transaction journal: {journal}") - payload = json.loads(journal.read_text(encoding="utf-8")) - if payload.get("schema_version") != 1 or payload.get("state") != "pending": - raise SystemExit("session-kit: incompatible lifecycle transaction state") - payload["state"] = "committed" - payload["committed_at_unix"] = int(time.time()) - backup_root = journal.parent / "backups" - backup_root.mkdir(parents=True, exist_ok=True) - os.chmod(backup_root, 0o700) - backup = backup_root / ( - f"lifecycle-{payload['committed_at_unix']}-{payload['transaction_id']}.json" - ) - atomic_json(backup, payload) - journal.unlink() -else: - raise SystemExit("session-kit: invalid lifecycle transaction operation") -PY -} - -recover_pending_transaction() { - validate_roots - local -a targets=() - mapfile -t targets < <(transaction_targets) - lifecycle_transaction recover "$transaction_path" "${targets[@]}" -} - -transaction_targets() { - local unit helper - printf '%s\n' \ - "$install_root/current" \ - "$bin_dir/session-kit" \ - "$bashrc_path" \ - "$integration_marker" \ - "$HOME/.no_shpool_journal" \ - "$receipt_path" \ - "$install_root/.session-kit-owned.json" \ - "$config_root/.session-kit-owned.json" - printf '%s\n' \ - "$config_root/projects.tsv" \ - "$config_root/inventory.json" \ - "$shpool_config" - for helper in "${helpers[@]}"; do printf '%s\n' "$bin_dir/$helper"; done - for unit in "${units[@]}"; do printf '%s\n' "$systemd_root/$unit"; done -} - -begin_transaction() { - local action=$1 - shift - umask 077 - mkdir -p "$state_root" - chmod 700 "$state_root" 2>/dev/null || true - local -a targets=() - mapfile -t targets < <(transaction_targets) - lifecycle_transaction begin "$transaction_path" "$action" "${targets[@]}" "$@" -} - -commit_transaction() { - lifecycle_transaction commit "$transaction_path" -} - -lifecycle_failpoint() { - [[ ${SESSION_KIT_TEST_FAILPOINT:-} != "$1" ]] || - die "isolated test failpoint after $1" -} - -write_receipt() { - local release_id=$1 source=$2 previous=$3 current_platform=$4 - umask 077 - mkdir -p "$state_root" - chmod 700 "$state_root" 2>/dev/null || true - python3 - "$receipt_path" "$release_id" "$source" "$previous" "$current_platform" <<'PY' -import json -import os -import pathlib -import sys -import tempfile -import time - -path, release_id, source, previous, platform = sys.argv[1:6] -destination = pathlib.Path(path) -payload = { - "schema_version": 2, - "installed_release": release_id, - "previous_release": previous or None, - "source": source, - "platform": platform, - "installed_at_unix": int(time.time()), - "roots": { - "install": os.environ.get("SESSION_KIT_ROOT", str(pathlib.Path.home() / ".local/lib/session-kit")), - "config": os.environ.get( - "SESSION_KIT_CONFIG_ROOT", - str(pathlib.Path(os.environ.get("XDG_CONFIG_HOME", pathlib.Path.home() / ".config")) / "session-kit"), - ), - }, -} -fd, temporary = tempfile.mkstemp(prefix=f".{destination.name}.", dir=destination.parent) -try: - os.fchmod(fd, 0o600) - with os.fdopen(fd, "w", encoding="utf-8") as handle: - json.dump(payload, handle, indent=2, sort_keys=True) - handle.write("\n") - handle.flush() - os.fsync(handle.fileno()) - os.replace(temporary, destination) -finally: - try: - os.unlink(temporary) - except FileNotFoundError: - pass -PY -} - -write_ownership_markers() { - local root marker kind - for kind in install config; do - if [[ $kind == install ]]; then root=$install_root; else root=$config_root; fi - marker=$root/.session-kit-owned.json - umask 077 - mkdir -p "$root" - chmod 700 "$root" 2>/dev/null || true - python3 - "$marker" "$root" "$kind" <<'PY' -import json -import os -import pathlib -import tempfile -import sys - -marker, root, kind = map(pathlib.Path, sys.argv[1:4]) -payload = { - "schema_version": 1, - "owner": "session-kit", - "kind": str(kind), - "root": str(root.resolve(strict=False)), -} -fd, temporary = tempfile.mkstemp(prefix=f".{marker.name}.", dir=marker.parent) -try: - os.fchmod(fd, 0o600) - with os.fdopen(fd, "w", encoding="utf-8") as handle: - json.dump(payload, handle, sort_keys=True) - handle.write("\n") - handle.flush() - os.fsync(handle.fileno()) - os.replace(temporary, marker) -finally: - try: - os.unlink(temporary) - except FileNotFoundError: - pass -PY - done -} - -current_release_id() { - local resolved - [[ -L $install_root/current ]] || return 0 - resolved=$(cd -P -- "$install_root/current" 2>/dev/null && pwd) || return 0 - case "$resolved" in - "$install_root"/releases/*) printf '%s\n' "${resolved##*/}" ;; - esac -} - -install_launchers() { - local release_id=$1 helper - mkdir -p "$bin_dir" - for helper in "${helpers[@]}"; do - install -m 0755 "$install_root/releases/$release_id/deploy/session-kit-launcher" \ - "$bin_dir/$helper" - done - install -m 0755 "$install_root/releases/$release_id/bin/session-kit" \ - "$bin_dir/session-kit" -} - -install_defaults() { - local release_id=$1 - mkdir -p "$config_root" - chmod 700 "$config_root" 2>/dev/null || true - [[ -e $config_root/projects.tsv ]] || - install -m 0600 "$install_root/releases/$release_id/config/projects.example.tsv" \ - "$config_root/projects.tsv" - [[ -e $config_root/inventory.json ]] || - install -m 0600 "$install_root/releases/$release_id/config/session-inventory.example.json" \ - "$config_root/inventory.json" - if [[ ! -e $shpool_config ]]; then - mkdir -p "$(dirname -- "$shpool_config")" - install -m 0600 "$install_root/releases/$release_id/config/shpool.example.toml" \ - "$shpool_config" - fi - # Session-color Codex themes. Kit-owned sk-*.tmTheme files track the - # release; anything else in the themes directory is left alone. - if [[ -d $install_root/releases/$release_id/config/codex-themes ]]; then - local codex_theme_root=${CODEX_HOME:-"$HOME/.codex"}/themes theme_file - mkdir -p "$codex_theme_root" - for theme_file in "$install_root/releases/$release_id"/config/codex-themes/sk-*.tmTheme; do - [[ -f $theme_file ]] || continue - install -m 0600 "$theme_file" "$codex_theme_root/${theme_file##*/}" - done - fi -} - -install_systemd_units() { - local release_id=$1 - [[ $(platform) == linux ]] || return 0 - mkdir -p "$systemd_root" - local unit shpool_path - for unit in "${units[@]}"; do - install -m 0644 "$install_root/releases/$release_id/systemd/$unit" \ - "$systemd_root/$unit" - done - shpool_path=$(command -v shpool) - python3 - "$systemd_root/shpool.service" "$shpool_path" <<'PY' -import os -import pathlib -import sys -import tempfile - -path = pathlib.Path(sys.argv[1]) -executable = sys.argv[2] -if any(character.isspace() for character in executable): - raise SystemExit("session-kit: shpool path contains whitespace; refusing systemd unit") -text = path.read_text(encoding="utf-8") -if text.count("@SHPOOL@") != 1: - raise SystemExit("session-kit: shpool service template is invalid") -text = text.replace("@SHPOOL@", executable) -fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) -try: - os.fchmod(fd, 0o644) - with os.fdopen(fd, "w", encoding="utf-8") as handle: - handle.write(text) - handle.flush() - os.fsync(handle.fileno()) - os.replace(temporary, path) -finally: - try: - os.unlink(temporary) - except FileNotFoundError: - pass -PY -} - -validate_purge_ownership() { - local kind=$1 root=$2 marker=$3 - [[ -r $receipt_path && -f $receipt_path && ! -L $receipt_path ]] || - die "purge requires a valid Session Kit install receipt" - [[ -r $marker && -f $marker && ! -L $marker ]] || - die "purge requires an ownership marker for $root" - python3 - "$receipt_path" "$marker" "$kind" "$root" <<'PY' -import json -import pathlib -import sys -receipt_path, marker_path, kind, root_raw = sys.argv[1:] -root = pathlib.Path(root_raw).resolve(strict=False) -try: - receipt = json.loads(pathlib.Path(receipt_path).read_text(encoding="utf-8")) - marker = json.loads(pathlib.Path(marker_path).read_text(encoding="utf-8")) -except (OSError, json.JSONDecodeError) as exc: - raise SystemExit(f"session-kit: invalid purge ownership evidence: {exc}") -if receipt.get("schema_version") != 2: - raise SystemExit("session-kit: purge requires a current ownership receipt; run update first") -roots = receipt.get("roots") -if not isinstance(roots, dict) or pathlib.Path(str(roots.get(kind, ""))).resolve(strict=False) != root: - raise SystemExit(f"session-kit: receipt does not own {kind} root: {root}") +journal = pathlib.Path(sys.argv[1]) +install_root = pathlib.Path(sys.argv[2]) +info = journal.lstat() if ( - marker.get("schema_version") != 1 - or marker.get("owner") != "session-kit" - or marker.get("kind") != kind - or pathlib.Path(str(marker.get("root", ""))).resolve(strict=False) != root + not stat.S_ISREG(info.st_mode) + or stat.S_ISLNK(info.st_mode) + or info.st_uid != os.geteuid() + or stat.S_IMODE(info.st_mode) != 0o600 ): - raise SystemExit(f"session-kit: ownership marker does not match {kind} root: {root}") -PY -} - -validate_mutable_targets() { - local target resolved - while IFS= read -r target; do - [[ ! -e $target && ! -L $target ]] && continue - if [[ -L $target ]]; then - [[ $target == "$install_root/current" ]] && continue - # Pre-receipt layouts linked helpers into the install root itself. A - # symlink resolving inside $install_root is the kit's own legacy - # launcher; install(1) replaces the link, never its target. - resolved=$(readlink -f -- "$target" 2>/dev/null) || resolved="" - [[ -n $resolved && $resolved == "$install_root"/* ]] || - die "refusing symlinked lifecycle target: $target" - continue - fi - [[ -f $target ]] || - die "refusing incompatible lifecycle target: $target" - done < <(transaction_targets) -} - -validate_uninstall_launchers() { - local release_id helper target expected - release_id=$(current_release_id) - [[ $release_id =~ ^[0-9a-f]{40}$ ]] || - die "uninstall requires a valid active Session Kit release" - verify_release "$install_root/releases/$release_id" "$release_id" - for helper in "${helpers[@]}"; do - target=$bin_dir/$helper - expected=$install_root/releases/$release_id/deploy/session-kit-launcher - [[ ! -e $target && ! -L $target ]] && continue - [[ -f $target && ! -L $target ]] || - die "refusing to remove unsafe launcher: $target" - cmp -s -- "$target" "$expected" || - die "refusing to remove launcher with unowned content: $target" - done - target=$bin_dir/session-kit - expected=$install_root/releases/$release_id/bin/session-kit - if [[ -e $target || -L $target ]]; then - [[ -f $target && ! -L $target ]] || - die "refusing to remove unsafe command: $target" - cmp -s -- "$target" "$expected" || - die "refusing to remove command with unowned content: $target" - fi -} - -rewrite_login() { - local action=$1 - mkdir -p "$(dirname -- "$bashrc_path")" - if [[ $action == disable && ! -e $bashrc_path && ! -L $bashrc_path ]]; then - return 0 - fi - [[ -e $bashrc_path ]] || : > "$bashrc_path" - [[ -f $bashrc_path && ! -L $bashrc_path ]] || - die "refusing to modify non-regular shell file: $bashrc_path" - python3 - "$bashrc_path" "$action" "$login_start" "$login_end" <<'PY' -import os -import pathlib -import sys -import tempfile - -path_raw, action, start, end = sys.argv[1:5] -path = pathlib.Path(path_raw) -text = path.read_text(encoding="utf-8") -begin = text.find(start) -finish = text.find(end) -if (begin < 0) != (finish < 0) or (begin >= 0 and finish < begin): - raise SystemExit("session-kit: shell integration markers are incomplete; refusing to edit") -if begin >= 0: - finish += len(end) - if finish < len(text) and text[finish] == "\n": - finish += 1 - before = text[:begin].rstrip("\n") - after = text[finish:].lstrip("\n") - text = before + ("\n" if before and after else "") + after -if action == "enable": - block = ( - f"{start}\n" - 'if [[ -r "$HOME/.local/lib/session-kit/current/bashrc/shpool.bashrc" ]]; then\n' - ' source "$HOME/.local/lib/session-kit/current/bashrc/shpool.bashrc"\n' - "fi\n" - f"{end}\n" - ) - text = text.rstrip("\n") + ("\n\n" if text.strip() else "") + block -fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + raise SystemExit("unsafe pending lifecycle transaction") try: - os.fchmod(fd, path.stat().st_mode & 0o777) - with os.fdopen(fd, "w", encoding="utf-8") as handle: - handle.write(text) - handle.flush() - os.fsync(handle.fileno()) - os.replace(temporary, path) -finally: - try: - os.unlink(temporary) - except FileNotFoundError: - pass -PY -} - -backup_login_once() { - local backup_root=$state_root/backups - local backup=$backup_root/bashrc.before-session-kit - local absent=$backup_root/bashrc.was-absent - [[ -e $backup || -e $absent ]] && return 0 - umask 077 - mkdir -p "$backup_root" - chmod 700 "$backup_root" 2>/dev/null || true - if [[ -e $bashrc_path || -L $bashrc_path ]]; then - [[ -f $bashrc_path && ! -L $bashrc_path ]] || - die "refusing to back up non-regular shell file: $bashrc_path" - install -m 0600 "$bashrc_path" "$backup" - else - : > "$absent" - chmod 600 "$absent" - fi -} - -set_journal_choice() { - case "$1" in - on) - if [[ -e $HOME/.no_shpool_journal ]]; then - [[ -f $HOME/.no_shpool_journal && ! -L $HOME/.no_shpool_journal ]] || - die "refusing to replace unsafe journal sentinel" - command rm -- "$HOME/.no_shpool_journal" - fi - ;; - off) - if [[ -e $HOME/.no_shpool_journal ]]; then - [[ -f $HOME/.no_shpool_journal && ! -L $HOME/.no_shpool_journal ]] || - die "refusing to replace unsafe journal sentinel" - else - (umask 077; : > "$HOME/.no_shpool_journal") - fi - ;; - *) die "--journal must be on or off" ;; - esac -} - -enable_login() { - backup_login_once - rewrite_login enable - if [[ -L $install_root/current ]]; then - local release_id - release_id=$(current_release_id) - [[ $release_id =~ ^[0-9a-f]{40}$ ]] || die "active release is invalid" - umask 077 - mkdir -p "$state_root" - printf 'session-kit-integration-v1 %s\n' "$release_id" > "$integration_marker" - chmod 600 "$integration_marker" - fi - printf 'SSH picker enabled in %s\n' "$bashrc_path" -} - -disable_login() { - rewrite_login disable - if [[ -e $integration_marker ]]; then - [[ -f $integration_marker && ! -L $integration_marker ]] || - die "refusing to remove unsafe integration marker" - command rm -- "$integration_marker" - fi - printf 'SSH picker disabled. Existing sessions and journals were not changed.\n' -} - -verify_release() { - python3 - "$1" "${2:-}" <<'PY' -import hashlib -import json -import pathlib -import re -import stat -import sys - -root = pathlib.Path(sys.argv[1]) -expected_commit = sys.argv[2] -if not root.is_dir() or root.is_symlink(): - raise SystemExit(f"session-kit: release is not a real directory: {root}") -try: - meta = json.loads((root / "RELEASE.json").read_text(encoding="utf-8")) -except (OSError, json.JSONDecodeError) as exc: - raise SystemExit(f"session-kit: invalid release metadata: {exc}") + payload = json.loads(journal.read_text(encoding="utf-8")) +except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise SystemExit(f"invalid pending lifecycle transaction: {exc}") +entries = payload.get("entries") if ( - meta.get("schema_version") != 1 - or meta.get("lifecycle_schema_version") != 2 - or not isinstance(meta.get("commit"), str) + payload.get("schema_version") != 1 + or payload.get("state") != "pending" + or not isinstance(entries, list) + or not entries + or not isinstance(entries[0], dict) + or entries[0].get("path") != os.fspath(install_root / "current") ): - raise SystemExit("session-kit: incompatible release metadata") -commit = meta["commit"] -if not re.fullmatch(r"[0-9a-f]{40}", commit) or root.name != commit: - raise SystemExit("session-kit: invalid release identity") -if expected_commit and commit != expected_commit: - raise SystemExit("session-kit: release identity does not match requested commit") -manifest = root / "MANIFEST.sha256" -try: - lines = manifest.read_text(encoding="utf-8").splitlines() -except OSError as exc: - raise SystemExit(f"session-kit: missing release manifest: {exc}") -expected = {} -for number, line in enumerate(lines, 1): - match = re.fullmatch(r"([0-9a-f]{64}) ([^\0]+)", line) - if not match: - raise SystemExit(f"session-kit: malformed release manifest line {number}") - rel = pathlib.PurePosixPath(match.group(2)) - if rel.is_absolute() or ".." in rel.parts or str(rel) in expected: - raise SystemExit("session-kit: unsafe release manifest path") - expected[str(rel)] = match.group(1) -actual = {} -for path in root.rglob("*"): - rel = path.relative_to(root).as_posix() - if path.is_symlink(): - raise SystemExit(f"session-kit: release contains symlink: {rel}") - if path.is_file() and rel != "MANIFEST.sha256": - actual[rel] = hashlib.sha256(path.read_bytes()).hexdigest() - if path.is_dir() and stat.S_IMODE(path.stat().st_mode) & 0o222: - raise SystemExit(f"session-kit: writable immutable release directory: {rel}") -if expected != actual: - differing = sorted(set(expected) ^ set(actual)) - if not differing: - differing = sorted(name for name in expected if expected[name] != actual[name]) - raise SystemExit(f"session-kit: release verification failed: {', '.join(differing[:3])}") -for rel in ("bin/session-kit", "deploy/session-kit-launcher"): - path = root / rel - if not path.is_file() or path.is_symlink() or not stat.S_IMODE(path.stat().st_mode) & 0o111: - raise SystemExit(f"session-kit: release executable is invalid: {rel}") -PY -} - -write_release_manifest() { - python3 - "$1" "$2" <<'PY' -import hashlib -import json -import os -import pathlib -import stat -import sys - -root = pathlib.Path(sys.argv[1]) -commit = sys.argv[2] -meta = root / "RELEASE.json" -meta.write_text( - json.dumps( - { - "schema_version": 1, - "lifecycle_schema_version": 2, - "commit": commit, - }, - indent=2, - sort_keys=True, - ) + "\n", - encoding="utf-8", -) -for path in sorted(root.rglob("*"), reverse=True): - if path.is_symlink(): - raise SystemExit(f"session-kit: release source contains symlink: {path}") - if path.is_file(): - mode = stat.S_IMODE(path.stat().st_mode) - os.chmod(path, 0o555 if mode & 0o111 else 0o444) -lines = [] -for path in sorted((path for path in root.rglob("*") if path.is_file())): - rel = path.relative_to(root).as_posix() - lines.append(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {rel}") -manifest = root / "MANIFEST.sha256" -manifest.write_text("\n".join(lines) + "\n", encoding="utf-8") -os.chmod(manifest, 0o444) -for path in sorted((path for path in root.rglob("*") if path.is_dir()), reverse=True): - os.chmod(path, 0o555) -os.chmod(root, 0o555) -PY -} - -install_release() { - local source=$1 release_id=$2 - local release_dir=$install_root/releases/$release_id - if [[ -e $release_dir ]]; then - [[ -d $release_dir && ! -L $release_dir ]] || - die "release path is unsafe: $release_dir" - verify_release "$release_dir" "$release_id" - return 0 - fi - mkdir -p "$install_root/releases" - local staging=$install_root/releases/.install-"$release_id"-$$ - [[ ! -e $staging ]] || die "staging path already exists" - mkdir "$staging" - local item - for item in bin lib bashrc config deploy systemd macos shpool-patch; do - [[ -e $source/$item ]] || continue - cp -R "$source/$item" "$staging/$item" - done - find "$staging/bin" "$staging/deploy" -type f -exec chmod 0755 {} + - write_release_manifest "$staging" "$release_id" - mv "$staging" "$release_dir" - verify_release "$release_dir" "$release_id" -} - -install_command() { - local source= login=prompt journal=prompt answer noninteractive=0 - while (($#)); do - case "$1" in - --source) require_arg "$1" "${2:-}"; source=$2; shift 2 ;; - --enable-login) login=enable; shift ;; - --disable-login) login=disable; shift ;; - --journal) require_arg "$1" "${2:-}"; journal=$2; shift 2 ;; - --non-interactive) - noninteractive=1 - [[ $journal != prompt ]] || journal=off - shift - ;; - *) die "unknown install option: $1" ;; - esac - done - [[ -n $source ]] || die "install requires --source PATH" - recover_pending_transaction - source=$(cd -- "$source" && pwd -P) || die "source is unavailable" - check_source "$source" - local release_id previous current_platform - release_id=$(release_id_from_source "$source") - previous=$(current_release_id) - current_platform=$(platform) - if [[ $login == prompt ]]; then - if [[ -L $install_root/current && -f $integration_marker && - ! -L $integration_marker ]]; then - # A rollover over an existing installation with validated login keeps - # it. Disabling (which also invalidates the launch marker) must be an - # explicit --disable-login choice, never a silent non-interactive default. - login=enable - elif [[ -t 0 && $noninteractive == 0 ]]; then - printf 'Enable the Session Kit picker for interactive SSH logins? [Y/n] ' - read -r answer - case ${answer:-y} in [Nn]*) login=disable ;; *) login=enable ;; esac - else - login=disable - fi - fi - if [[ $journal == prompt ]]; then - if [[ -t 0 ]]; then - printf 'Terminal journals can contain raw commands and output in ~/.local/state/shpool-journal. Opt in to keep them? [y/N] ' - read -r answer - case ${answer:-n} in [Yy]*) journal=on ;; *) journal=off ;; esac - else - journal=off - fi - fi - validate_mutable_targets - if [[ -e $install_root/releases/$release_id ]]; then - verify_release "$install_root/releases/$release_id" "$release_id" - fi - begin_transaction install - install_release "$source" "$release_id" - atomic_symlink "$install_root/releases/$release_id" "$install_root/current" - lifecycle_failpoint current - install_launchers "$release_id" - install_defaults "$release_id" - install_systemd_units "$release_id" - set_journal_choice "$journal" - if [[ $login == enable ]]; then enable_login; else disable_login; fi - write_ownership_markers - write_receipt "$release_id" "$source" "$previous" "$current_platform" - commit_transaction - printf 'Installed Session Kit release %s.\n' "$release_id" - if [[ $current_platform == linux ]]; then - printf 'User services were installed but not started. Review them, then run:\n' - printf ' systemctl --user daemon-reload\n' - printf ' systemctl --user enable --now shpool.socket shpool-reaper.timer\n' - fi - printf 'Run session-kit doctor before opening a new managed session.\n' -} - -doctor_command() { - local json=0 - [[ ${1:-} != --json ]] || { json=1; shift; } - (($# == 0)) || die "unknown doctor option: $1" - local current_platform release_id= failed=0 helper required row status name detail - current_platform=$(platform) - release_id=$(current_release_id) - local -a checks=() - add_check() { - checks+=("$1"$'\t'"$2"$'\t'"$3") - [[ $1 != fail ]] || failed=1 - } - if [[ $current_platform != unsupported ]]; then - add_check ok platform "$current_platform" - else - add_check fail platform "unsupported operating system" - fi - if [[ $release_id =~ ^[0-9a-f]{40}$ && - -d $install_root/releases/$release_id ]]; then - local release_error= - if release_error=$(verify_release "$install_root/releases/$release_id" "$release_id" 2>&1); then - add_check ok release "$release_id" - else - add_check fail release "${release_error#session-kit: }" - fi - else - add_check fail release "no valid active release" - fi - local -a missing_commands=() - for required in "${required_commands[@]}"; do - command -v "$required" >/dev/null 2>&1 || missing_commands+=("$required") - done - if ((${#missing_commands[@]} == 0)); then - add_check ok prerequisites "all required commands are available" - else - add_check fail prerequisites "commands unavailable: ${missing_commands[*]}" - fi - if [[ -d /proc || ${SESSION_KIT_TESTING:-0} == 1 ]]; then - add_check ok proc "process identity filesystem is available" - else - add_check fail proc "/proc is unavailable" - fi - if [[ ${SESSION_KIT_TESTING:-0} == 1 ]] || - systemctl --user show-environment >/dev/null 2>&1; then - add_check ok systemd "user service manager is available" - else - add_check fail systemd "user service manager is unavailable" - fi - if command -v claude >/dev/null 2>&1 || command -v codex >/dev/null 2>&1; then - add_check ok provider "Claude Code or Codex is available" - else - add_check fail provider "install Claude Code, Codex, or both" - fi - for helper in "${helpers[@]}"; do - if [[ -x $bin_dir/$helper ]]; then - add_check ok "$helper" "$bin_dir/$helper" - else - add_check fail "$helper" "launcher unavailable" - fi - done - if [[ -f $config_root/inventory.json && ! -L $config_root/inventory.json ]] && - python3 -m json.tool "$config_root/inventory.json" >/dev/null 2>&1; then - add_check ok config "inventory.json is valid JSON" - else - add_check fail config "inventory.json is missing, unsafe, or invalid" - fi - if ( - validate_purge_ownership \ - install "$install_root" "$install_root/.session-kit-owned.json" && - validate_purge_ownership \ - config "$config_root" "$config_root/.session-kit-owned.json" - ) >/dev/null 2>&1; then - add_check ok ownership "receipt and root ownership markers match" - else - add_check fail ownership "receipt or root ownership marker is missing or incompatible" - fi - if [[ -e $transaction_path || -L $transaction_path ]]; then - add_check fail transaction "interrupted lifecycle transaction requires recovery" - else - add_check ok transaction "no interrupted lifecycle transaction" - fi - if [[ -f $integration_marker && ! -L $integration_marker && - $(stat_mode "$integration_marker") == 600 ]]; then - add_check ok login "enabled with a private release marker" - else - add_check warn login "disabled or not yet validated" - fi - if (( json )); then - printf '%s\n' "${checks[@]}" | python3 -c ' -import json,sys -rows=[] -for line in sys.stdin: - status,name,detail=line.rstrip("\n").split("\t",2) - rows.append({"status":status,"name":name,"detail":detail}) -print(json.dumps({"ok":all(r["status"]!="fail" for r in rows),"checks":rows},indent=2,sort_keys=True)) -' - else - for row in "${checks[@]}"; do - IFS=$'\t' read -r status name detail <<<"$row" - printf '%-5s %-18s %s\n' "${status^^}" "$name" "$detail" - done - fi - (( failed == 0 )) -} - -update_command() { - local source= login= journal=off - while (($#)); do - case "$1" in - --source) require_arg "$1" "${2:-}"; source=$2; shift 2 ;; - --enable-login) login=enable; shift ;; - --disable-login) login=disable; shift ;; - --journal) require_arg "$1" "${2:-}"; journal=$2; shift 2 ;; - *) die "unknown update option: $1" ;; - esac - done - [[ $(platform) == linux ]] || die "update is supported only on Linux" - if [[ -z $source && -r $receipt_path ]]; then - source=$(python3 - "$receipt_path" <<'PY' -import json,sys + raise SystemExit("incompatible pending lifecycle transaction") +current_before = entries[0] +if current_before.get("kind") == "absent": + raise SystemExit(0) +target_raw = current_before.get("target") +if current_before.get("kind") != "symlink" or not isinstance(target_raw, str): + raise SystemExit("invalid pending current-release preimage") +target = pathlib.Path(target_raw) +release_root = install_root / "releases" try: - value=json.load(open(sys.argv[1],encoding="utf-8")).get("source","") -except (OSError,ValueError): - value="" -print(value if isinstance(value,str) else "") + relative = target.relative_to(release_root) +except ValueError: + raise SystemExit("pending recovery release is outside the install root") +if len(relative.parts) != 1 or re.fullmatch(r"[0-9a-f]{40}", relative.name) is None: + raise SystemExit("pending recovery release is invalid") +target_info = target.lstat() +if ( + not stat.S_ISDIR(target_info.st_mode) + or stat.S_ISLNK(target_info.st_mode) + or target_info.st_uid != os.geteuid() + or not (target / "lib/sh/session_kit_lifecycle.sh").is_file() +): + raise SystemExit("pending recovery release is unavailable") +print(target) PY -) - fi - [[ -n $source ]] || die "update needs --source PATH" - if [[ -z $login ]]; then - if [[ -f $integration_marker && ! -L $integration_marker ]]; then - login=enable - else - login=disable + ) || die "pending lifecycle recovery release is unsafe" + if [[ -n $recovery_release ]]; then + module_dir=$recovery_release/lib/sh fi - fi - local args=(--source "$source" --journal "$journal" --non-interactive) - case "$login" in enable) args+=(--enable-login) ;; disable) args+=(--disable-login) ;; esac - install_command "${args[@]}" -} - -rollback_command() { - local target= - while (($#)); do - case "$1" in - --to) require_arg "$1" "${2:-}"; target=$2; shift 2 ;; - *) die "unknown rollback option: $1" ;; - esac - done - [[ $(platform) == linux ]] || die "rollback is supported only on Linux" - recover_pending_transaction - if [[ -z $target && -r $receipt_path ]]; then - target=$(python3 - "$receipt_path" <<'PY' -import json,sys -try: - value=json.load(open(sys.argv[1],encoding="utf-8")).get("previous_release","") -except (OSError,ValueError): - value="" -print(value if isinstance(value,str) else "") -PY -) - fi - [[ $target =~ ^[0-9a-f]{40}$ ]] || die "rollback requires a full release commit" - [[ -d $install_root/releases/$target && ! -L $install_root/releases/$target ]] || - die "rollback release is not installed: $target" - verify_release "$install_root/releases/$target" "$target" - local active_tool=$install_root/current/deploy/session-kit-release - if [[ -f $active_tool && ! -L $active_tool ]]; then - python3 - "$active_tool" "$HOME" <<'PY' -import pathlib -import runpy -import sys - -namespace = runpy.run_path(sys.argv[1], run_name="session_kit_release_gate") -namespace["validate_rollback_launch_records"](pathlib.Path(sys.argv[2])) -PY - fi - validate_mutable_targets - begin_transaction rollback - local previous source= - previous=$(current_release_id) - if [[ -r $receipt_path ]]; then - source=$(python3 - "$receipt_path" <<'PY' -import json,sys -try: - value=json.load(open(sys.argv[1],encoding="utf-8")).get("source","") -except (OSError,ValueError): - value="" -print(value if isinstance(value,str) else "") -PY -) - fi - atomic_symlink "$install_root/releases/$target" "$install_root/current" - lifecycle_failpoint current - install_launchers "$target" - install_systemd_units "$target" - if [[ -f $integration_marker && ! -L $integration_marker ]]; then - printf 'session-kit-integration-v1 %s\n' "$target" > "$integration_marker" - chmod 600 "$integration_marker" - fi - write_ownership_markers - write_receipt "$target" "$source" "$previous" "$(platform)" - commit_transaction - printf 'Rolled back Session Kit to %s. Services were not restarted.\n' "$target" -} - -uninstall_command() { - local purge_code=0 purge_config=0 helper - while (($#)); do - case "$1" in - --purge-code) purge_code=1; shift ;; - --purge-config) purge_config=1; shift ;; - *) die "unknown uninstall option: $1" ;; - esac - done - recover_pending_transaction - validate_roots - validate_uninstall_launchers - if (( purge_config )); then - validate_purge_ownership config "$config_root" "$config_root/.session-kit-owned.json" - fi - if (( purge_code )); then - validate_purge_ownership install "$install_root" "$install_root/.session-kit-owned.json" - fi - validate_mutable_targets - begin_transaction uninstall - disable_login - for helper in "${helpers[@]}"; do - [[ ! -e $bin_dir/$helper || ( -f $bin_dir/$helper && ! -L $bin_dir/$helper ) ]] || - die "refusing to remove unsafe launcher: $bin_dir/$helper" - command rm -f -- "$bin_dir/$helper" - done - command rm -f -- "$bin_dir/session-kit" - if (( purge_config )); then - [[ ! -e $config_root || ( -d $config_root && ! -L $config_root ) ]] || - die "refusing to remove unsafe config directory" - command rm -rf -- "$config_root" - fi - if (( purge_code )); then - [[ ! -e $install_root || ( -d $install_root && ! -L $install_root ) ]] || - die "refusing to remove unsafe install directory" - find "$install_root" -type d -exec chmod u+rwx {} + - command rm -rf -- "$install_root" - fi - command rm -f -- "$receipt_path" - commit_transaction - printf 'Session Kit integration removed. Journals, archives, and session data were retained.\n' - printf 'No service was stopped or restarted. Review active sessions before removing service files.\n' -} + unset recovery_release + fi + [[ -n $module_dir ]] || module_dir=$install_root/current/lib/sh +fi +[[ -r $module_dir/session_kit_checks.sh ]] || + die "shell modules are unavailable: $module_dir" +# shellcheck source=lib/sh/session_kit_checks.sh +source "$module_dir/session_kit_checks.sh" +# shellcheck source=lib/sh/session_kit_lifecycle.sh +source "$module_dir/session_kit_lifecycle.sh" +# shellcheck source=lib/sh/session_kit_login.sh +source "$module_dir/session_kit_login.sh" +# shellcheck source=lib/sh/session_kit_projects.sh +source "$module_dir/session_kit_projects.sh" +# shellcheck source=lib/sh/session_kit_install.sh +source "$module_dir/session_kit_install.sh" +# shellcheck source=lib/sh/session_kit_doctor.sh +source "$module_dir/session_kit_doctor.sh" +# shellcheck source=lib/sh/session_kit_services.sh +source "$module_dir/session_kit_services.sh" +# shellcheck source=lib/sh/session_kit_uninstall.sh +source "$module_dir/session_kit_uninstall.sh" command_name=${1:-help} shift || true @@ -1272,8 +325,13 @@ case "$command_name" in ;; install) install_command "$@" ;; doctor) doctor_command "$@" ;; + projects) + [[ -L $install_root/current ]] || die "Session Kit is not installed" + projects_command "$@" + ;; update) update_command "$@" ;; rollback) rollback_command "$@" ;; + services) services_command "$@" ;; enable-login) (($# == 0)) || die "enable-login takes no options"; enable_login ;; disable-login) (($# == 0)) || die "disable-login takes no options"; disable_login ;; uninstall) uninstall_command "$@" ;; diff --git a/bin/session_kit_common b/bin/session_kit_common index 47f3619..b5e84c9 100755 --- a/bin/session_kit_common +++ b/bin/session_kit_common @@ -4,6 +4,13 @@ SESSION_KIT_BIN_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) SESSION_KIT_RELEASE_DIR=$(cd -- "$SESSION_KIT_BIN_DIR/.." && pwd -P) +# A release directory is installed read-only, so Python's bytecode cache can +# never be written there: every import attempts to create __pycache__ and is +# denied. Nothing is lost by not asking, since nothing was ever cached, and it +# saves a failed syscall per module per command. This only disables writing, so +# a .pyc precompiled by a future install step is still read. +export PYTHONDONTWRITEBYTECODE=1 + SK_STATE_DIR=${SESSION_KIT_STATE_DIR:-${XDG_STATE_HOME:-"$HOME/.local/state"}/session-kit} SK_JOURNAL_DIR=${SESSION_KIT_JOURNAL_DIR:-${XDG_STATE_HOME:-"$HOME/.local/state"}/shpool-journal} SK_ARCHIVE_DIR=${SESSION_KIT_ARCHIVE_DIR:-${XDG_STATE_HOME:-"$HOME/.local/state"}/shpool-archive} @@ -14,7 +21,11 @@ SK_PROJECTS_FILE=${SESSION_KIT_PROJECTS_FILE:-${XDG_CONFIG_HOME:-"$HOME/.config" SK_CONFIG_FILE=${SESSION_KIT_CONFIG:-${XDG_CONFIG_HOME:-"$HOME/.config"}/session-kit/inventory.json} SK_SHPOOL=${SESSION_KIT_SHPOOL_CMD:-} SK_INVENTORY_CORE=${SESSION_KIT_INVENTORY_CORE:-"$SESSION_KIT_RELEASE_DIR/lib/session_inventory.py"} +SK_PROJECTS_CORE=${SESSION_KIT_PROJECTS_CORE:-"$SESSION_KIT_RELEASE_DIR/lib/sessionkit_projects/cli.py"} SK_ACTION_LOG="$SK_STATE_DIR/action-events.jsonl" +# Internal dynamic-scope proof set only after one exact shell death. Never +# inherit a caller-supplied value that could weaken restore duplicate checks. +SK_RESTORE_IGNORE_STALE_ID= if [[ -z $SK_SHPOOL ]]; then SK_SHPOOL=$(command -v shpool 2>/dev/null || true) @@ -38,22 +49,44 @@ sk_require_shpool() { [[ -n $SK_SHPOOL && -x $SK_SHPOOL ]] || sk_die "shpool executable unavailable" } +# Reads a test-only hook variable. Returns 0 and sets SK_TEST_HOOK to its value +# only when the isolated test harness armed it; otherwise returns 1 and leaves +# SK_TEST_HOOK empty. +# +# Every one of these variables replaces evidence the kit would otherwise gather +# from the machine -- the platform, whether a provider process really started. +# Setting an environment variable is not a privilege, so honouring one outside +# the harness would let any process that can reach this shell's environment +# feed session-kit a fabricated answer. The single SESSION_KIT_TESTING gate +# lives here so no caller can forget it, and an ungated attempt is reported +# once rather than silently swallowed. +sk_test_hook() { + local name=$1 + SK_TEST_HOOK=${!name:-} + [[ -n $SK_TEST_HOOK ]] || return 1 + if [[ ${SESSION_KIT_TESTING:-0} != 1 ]]; then + SK_TEST_HOOK= + if [[ ${SK_TEST_HOOK_NOTED:-} != *"|$name|"* ]]; then + SK_TEST_HOOK_NOTED="${SK_TEST_HOOK_NOTED:-|}$name|" + printf 'session-kit: ignoring %s: reserved for isolated tests\n' "$name" >&2 + fi + return 1 + fi + return 0 +} + sk_platform() { - if [[ ${SESSION_KIT_TESTING:-0} == 1 && -n ${SESSION_KIT_TEST_PLATFORM:-} ]]; then - printf '%s\n' "$SESSION_KIT_TEST_PLATFORM" + if sk_test_hook SESSION_KIT_TEST_PLATFORM; then + printf '%s\n' "$SK_TEST_HOOK" else command uname -s 2>/dev/null fi } -sk_darwin_preview() { - [[ $(sk_platform) == Darwin && ${SESSION_KIT_MACOS_PREVIEW:-0} == 1 ]] -} - sk_boot_id() { if [[ -n ${SESSION_KIT_BOOT_ID_FILE:-} ]]; then command cat -- "$SESSION_KIT_BOOT_ID_FILE" - elif sk_darwin_preview; then + elif [[ $(sk_platform) == Darwin ]]; then python3 "$SK_INVENTORY_CORE" platform boot-id else command cat -- /proc/sys/kernel/random/boot_id @@ -68,49 +101,196 @@ sk_now_unix_ms() { fi } -# Linux keeps its established flock semantics. The Darwin preview uses a -# single atomic directory and never removes a lock it did not acquire. A crash -# therefore fails closed until the preview operator inspects and removes it. +# Linux keeps its established flock command. Darwin uses the native flock(2) +# binding through Python, holding the lock on the inherited Bash descriptor so +# a killed command releases it with the descriptor instead of leaving stale +# lock state behind. +# +# The wait is bounded on both. An unbounded acquire meant one holder wedged +# inside the daemon stopped every attach, create and close on the machine for +# as long as it stayed wedged, with no message and no way out but finding the +# process by hand. The bound is generous, an honest create holds this lock +# through an attach and its generation proof, so reaching it means the holder +# is stuck, not merely slow. +SK_LOCK_WAIT_SECONDS=${SESSION_KIT_LOCK_WAIT_SECONDS:-120} +[[ $SK_LOCK_WAIT_SECONDS =~ ^[0-9]+$ && $SK_LOCK_WAIT_SECONDS -ge 1 ]] || + SK_LOCK_WAIT_SECONDS=120 sk_lock_acquire() { - local fd=$1 path=$2 + local fd=$1 path=$2 seconds=${3:-$SK_LOCK_WAIT_SECONDS} + [[ $seconds =~ ^[0-9]+$ && $seconds -ge 1 ]] || seconds=$SK_LOCK_WAIT_SECONDS if [[ $(sk_platform) != Darwin ]]; then command -v flock >/dev/null 2>&1 || { sk_die "flock is required" return 1 } - flock -x "$fd" - return - fi - sk_darwin_preview || { - sk_die "macOS preview is disabled" - return 1 - } - local directory="${path}.darwin-lock" - mkdir "$directory" 2>/dev/null || { - sk_die "macOS preview lock is busy: $directory" + flock -x -w "$seconds" "$fd" && return 0 + sk_die "another session-kit action has held $(basename -- "$path") for ${seconds}s; nothing changed" return 1 - } - printf '%s\n' "$$" > "$directory/owner" || { - command rm -- "$directory/owner" 2>/dev/null || true - rmdir "$directory" 2>/dev/null || true - return 1 - } - SK_DARWIN_LOCK_DIR=$directory + fi + python3 - "$fd" "$seconds" <<'PY' && return 0 +import fcntl +import sys +import time + +descriptor, seconds = int(sys.argv[1]), float(sys.argv[2]) +deadline = time.monotonic() + seconds +while True: + try: + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + if time.monotonic() >= deadline: + raise SystemExit(1) + time.sleep(0.1) +PY + sk_die "another session-kit action has held $(basename -- "$path") for ${seconds}s; nothing changed" + return 1 } sk_lock_release() { - local fd=$1 result=0 - if [[ -n ${SK_DARWIN_LOCK_DIR:-} ]]; then - command rm -- "$SK_DARWIN_LOCK_DIR/owner" 2>/dev/null || result=1 - rmdir "$SK_DARWIN_LOCK_DIR" 2>/dev/null || result=1 - if (( result == 0 )); then - SK_DARWIN_LOCK_DIR= - else - sk_die "macOS preview lock release failed; lock retained" - fi - fi + local fd=$1 eval "exec ${fd}>&-" - return "$result" +} + +sk_timeout() { + local seconds=$1 + shift + # Keep the caller's stdin attached to the child. The former here-doc + # replaced pipeline input with Python source, so the hidden Claude color + # bootstrap never received its /color command. + # This is Python source, so shell expansion is deliberately disabled. + # shellcheck disable=SC2016 + python3 -c ' +import ctypes +import os +import signal +import subprocess +import sys +import time + +seconds = float(sys.argv[1]) +if not 0 < seconds <= 3600 or len(sys.argv) < 3: + raise SystemExit(2) + +# Opt-in: the child must not outlive this wrapper under ANY death, and one +# Ctrl-C must take it with us. Only callers that start a REAL provider ask for +# this; the picker refresh deliberately does not (see _forward). +tied = os.environ.get("SK_TIMEOUT_TIE_CHILD") == "1" + + +def _tie_to_parent(): + # PR_SET_PDEATHSIG(1): the kernel signals this child the moment its parent + # dies, including a SIGKILL nothing can trap. Without it a wrapper killed + # hard leaves a detached provider running forever -- a four-day-old + # throwaway pair was found alive on the operator box, holding ~250 MB and + # an account profile, with nothing on the machine able to reap it. + try: + ctypes.CDLL("libc.so.6", use_errno=True).prctl(1, int(signal.SIGTERM)) + except OSError: + pass + + +process = subprocess.Popen( + sys.argv[2:], + start_new_session=True, + preexec_fn=_tie_to_parent if tied else None, +) + + +def _descendants(root): + # `script` puts the provider in its OWN session, so the provider is NOT in + # the group this wrapper started: one killpg reached `script` and left the + # real provider running. Walk the tree instead, deepest last, so a signal + # goes to every process this wrapper is responsible for. + children = {} + for entry in os.listdir("/proc"): + if not entry.isdigit(): + continue + try: + with open(f"/proc/{entry}/stat", "rb") as handle: + fields = handle.read().rpartition(b")")[2].split() + parent = int(fields[1]) + except (OSError, IndexError, ValueError): + continue + children.setdefault(parent, []).append(int(entry)) + found, queue = [], [root] + while queue: + current = queue.pop() + for child in children.get(current, ()): + if child not in found: + found.append(child) + queue.append(child) + return found + + +def _signal_everything(number, targets): + try: + os.killpg(process.pid, number) + except (ProcessLookupError, PermissionError): + pass + for pid in targets: + try: + os.kill(pid, number) + os.killpg(pid, number) + except (ProcessLookupError, PermissionError): + pass + + +def _forward(number, _frame): + # The child owns its own process group and session, so nothing that signals + # this wrapper reaches the real work: a terminated collector left a full + # status collection running detached, ~150 MB, until it finished by itself. + # SIGINT is handled ONLY when the caller tied the child to us: bash sets it + # to ignore for background jobs, and taking it over unasked would make one + # Ctrl-C fail the live refresh the picker depends on -- but for a real + # provider, a Ctrl-C the child never receives is exactly how an orphan is + # made. + # Collected BEFORE anything is signalled. `script` dies first and the + # provider is reparented to init within milliseconds, so a tree walked + # after the first signal no longer contains the process that matters -- + # which is how the provider survived a wrapper that thought it had killed + # everything. + targets = _descendants(process.pid) + _signal_everything(signal.SIGTERM, targets) + if tied: + # One SIGTERM and an immediate exit is not cleanup: a provider that + # ignores TERM simply outlives the Ctrl-C that was meant to stop it, + # and the caller is already dying, so nothing later gets to try again. + # Same escalation the timeout path uses, with time to flush first -- + # a provider SIGKILLed mid-write is what leaves a torn record behind. + time.sleep(1.5) + _signal_everything(signal.SIGKILL, targets) + raise SystemExit(128 + number) + + +_handled = [signal.SIGTERM, signal.SIGHUP] +if tied: + _handled.append(signal.SIGINT) +for _number in _handled: + if signal.getsignal(_number) is not signal.SIG_IGN: + signal.signal(_number, _forward) +try: + status = process.wait(timeout=seconds) +except subprocess.TimeoutExpired: + # The tree is collected first, for the same reason the signal path does + # it: `script` gives the provider its own session, so signalling only the + # child process group reaches the wrapper around the provider and not the + # provider itself, and once the wrapper dies the provider is no longer + # anywhere a later walk would find it. + _timed_out = _descendants(process.pid) + _signal_everything(signal.SIGTERM, _timed_out) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + _signal_everything(signal.SIGKILL, _timed_out) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + raise SystemExit(124) +raise SystemExit(status if status >= 0 else 128 - status) +' "$seconds" "$@" } sk_integration_marker_ok() { @@ -233,12 +413,20 @@ try: continue if ( isinstance(item, dict) - and set(item) == { - "action", - "at_unix_ms", - "outcome", - "schema_version", - } + and set(item) in ( + {"action", "at_unix_ms", "outcome", "schema_version"}, + # A record may name the session it is about. An audit line + # that cannot say WHICH session it describes is why a night of + # launches that started nothing took hours to explain. + {"action", "at_unix_ms", "outcome", "schema_version", "session"}, + ) + and ( + "session" not in item + or ( + isinstance(item.get("session"), str) + and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,63}", item["session"]) + ) + ) and item.get("schema_version") == 1 and isinstance(item.get("at_unix_ms"), int) and not isinstance(item["at_unix_ms"], bool) @@ -331,6 +519,29 @@ if len(matches) != 1: row = matches[0] identity = row.get("identity") or {} daemon = data.get("daemon_generation") or {} +conversation_provider = row.get("provider") or "unknown" +conversation_uuid = identity.get("uuid") or "" +if conversation_provider not in {"claude", "codex"} or not conversation_uuid: + exited_identity = row.get("exited_identity") or {} + recovery = row.get("recovery") or {} + exited_provider = row.get("exited_provider") + exited_uuid = exited_identity.get("uuid") + if ( + row.get("provider") == "shell" + and row.get("agent_status") == "provider exited" + and exited_provider in {"claude", "codex"} + and row.get("display_provider") == exited_provider + and exited_identity.get("confidence") == "historical-exact" + and isinstance(exited_uuid, str) + and recovery.get("available") is True + and recovery.get("provider") == exited_provider + and recovery.get("uuid") == exited_uuid + ): + conversation_provider = exited_provider + conversation_uuid = exited_uuid + else: + conversation_provider = "" + conversation_uuid = "" raw_id = row.get("shpool_id_raw") if not isinstance(raw_id, str) or raw_id != row.get("shpool_id"): raise SystemExit(2) @@ -351,6 +562,12 @@ values = [ str(daemon.get("process_start_ticks") or ""), "true" if row.get("mutation_allowed") is True else "false", row.get("mutation_rejection_reason") or "", + conversation_provider, + conversation_uuid, + str(row.get("terminal_number") or ""), + row.get("account_alias") or "", + "true" if row.get("account_switch_capable") is True else "false", + row.get("model") or "", ] for value in values: value = re.sub(r"[\t\r\n]+", " ", str(value)) @@ -362,7 +579,7 @@ sk_resolve() { local inventory=$1 selector=$2 local -a fields=() while IFS= read -r field; do fields+=("$field"); done < <(sk_lookup_file "$inventory" "$selector") || return 2 - [[ ${#fields[@]} -eq 16 ]] || return 2 + [[ ${#fields[@]} -eq 22 ]] || return 2 SK_ID=${fields[0]} SK_STARTED=${fields[1]} SK_STATUS=${fields[2]} @@ -379,6 +596,12 @@ sk_resolve() { SK_DAEMON_START=${fields[13]} SK_MUTATION_ALLOWED=${fields[14]} SK_MUTATION_REASON=${fields[15]} + SK_CONVERSATION_PROVIDER=${fields[16]} + SK_CONVERSATION_UUID=${fields[17]} + SK_NUMBER=${fields[18]} + SK_ACCOUNT_ALIAS=${fields[19]} + SK_ACCOUNT_CAPABLE=${fields[20]} + SK_MODEL=${fields[21]} } sk_require_mutation_target() { @@ -388,6 +611,165 @@ sk_require_mutation_target() { } } +# Report the exact daemon generation holding the Unix listener used by this +# kit's shpool client. This does not issue a client request of its own, so it +# can bracket the one raw request whose author a caller needs to prove. +sk_observe_shpool_holder() { + python3 "$SK_INVENTORY_CORE" platform shpool-holder-generation 2>/dev/null +} + +# True only when the current socket author is the daemon generation carried by +# a guard-live snapshot. PID alone is never enough because it can be reused. +sk_shpool_holder_is() { + local expected_pid=$1 expected_start=$2 observed + observed=$(sk_observe_shpool_holder) || return 1 + [[ $observed == "$expected_pid"$'\t'"$expected_start" ]] +} + +# Prove the close itself, independently of a client's exit status or a raw +# list losing a name. The exact old shell generation must be gone while the +# bound daemon generation remains unchanged throughout the observation. +sk_exact_shell_gone() { + local daemon_pid=$1 daemon_start=$2 shell_pid=$3 shell_start=$4 + python3 "$SK_INVENTORY_CORE" platform exact-shell-gone \ + "$daemon_pid" "$daemon_start" "$shell_pid" "$shell_start" +} + +# End one exact PID/start-tick generation. Production delivery is pinned by a +# pidfd and re-reads the identity after the fd is open, immediately before +# TERM and (only after a delivered TERM) bounded KILL. +sk_terminate_exact_process() { + local pid=$1 start_ticks=$2 outcome status=0 + outcome=$(python3 "$SK_INVENTORY_CORE" platform terminate-exact-process \ + "$pid" "$start_ticks" 2>&1) || status=$? + if (( status != 0 )); then + [[ -n $outcome ]] || outcome="exact process terminator unavailable" + printf '%s\n' "$outcome" >&2 + return "$status" + fi + printf '%s\n' "$outcome" +} + +# The shell is the destructive act. A name is never used to choose what dies; +# the daemon proof remains a postcondition so a stale snapshot cannot turn a +# successful signal into a false close claim. +sk_terminate_exact_shell() { + local daemon_pid=$1 daemon_start=$2 shell_pid=$3 shell_start=$4 outcome + outcome=$(sk_terminate_exact_process "$shell_pid" "$shell_start" 2>&1) || { + # The picker proof can outlive the shell by a few milliseconds. Already + # gone is the desired close state, but only while the exact bound daemon + # generation still brackets that proof. + if sk_exact_shell_gone "$daemon_pid" "$daemon_start" \ + "$shell_pid" "$shell_start" >/dev/null 2>&1; then + return 0 + fi + printf '%s\n' "$outcome" >&2 + return 1 + } + sk_exact_shell_gone "$daemon_pid" "$daemon_start" \ + "$shell_pid" "$shell_start" +} + +# Read one manager entry only while the Unix listener is still owned by the +# exact daemon generation from the guard snapshot. Prints `present` or `gone`; +# a reused name, malformed payload, wrong lifecycle state, or changed author +# refuses. +sk_bound_manager_entry_state() { + local id=$1 started=$2 daemon_pid=$3 daemon_start=$4 payload answer status=0 + sk_shpool_holder_is "$daemon_pid" "$daemon_start" || return 1 + payload=$(sk_timeout 5 "$SK_SHPOOL" list --json 2>/dev/null) || return 1 + sk_shpool_holder_is "$daemon_pid" "$daemon_start" || return 1 + answer=$(SK_MANAGER_LIST_JSON=$payload python3 - "$id" "$started" <<'PY' +import json +import os +import sys + +session_id, started_text = sys.argv[1:3] +try: + started = int(started_text) + payload = json.loads(os.environ["SK_MANAGER_LIST_JSON"]) +except (KeyError, ValueError, json.JSONDecodeError): + raise SystemExit(2) +rows = payload.get("sessions") if isinstance(payload, dict) else payload +if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows): + raise SystemExit(2) +matches = [row for row in rows if row.get("name") == session_id] +if not matches: + print("gone") + raise SystemExit(0) +if len(matches) != 1: + raise SystemExit(2) +row = matches[0] +if ( + row.get("started_at_unix_ms") != started + or str(row.get("status", "")).casefold() not in {"disconnected", "attached"} +): + raise SystemExit(2) +print("present") +PY + ) || status=$? + (( status == 0 )) || return 1 + printf '%s\n' "$answer" +} + +# Stock shpool 0.11 aborts `kill` bookkeeping when the child PID is already +# gone. Reattaching is its supported stale-entry recovery path: select_shell +# replaces the exited generation, and /bin/true exits under the attach handler +# so that handler removes the table entry normally. The reusable name is used +# only after the old instance timestamp, disconnected state, dead shell, and +# exact daemon author have all been proved under the kit's creation lock. +sk_finalize_dead_shpool_entry() { + local id=$1 daemon_pid=$2 daemon_start=$3 started=$4 state attach_status=0 + state=$(sk_bound_manager_entry_state \ + "$id" "$started" "$daemon_pid" "$daemon_start") || { + printf 'session-kit: ERROR: cannot prove manager entry %s is the exact dead instance; it was not removed\n' \ + "$id" >&2 + return 1 + } + [[ $state == present ]] || return 0 + sk_shpool_holder_is "$daemon_pid" "$daemon_start" || { + printf 'session-kit: ERROR: manager changed before dead entry %s could be finalized; it was not removed\n' \ + "$id" >&2 + return 1 + } + sk_timeout 10 "$SK_SHPOOL" attach --cmd /bin/true --dir / "$id" \ + </dev/null >/dev/null 2>&1 || attach_status=$? + sk_shpool_holder_is "$daemon_pid" "$daemon_start" || { + printf 'session-kit: ERROR: manager changed while finalizing dead entry %s; removal is unproved\n' \ + "$id" >&2 + return 1 + } + # The attach client in stock 0.11 can report 1 even after the shell's exit + # landed. The table postcondition, not that unreliable status, is truth. + local attempt + for attempt in 1 2 3 4 5; do + state=$(sk_bound_manager_entry_state \ + "$id" "$started" "$daemon_pid" "$daemon_start") || break + [[ $state == gone ]] && return 0 + sleep 0.1 + done + printf 'session-kit: ERROR: manager entry %s remains after dead-shell finalization (attach status %s)\n' \ + "$id" "$attach_status" >&2 + return 1 +} + +sk_cleanup_dead_shpool_entry() { + local id=$1 daemon_pid=$2 daemon_start=$3 shell_pid=$4 shell_start=$5 + local started=${6:-} + if ! sk_exact_shell_gone "$daemon_pid" "$daemon_start" \ + "$shell_pid" "$shell_start" >/dev/null 2>&1; then + printf 'session-kit: ERROR: manager entry %s was not finalized because exact shell death is unproved\n' \ + "$id" >&2 + return 1 + fi + if [[ ! $started =~ ^[0-9]+$ || $started -le 0 ]]; then + printf 'session-kit: ERROR: manager entry %s was not finalized because its exact start time is unavailable\n' \ + "$id" >&2 + return 1 + fi + sk_finalize_dead_shpool_entry "$id" "$daemon_pid" "$daemon_start" "$started" +} + # True only when the running process is the exact one recorded earlier. The # start ticks make a recycled PID impossible to mistake for the original. sk_proc_is_exact() { @@ -396,7 +778,6 @@ sk_proc_is_exact() { [[ $pid =~ ^[0-9]+$ && $start_ticks =~ ^[0-9]+$ ]] || return 1 (( pid > 0 && start_ticks > 0 )) || return 1 if [[ $(sk_platform) == Darwin ]]; then - sk_darwin_preview || return 1 local current current=$(python3 "$SK_INVENTORY_CORE" platform process-info "$pid" 2>/dev/null) || return 1 [[ ${current#*$'\t'} == "$start_ticks" ]] @@ -425,16 +806,16 @@ sk_proc_is_exact() { # exactly what the launch record asked the shell to do. sk_provider_process_present() { local shell_pid=$1 shell_start=$2 provider=$3 - local override=${SESSION_KIT_PROVIDER_PRESENCE_OVERRIDE:-} - if [[ -n $override ]]; then + if sk_test_hook SESSION_KIT_PROVIDER_PRESENCE_OVERRIDE; then # Test and diagnostic hook only; unset in normal operation. Checked before - # anything reads /proc, because a fixture's shell PIDs are synthetic. - [[ $override == present ]] + # anything reads /proc, because a fixture's shell PIDs are synthetic. This + # answer is the whole proof that a launch succeeded, so outside the harness + # sk_test_hook refuses it and the real process tree decides. + [[ $SK_TEST_HOOK == present ]] return fi sk_proc_is_exact "$shell_pid" "$shell_start" || return 1 if [[ $(sk_platform) == Darwin ]]; then - sk_darwin_preview || return 1 python3 "$SK_INVENTORY_CORE" platform provider-present \ "$shell_pid" "$shell_start" "$provider" >/dev/null return @@ -482,43 +863,57 @@ raise SystemExit(1) PY } -# `shpool kill` ends the session's own shell, but a provider process wedged on -# a terminal write can outlive it and keep holding the old pty. Only processes +# A delegated session was given its own copy of the code; closing it gives the +# copy back. Only a copy the kit cut for that session is a candidate, and the +# core refuses one with uncommitted or unmerged work, one somebody is still +# working in, and anything outside the kit's own worktree root -- it says which +# and keeps the directory. The close already happened, so nothing here can fail +# it: the worst case is a line saying the copy was kept. +sk_release_worktree() { + local id=$1 verdict= status=0 + [[ -n $id ]] || return 0 + verdict=$(python3 "$SK_INVENTORY_CORE" worktree release --shpool-id "$id" \ + 2>/dev/null) || status=$? + if (( status != 0 )); then + # The copy is still on disk -- nothing removes one except this verb -- so + # the only cost is a directory. Saying nothing is the part that is not + # free: the close would print exactly what it prints when there was no + # copy at all, and the operator would read "handled". + printf 'session-kit: could not check the working copy for session %s, so it was left where it is. Run sp worktree list to see it.\n' \ + "$id" + return 0 + fi + [[ -n $verdict ]] || return 0 + printf '%s' "$verdict" + return 0 +} + +# Ending the session's own shell can leave a provider wedged on a terminal +# write alive and holding the old pty. Only processes # whose PID *and* start ticks still match the recorded generation are signalled. sk_reap_session_leftovers() { - local pid start_ticks signal reaped=0 + local pid start_ticks local -a pairs=("$@") local index - for signal in HUP TERM KILL; do - for (( index = 0; index + 1 < ${#pairs[@]}; index += 2 )); do - pid=${pairs[index]} - start_ticks=${pairs[index + 1]} - if sk_proc_is_exact "$pid" "$start_ticks"; then - kill -"$signal" "$pid" 2>/dev/null || true - reaped=1 - fi - done - (( reaped )) || return 0 - sleep 2 - reaped=0 - for (( index = 0; index + 1 < ${#pairs[@]}; index += 2 )); do - if sk_proc_is_exact "${pairs[index]}" "${pairs[index + 1]}"; then - reaped=1 - fi - done - (( reaped )) || return 0 + for (( index = 0; index + 1 < ${#pairs[@]}; index += 2 )); do + pid=${pairs[index]} + start_ticks=${pairs[index + 1]} + [[ $pid =~ ^[0-9]+$ && $start_ticks =~ ^[0-9]+$ ]] || continue + (( pid > 0 && start_ticks > 0 )) || continue + sk_terminate_exact_process "$pid" "$start_ticks" >/dev/null 2>&1 || true done return 0 } -sk_revalidate() { - local expected_id=$1 expected_started=$2 expected_provider=$3 expected_uuid=$4 - local expected_shell_pid=$5 expected_shell_start=$6 - local expected_provider_pid=$7 expected_provider_start=$8 - local expected_daemon_pid=$9 expected_daemon_start=${10} - local fresh result=0 - fresh=$(mktemp "$SK_STATE_DIR/revalidate.XXXXXX.json") || return 1 - if ! sk_guard_snapshot_file "$fresh" || ! sk_resolve "$fresh" "$expected_id"; then +sk_snapshot_matches_exact() { + local inventory=$1 expected_id=$2 expected_started=$3 expected_provider=$4 + local expected_uuid=$5 expected_shell_pid=$6 expected_shell_start=$7 + local expected_provider_pid=$8 expected_provider_start=$9 + local expected_daemon_pid=${10} expected_daemon_start=${11} + local expected_conversation_provider=${12:-} + local expected_conversation_uuid=${13:-} + local result=0 + if ! sk_resolve "$inventory" "$expected_id"; then result=1 elif [[ $SK_ID != "$expected_id" || $SK_STARTED != "$expected_started" ]]; then result=1 @@ -532,6 +927,19 @@ sk_revalidate() { result=1 elif [[ $SK_MUTATION_ALLOWED != true ]]; then result=1 + elif [[ -n $expected_conversation_provider ]] && + [[ $SK_CONVERSATION_PROVIDER != "$expected_conversation_provider" || + $SK_CONVERSATION_UUID != "$expected_conversation_uuid" ]]; then + result=1 + fi + return "$result" +} + +sk_revalidate() { + local fresh result=0 + fresh=$(mktemp "$SK_STATE_DIR/revalidate.json.XXXXXX") || return 1 + if ! sk_guard_snapshot_file "$fresh" || ! sk_snapshot_matches_exact "$fresh" "$@"; then + result=1 fi command rm -- "$fresh" return "$result" @@ -698,11 +1106,31 @@ sk_picker_proof_matches() { $SK_DAEMON_START == "$SK_PROOF_DAEMON_START" ]] } +# The one allowed close race: the private proof still names the exact old +# shell generation, that generation is now gone, and the same manager instance +# has entered the collector's shell-less quarantine. No other action accepts +# this weaker shape. +sk_picker_proof_matches_dead_shell() { + local inventory=$1 + sk_exact_shell_gone "$SK_PROOF_DAEMON_PID" "$SK_PROOF_DAEMON_START" \ + "$SK_PROOF_SHELL_PID" "$SK_PROOF_SHELL_START" >/dev/null 2>&1 || return 1 + sk_resolve "$inventory" "$SK_PROOF_ID" || return 1 + [[ $SK_ID == "$SK_PROOF_ID" && + $SK_STARTED == "$SK_PROOF_STARTED" && + -z $SK_SHELL_PID && -z $SK_SHELL_START && + $SK_DAEMON_PID == "$SK_PROOF_DAEMON_PID" && + $SK_DAEMON_START == "$SK_PROOF_DAEMON_START" && + $SK_MUTATION_ALLOWED == false && + $SK_MUTATION_REASON == missing-shell-generation && + ( $SK_STATUS == disconnected || $SK_STATUS == Disconnected ) ]] +} + sk_project_lookup() { local alias=$1 [[ -r $SK_PROJECTS_FILE ]] || return 1 + # An ignore row is not a shortcut, so it never resolves as a launch target. awk -F '\t' -v wanted="$alias" ' - $0 !~ /^[[:space:]]*#/ && NF >= 3 && $1 == wanted { + $0 !~ /^[[:space:]]*#/ && NF >= 3 && $1 == wanted && $2 != "ignore" { if ($2 !~ /^(claude|codex|shell)$/ || $3 !~ /^\//) exit 2 print $1 "\t" $2 "\t" $3 found=1 @@ -712,6 +1140,28 @@ sk_project_lookup() { ' "$SK_PROJECTS_FILE" } +sk_project_plan() { + # One line for `sp new`: root, provider, account, model, startup state. + # The resolver applies a committed session-kit.toml only for a project on + # this host's list, so what comes back is already what the host agreed to + # launch. Flags given here still win: they are passed in and returned + # unchanged. + local target=$1 provider=$2 account=$3 model=$4 + local -a options=(--projects-file "$SK_PROJECTS_FILE" --state-dir "$SK_STATE_DIR" + launch-plan "$target" --format tsv) + [[ -z $provider ]] || options+=(--provider "$provider") + [[ -z $account ]] || options+=(--account "$account") + [[ -z $model ]] || options+=(--model "$model") + if [[ -t 0 ]]; then options+=(--interactive); fi + # Say which of the two things went wrong. Without this the caller's "unknown + # or invalid project" would blame a perfectly good alias for a missing file. + [[ -f $SK_PROJECTS_CORE && ! -L $SK_PROJECTS_CORE ]] || { + printf 'session-kit: the project resolver is missing at %s\n' "$SK_PROJECTS_CORE" >&2 + return 1 + } + python3 "$SK_PROJECTS_CORE" "${options[@]}" +} + sk_allocate_id() { local json base candidate counter=0 json=$("$SK_SHPOOL" list --json 2>/dev/null) || return 1 @@ -731,7 +1181,8 @@ rows=d.get("sessions", d if isinstance(d,list) else []) raise SystemExit(0 if any(r.get("name")==sys.argv[1] for r in rows) else 1) ' "$candidate" <<<"$json" || [[ -e $SK_START_DIR/$candidate || -L $SK_START_DIR/$candidate || - -e $SK_START_DIR/$candidate.expected || -L $SK_START_DIR/$candidate.expected ]] + -e $SK_START_DIR/$candidate.expected || -L $SK_START_DIR/$candidate.expected || + -e $SK_START_DIR/$candidate.account || -L $SK_START_DIR/$candidate.account ]] do counter=$((counter + 1)) candidate="${base}-${counter}" @@ -739,6 +1190,101 @@ raise SystemExit(0 if any(r.get("name")==sys.argv[1] for r in rows) else 1) printf '%s\n' "$candidate" } +# True only when a fresh manager list still contains this exact raw name. A +# failed create can have many causes; retrying under a different name is +# justified only when the refused name is now visibly occupied. +sk_shpool_name_exists() { + local wanted=$1 json + json=$("$SK_SHPOOL" list --json 2>/dev/null) || return 1 + python3 -c ' +import json,sys +try: + value=json.load(sys.stdin) +except Exception: + raise SystemExit(2) +rows=value.get("sessions") if isinstance(value,dict) else value +if not isinstance(rows,list): + raise SystemExit(2) +raise SystemExit(0 if any(isinstance(row,dict) and row.get("name")==sys.argv[1] + for row in rows) else 1) +' "$wanted" <<<"$json" +} + +# Move every pre-creation record to a newly allocated name without following +# links or leaving a split set if one rename fails. +sk_reassign_launch_records() { + local old_id=$1 new_id=$2 suffix source destination index rollback + local -a sources=() destinations=() + [[ $old_id =~ ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$ && + $new_id =~ ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$ && + $old_id != "$new_id" ]] || return 1 + for suffix in '' .account .launch .prompt; do + source=$SK_START_DIR/$old_id$suffix + destination=$SK_START_DIR/$new_id$suffix + [[ ! -e $source && ! -L $source ]] && continue + [[ -f $source && ! -L $source && ! -e $destination && ! -L $destination ]] || + return 1 + sources+=("$source") + destinations+=("$destination") + done + (( ${#sources[@]} > 0 )) || return 1 + for (( index=0; index<${#sources[@]}; index++ )); do + if ! mv -- "${sources[index]}" "${destinations[index]}"; then + for (( rollback=index-1; rollback>=0; rollback-- )); do + mv -- "${destinations[rollback]}" "${sources[rollback]}" 2>/dev/null || true + done + return 1 + fi + done +} + +# After a refused background create, allocate and stage a different name only +# when the refused one is now present as a stale entry. The old entry remains +# visible. No path frees it by destruction. +sk_reallocate_stale_launch_name() { + local old_id=$1 new_id + sk_shpool_name_exists "$old_id" || return 1 + new_id=$(sk_allocate_id) || return 1 + [[ $new_id != "$old_id" ]] || return 1 + sk_reassign_launch_records "$old_id" "$new_id" || return 1 + printf 'session-kit: stale shpool entry %s blocked creation; allocated fresh session name %s\n' \ + "$old_id" "$new_id" >&2 + printf '%s\n' "$new_id" +} + +# Create under an allocated name, retrying only a proved stale-name collision. +# Timeout remains ambiguous and follows the existing generation-capture path; +# it is never retried because the first session may have been created. +sk_attach_new_unique() { + local initial_id=$1 cwd=$2 origin=${3:-} status=0 replacement attempts=0 + SK_ATTACHED_ID=$initial_id + SK_ATTACH_STATUS=0 + while (( attempts < 8 )); do + status=0 + sk_timeout 30 "$SK_SHPOOL" attach --background --dir "$cwd" \ + "$SK_ATTACHED_ID" || status=$? + if (( status == 0 || status == 124 || status == 137 )); then + SK_ATTACH_STATUS=$status + return 0 + fi + attempts=$((attempts + 1)) + if (( attempts >= 8 )); then + SK_ATTACH_STATUS=$status + return 1 + fi + replacement=$(sk_reallocate_stale_launch_name "$SK_ATTACHED_ID") || { + SK_ATTACH_STATUS=$status + return 1 + } + SK_ATTACHED_ID=$replacement + if [[ -n $origin ]] && declare -F sk_record_origin >/dev/null 2>&1; then + sk_record_origin "$SK_ATTACHED_ID" "$origin" + fi + done + SK_ATTACH_STATUS=$status + return 1 +} + sk_write_start_record() { local shpool_id=$1 provider=$2 cwd=$3 uuid=${4:-} launch_mode=${5:-} [[ $provider =~ ^(claude|codex|shell)$ && $cwd == /* ]] || return 1 @@ -767,11 +1313,26 @@ sk_write_start_record() { mv -f "$tmp" "$SK_START_DIR/$shpool_id" } +sk_write_account_record() { + local shpool_id=$1 provider=$2 alias=$3 + [[ $provider =~ ^(claude|codex)$ && + $alias =~ ^[a-z][a-z0-9_-]{0,11}$ ]] || return 1 + umask 077 + mkdir -p "$SK_START_DIR" || return 1 + chmod 700 "$SK_START_DIR" 2>/dev/null || true + local path="$SK_START_DIR/$shpool_id.account" + [[ ! -e $path && ! -L $path ]] || return 1 + local tmp="$SK_START_DIR/.${shpool_id}.account.$$" + printf '%s\t%s\n' "$provider" "$alias" > "$tmp" || return 1 + chmod 600 "$tmp" 2>/dev/null || true + mv -f -- "$tmp" "$path" +} + sk_capture_session_generation() { local shpool_id=$1 creation_floor_ms=$2 local attempt fresh for attempt in {1..20}; do - fresh=$(mktemp "$SK_STATE_DIR/generation-proof.XXXXXX.json") || return 1 + fresh=$(mktemp "$SK_STATE_DIR/generation-proof.json.XXXXXX") || return 1 if sk_guard_snapshot_file "$fresh" 2>/dev/null && python3 -c ' import json,sys @@ -849,6 +1410,9 @@ sk_quarantine_start_record() { if [[ -f $SK_START_DIR/$shpool_id.expected && ! -L $SK_START_DIR/$shpool_id.expected ]]; then mv "$SK_START_DIR/$shpool_id.expected" "$destination.expected" || return 1 fi + if [[ -f $SK_START_DIR/$shpool_id.account && ! -L $SK_START_DIR/$shpool_id.account ]]; then + mv "$SK_START_DIR/$shpool_id.account" "$destination.account" || return 1 + fi printf '%s\n' "$destination" } @@ -973,7 +1537,7 @@ sk_wait_for_provider() { current_boot_id=$(sk_boot_id) || return 1 current_boot_id=${current_boot_id//$'\r'/} current_boot_id=${current_boot_id//$'\n'/} - fresh=$(mktemp "$SK_STATE_DIR/provider-proof.XXXXXX.json") || return 1 + fresh=$(mktemp "$SK_STATE_DIR/provider-proof.json.XXXXXX") || return 1 local identity_matches=false if sk_guard_snapshot_file "$fresh" 2>/dev/null && sk_resolve "$fresh" "$shpool_id"; then @@ -1039,34 +1603,120 @@ PY return 0 fi command rm -- "$fresh" + # A human is usually watching this wait, and it can run for minutes on a + # loaded box. Silence reads as a hang; say what is still being waited on. + # Attempts sleep 0.25s plus inventory time, so attempt/4 is a floor. + if [[ -t 1 ]] && (( attempt % 40 == 0 )); then + printf ' still waiting for %s to publish its identity (%ss+ elapsed)\n' \ + "$expected_provider" "$(( attempt / 4 ))" + fi (( attempt == attempts )) || sleep 0.25 done return 1 } -# One keypress, never a typed session name. The caller has already selected an -# exact session and the line below names it, so retyping an ID proved nothing -# except that the human could copy a string. -sk_confirm_exact() { - local action=$1 id=$2 title=$3 provider=$4 - printf '\n %s\n %s [%s] %s\n' "$action" "$title" "$id" "$provider" - if [[ ${SESSION_KIT_NONINTERACTIVE:-0} == 1 ]]; then - [[ ${SESSION_KIT_CONFIRM_ID:-} == "$id" ]] - return +# Provider names as a person reads them. Claude and Codex are product names and +# are always capitalized; a plain shell is `shell`, a common noun, and stays +# lowercase. One helper so no print site has to remember. +sk_provider_name() { + case "${1:-}" in + claude) printf 'Claude' ;; + codex) printf 'Codex' ;; + *) printf '%s' "${1:-}" ;; + esac +} + +# How a session is named to a person: its session number and its own title, +# or its provider when it has neither. Never an internal ID, no human-readable +# Session Kit output prints one, anywhere. +# The kit owns the terminal tab name (K3), on both providers. +# +# Claude Code's documented off-switch cannot be part of this. The live drill in +# tools/title-ownership-drill (2026-08-13, Claude Code 2.1.229) proved that +# CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 silences EVERY title write from that +# window, the kit's own hook output included: with the switch unset a hook +# asking for a title produces `ESC]0;<glyph> <kit name>BEL`, and with it set the +# same session writes no title sequence at all, for sessionTitle and for a +# hook-emitted terminalSequence alike. So the kit never sets that variable. It +# writes the tab name itself at the moments no provider hook fires -- opening, +# attaching, and taking over a session -- and lets the provider's in-window hook +# carry renames from there. A restored conversation takes its name when it is +# opened; the name and colour themselves are written into the provider's own +# store before the restore launches (a restore ends at the picker or a prompt, +# not inside the session, so naming the tab there would label a terminal that is +# not in that session). +# +# This lives here rather than beside the attach primitives because both front +# doors need it: `sp` (open, attach, takeover) and the login picker (its own +# open, and the "session kit" name it restores on the way back). +# +# Kill switch: SESSION_KIT_TAB_TITLE=off writes nothing at all, on either +# provider (the Codex launch override in bashrc/shpool.bashrc reads the same +# variable). Rollback is that one variable. +sk_tab_title() { + local name=${1:-} switch=${SESSION_KIT_TAB_TITLE:-} + # Read exactly as `session-kit doctor` reads it, or the two disagree about + # whether the tab name is on: strip, then casefold. + switch=${switch#"${switch%%[![:space:]]*}"} + switch=${switch%"${switch##*[![:space:]]}"} + [[ ${switch,,} != off ]] || return 0 + [[ -t 1 ]] || return 0 + # A session title is provider metadata: it can carry control bytes, and a + # control byte inside an OSC string is a terminal command. C1 (0x80-0x9F) + # counts -- 0x9C is ST and 0x9B is CSI, so an untrimmed C1 byte ends the OSC + # string early and the rest is parsed as a control sequence. LC_ALL=C keeps + # `tr` working on bytes rather than on the locale's idea of a character. + name=$(printf '%s' "$name" | + LC_ALL=C tr -d '\000-\037\177-\237' | + LC_ALL=C tr -s '[:space:]' ' ') + name=${name#"${name%%[![:space:]]*}"} + name=${name%"${name##*[![:space:]]}"} + [[ -n $name ]] || return 0 + printf '\033]0;%s\007' "${name:0:64}" +} + +sk_human_label() { + local title=${1:-} provider=${2:-} number=${3:-} + # A title is provider metadata, so it can carry terminal control bytes. + # Scrub before any of it reaches a screen. + title=$(printf '%s' "$title" | tr -d '[:cntrl:]' | tr -s '[:space:]' ' ') + title=${title#"${title%%[![:space:]]*}"} + title=${title%"${title##*[![:space:]]}"} + if [[ -n $number && -n $title ]]; then + printf 'session %s (%s)' "$number" "$title" + elif [[ -n $number ]]; then + printf 'session %s' "$number" + elif [[ -n $title ]]; then + printf '%s' "$title" + elif [[ -n $provider ]]; then + printf 'the %s session' "$(sk_provider_name "$provider")" + else + printf 'the session' fi - local answer="" - read -r -n 1 -p " Confirm? [y/N] " answer - # Humans type y-then-Enter. The single-character read leaves that Enter in - # the terminal buffer, where the caller's next menu read would consume it - # as an empty choice — which means "go to a regular terminal" and silently - # ended the picker after every kill. Drain the rest of the line. - if [[ $answer != "" && $answer != $'\n' ]]; then - local __sk_confirm_rest="" - IFS= read -r -s -t 0.3 __sk_confirm_rest 2>/dev/null || true - : "${__sk_confirm_rest}" +} + +# Nothing is ever asked (operator decision, permanent). A person already chose +# an exact session under a frozen proof; the guard-and-revalidate path that +# follows every caller is the safety net, and a question only added friction to +# a decision already made. Mistakes are undone by restore, not prevented by a +# prompt. +# +# The two halves that remain: +# +# * a person at a terminal is told what is about to happen, by name, and the +# action proceeds; +# * automation, which has no one to tell, must name the exact session it is +# acting on in SESSION_KIT_CONFIRM_ID. That half is a proven safety net: +# a pipe feeding "y" was never a person reading a label, and it stays. +# +# The exact ID travels as an argument and is never printed back. +sk_confirm_exact() { + local action=$1 id=$2 title=$3 provider=$4 number=${5:-} + if [[ ${SESSION_KIT_NONINTERACTIVE:-0} == 1 || ! -t 0 ]]; then + [[ ${SESSION_KIT_CONFIRM_ID:-} == "$id" ]] || return 1 fi - printf '\n' - [[ $answer == y || $answer == Y ]] + printf '%s %s.\n' "$action" "$(sk_human_label "$title" "$provider" "$number")" + return 0 } sk_recovery_journal() { diff --git a/bin/session_kit_notice b/bin/session_kit_notice new file mode 100755 index 0000000..e8ab9a9 --- /dev/null +++ b/bin/session_kit_notice @@ -0,0 +1,1451 @@ +#!/usr/bin/env python3 +"""session_kit_notice -- the door every notice uses that is NOT a picker row. + +The picker used to be the place four different things were told to a person: +a repair that failed, a session reported quiet with nothing changed, a stalled +session, and a question some other window was asking. Those are not "sessions +waiting for you", and the picker is being narrowed to exactly that list. This +program is where the other four go instead, so that narrowing costs nothing. + + at the machine -> the terminal session they are already sitting in + away, or unsure -> the away transport (a phone-reachable notifier) + the picker -> nothing, ever + +THE TERMINAL LEG ONLY APPLIES INSIDE A MANAGED SESSION. A person working in a +terminal this kit does not manage has nothing attached, so presence reads away +and every notice takes the away transport. That is not a fault -- there is no +managed conversation to deliver into -- but it means an estate whose operator +usually works outside the kit has ONE route, and leaving the away transport +unset means it has none. + +THE PRESENCE RULE, in one sentence: they are at the machine only when a live, +recent snapshot shows EXACTLY ONE attached session and that session has a valid +recent UserPromptSubmit record; provider output is never presence evidence. +Every other answer -- no snapshot, a stale one, nothing attached, SEVERAL +attached, stale/missing/future input, or a question open in the target -- is +away, because a duplicate notice costs them a glance and a missed one costs them +the night. + +Why evidence and not a flag someone set: a declared presence is a guess that +goes stale (the estate had one reading "at the keyboard" for two days while +nobody was there). Why not `attached` alone: the snapshot contract defines it as +"open in another window", and the watchdog beside this file exists partly +because a session can stay wired to an SSH window that no longer exists. And why +never a choice between two attached rows: nothing here ranks them, provider +output age is not focus, and a notice into a Claude session PROMPTS that +conversation -- so a guess does not merely display to the wrong window, it talks +to the wrong conversation. + +THE TERMINAL ROUTE NEVER WRITES TO A PTY. It uses the session's own +authenticated message socket (lib/sessionkit_messages/claude_socket.py), which +is the vendor's interface for putting a line into a running session. Writing to +a terminal a provider owns is how a screen gets corrupted, and this kit has +already had one terminal handed over in the wrong mode. + +NO SILENT SUCCESS, AND DUPLICATES ONLY AS A SAFE FALLBACK. Every notice is +latched by (class, key) and by the EVIDENCE behind it: the same thing said twice +is not normally said twice, and stronger evidence about the same session is. A +terminal send is first recorded as PROVISIONAL and suppresses nothing. It is +promoted to landed only after reconciliation proves a receipt and the same +attachment from immediately before the send through receipt confirmation. Any +detach or uncertainty demotes it and fires the away leg. That can deliver one +duplicate; it cannot safely be avoided because the receipt proves conversation +acceptance, not that a person remained at the terminal. + +Commands + presence print the presence verdict as JSON; send nothing + route --class C --key K --subject S --body B [--evidence E] + route one notice + sweep read the durable records and route what is new + +Configuration (all optional; every path defaults under the kit's state dir) + SESSION_KIT_NOTICE=off disable every route (the kill switch) + SESSION_KIT_NOTICE_AWAY_CMD the away transport. Takes the same + --type/--severity/--title/--body + contract the watchdog notifier takes, + so any conforming notifier fits -- but + it is NEVER inherited from that + notifier: see away_command() for why. + Unset means there is no away route and + every attempt says so. + SESSION_KIT_NOTICE_TERMINAL=off never use the terminal route + SESSION_KIT_NOTICE_QUESTION_AWAY=hold + stop pushing another window's open + question to the away transport. The + default is `send`, because a class with + no route is what this file exists to + prevent; `hold` still records, logs and + counts the question rather than dropping + it. + SESSION_KIT_FLEET_DIR where the stall flags and the question + inbox live (default ~/.local/state/fleet) + SESSION_KIT_NOTICE_SNAPSHOT_MAX_AGE seconds a snapshot may be old and still + answer the presence question (default 90) + SESSION_KIT_NOTICE_PRESENT_WITHIN seconds of silence after which the one + attached session reads as a window left + open rather than one they are in (300) + SESSION_KIT_NOTICE_CONFIRM_SECONDS how long to wait for a terminal notice to + appear in the target's own transcript + before calling it unconfirmed (6) + SESSION_KIT_NOTICE_MAX_AGE seconds after which a record is counted + rather than named (21600) +""" + +from __future__ import annotations + +import argparse +import calendar +import glob +import json +import os +from pathlib import Path +import subprocess +import sys +import time +from typing import Any, Mapping + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib")) + +from sessionkit_inventory import transcript_text, transcripts # noqa: E402 +from sessionkit_inventory.attention import ( # noqa: E402 + default_state_dir, + read_record, +) + +try: + from sessionkit_messages import claude_socket +except ImportError: # pragma: no cover -- a tree without the messages module + claude_socket = None # type: ignore[assignment] + + +SCHEMA_VERSION = 1 +# One hour, matching the coalescing window the existing notifier already applies +# to a warning. Callers that own their own incident discipline (the watchdog's +# report_once, its per-session record latch) pass this; the record sweep passes +# 0, which means "while this record exists, it has been said". +DEFAULT_REPEAT_AFTER = 3600 +# Stall flags older than this mean the observer that writes them is not running. +# The picker used the same bound; a dead observer's last opinion is not news. +FLEET_FLAG_MAX_AGE = 300 +LATCH_ENTRIES = 400 +# Older than this and a record is COUNTED in the message rather than named in +# it. It is never dropped for being old: age is not acknowledgement. Six hours +# matches the watchdog's own quiet-report cooldown. Measured against the live +# estate on 2026-08-15: 50 records, 21 sessions, ages 3.7h to 29.7h -- one +# message names the newest five and counts the rest. +DEFAULT_MAX_AGE = 21600 +# How many sessions one message names before it says "and N more". A notice is +# a nudge to go and look, not the report itself. +NAMED_IN_A_BATCH = 5 +# Same ranking `fix/attention-truth-line` gives the watchdog's record latch. A +# record written before the evidence field existed reads as the weak kind, +# which is what all of them were. +EVIDENCE_STRENGTH = {"silence-only": 1, "daemon-marker": 2} + +CLASS_QUESTION = "background-question" + + +# --------------------------------------------------------------------------- +# small shared helpers +# --------------------------------------------------------------------------- + + +def _home() -> Path: + return Path(os.environ.get("HOME") or Path.home()) + + +def state_dir() -> Path: + return default_state_dir(os.environ, _home()) + + +def fleet_dir() -> Path: + configured = os.environ.get("SESSION_KIT_FLEET_DIR") + if configured: + return Path(configured) + return _home() / ".local" / "state" / "fleet" + + +def enabled() -> bool: + return (os.environ.get("SESSION_KIT_NOTICE") or "on").strip().casefold() != "off" + + +def log(message: str) -> None: + path = Path( + os.environ.get("SESSION_KIT_NOTICE_LOG") or (state_dir() / "notice.log") + ) + stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + try: + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + with open(path, "a", encoding="utf-8") as handle: + handle.write(f"{stamp} {message}\n") + os.chmod(path, 0o600) + except OSError: + pass + + +def read_json(path: Path, *, limit: int = 4 * 1024 * 1024) -> Any: + try: + if path.stat().st_size > limit: + return None + with open(path, encoding="utf-8") as handle: + return json.load(handle) + except (OSError, ValueError): + return None + + +def write_json_private(path: Path, payload: Any) -> bool: + """Replace one owner-only file atomically. False rather than an exception: + losing the bookkeeping must never stop the notice itself going out.""" + try: + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + temporary = path.with_name(f"{path.name}.tmp.{os.getpid()}") + descriptor = os.open( + temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600 + ) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(payload, handle, sort_keys=True, indent=1) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + return True + except OSError: + return False + + +def clean(value: object, limit: int, *, newlines: bool = False) -> str: + """Every field here is written by another process and ends up on a terminal + or in a chat message. Control characters cannot forge a line from either. + + ``newlines`` keeps line breaks, and ONLY line breaks, for the one field + that is a list rather than a sentence: a batch body naming five sessions is + unreadable as one long line, which is what collapsing all whitespace did to + it. Everything below a space still goes, so a title cannot clear a screen + or forge a row. + """ + text = "".join( + character + for character in str(value if value is not None else "") + if character >= " " or (newlines and character == "\n") + if character != "\x7f" + ) + if not newlines: + return " ".join(text.split())[:limit] + kept = [" ".join(line.split()) for line in text.split("\n")] + return "\n".join(kept).strip("\n")[:limit] + + +# --------------------------------------------------------------------------- +# presence +# --------------------------------------------------------------------------- + + +def snapshot_is_live(document: object, *, max_age: float) -> bool: + """The same gate the watchdog trusts, plus an age bound. + + `source: live` and `stale: false` say the snapshot was not served from a + cache. Neither says WHEN. A snapshot written an hour ago is a truthful + record of an hour ago, and reading an hour-old "attached" as "they are here" + is exactly the wrong-way failure: the notice would be delivered to an empty + chair. + """ + if not isinstance(document, Mapping): + return False + if document.get("source") != "live" or document.get("stale") is not False: + return False + generated = document.get("generated_at") + if not isinstance(generated, str) or not generated: + # No stamp to check: accept it only from a command run just now, which + # is the caller's job to arrange (read_snapshot passes max_age=0 there). + return max_age <= 0 + try: + # The stamp is UTC and says so. timegm reads it as UTC; mktime would + # read it as local time and put the snapshot hours into the future or + # the past depending on where the machine is. + moment = calendar.timegm(time.strptime(generated, "%Y-%m-%dT%H:%M:%SZ")) + except (TypeError, ValueError): + return False + age = time.time() - moment + # A stamp a few seconds ahead is clock skew. A stamp a MINUTE ahead is a + # clock nobody should be making a presence decision on, so the tolerance is + # small on purpose. + return -5 <= age <= max_age + + +def read_snapshot(*, require_current: bool = False) -> tuple[Any, str]: + """(snapshot, where it came from). Never raises, never blocks for long. + + ``require_current`` refuses the published fallback. A recent inventory is + enough for the ordinary presence verdict, but it cannot prove what stayed + attached across a send that just finished. + """ + given = os.environ.get("SESSION_KIT_NOTICE_SNAPSHOT") + if given: + document = read_json(Path(given)) + if snapshot_is_live(document, max_age=max_age_seconds()): + return document, "snapshot file" + return None, "the given snapshot is not live" + command = os.environ.get("SESSION_KIT_STATUS_CMD") or str( + Path(__file__).resolve().parent / "shpool_status" + ) + if os.access(command, os.X_OK): + try: + # --strict-json, NEVER --json. `shpool_status` lists --json under + # "Modes that refresh the cached inventory as they run": its branch + # calls fill_pending_titles, which hydrates and PUSHES automatic + # titles, and it publishes snapshot state as it goes. Asking "are they + # here?" must not write anything, and on a 60-second watchdog that + # would have doubled this machine's inventory-refresh and + # title-push rate for the rest of its life. + # + # --strict-json is `snapshot --strict-live --no-write`: nothing is + # written, and it exits 3 rather than answer from stale, partial or + # ambiguous evidence. That refusal is the right one here, because + # the identity it hands back is the conversation this router will + # talk to -- and a refusal reads as presence unknown, which is away. + finished = subprocess.run( + [command, "--strict-json"], capture_output=True, text=True, timeout=30 + ) + if finished.returncode == 0: + document = json.loads(finished.stdout or "null") + # Straight from the manager a moment ago: its own freshness. + if snapshot_is_live(document, max_age=0) or snapshot_is_live( + document, max_age=max_age_seconds() + ): + return document, "the session manager" + except (OSError, ValueError, subprocess.SubprocessError): + pass + if require_current: + return None, "no current answer from the session manager" + published = state_dir() / "inventory.json" + document = read_json(published) + if snapshot_is_live(document, max_age=max_age_seconds()): + return document, "the published inventory" + return None, "no live snapshot" + + +def max_age_seconds() -> float: + # 90 seconds: the watchdog collects fresh every 60, so a snapshot older + # than this is not the current state of the estate. They can walk away inside + # the window either way -- which is why presence needs the activity test as + # well, not just a young snapshot. + raw = os.environ.get("SESSION_KIT_NOTICE_SNAPSHOT_MAX_AGE") or "90" + try: + return max(0.0, float(raw)) + except ValueError: + return 90.0 + + +def active_within() -> float: + raw = os.environ.get("SESSION_KIT_NOTICE_PRESENT_WITHIN") or "300" + try: + return max(0.0, float(raw)) + except ValueError: + return 300.0 + + +def last_human_input(uuid: str) -> tuple[bool, float]: + """(a person typed here, how long ago). The only presence evidence used. + + This used to read `recent_output_age_seconds` -- the session PRODUCING + output -- and call that presence. It is not. Provider output is autonomous: + a session works, prints, and keeps printing for as long as its turn takes, + with nobody in the room. A window they walked away from mid-turn looked more + present than one they were reading, and the notice was delivered into an + unattended conversation and latched as said. + + So the evidence has to be INPUT: something only a person produces. The kit + already records exactly that. Claude Code's UserPromptSubmit hook fires when + a prompt is submitted, and config/claude/attention_hook.sh writes it to the + session's attention record with the timestamp -- its own comment for that + branch is "The person answered." + + Limits, said plainly rather than papered over: + + * A session with no hook installed, a Codex session, or one whose newest + record is a notification rather than a prompt, all read as NO evidence, + which is away. That is the conservative direction and it is deliberate. + * A prompt submitted by automation would look the same as a person + typing. Combined with "attached", that is a narrow case, and it fails + toward delivering a notice to a live terminal rather than toward + silence. + """ + exact = (uuid or "").strip().lower() + if not exact: + return False, float("inf") + record = read_record(state_dir(), exact) + if not isinstance(record, Mapping): + return False, float("inf") + if str(record.get("hook_event") or "") != "UserPromptSubmit": + return False, float("inf") + stamp = record.get("recorded_at_ms") + if not isinstance(stamp, int) or isinstance(stamp, bool) or stamp <= 0: + return False, float("inf") + return True, max(0.0, time.time() - stamp / 1000) + + +def presence(*, require_current: bool = False) -> dict[str, Any]: + """Are they at the machine, and if so WHICH session may carry a notice. + + `attached` alone is not enough, and treating it as enough was wrong twice + over. The snapshot contract defines it as "open in another window" + (lib/sessionkit_inventory/SNAPSHOT.md) -- there is no focus in it, no + keyboard, no proof a person is there. The watchdog beside this file exists + partly BECAUSE a session can stay wired to an SSH window that no longer + exists, so an attached row can be an abandoned terminal. And a notice into + a Claude session is a `type=user` frame: the wrong session is not merely + written to, its conversation is PROMPTED. + + So two conditions, and both are required: + + * EXACTLY ONE session is attached. Two attached rows mean the machine + cannot say which window they are in -- nothing in the snapshot ranks them, + and provider output age is not focus (a session working by itself + prints constantly while they read another). Ambiguity takes the away + route; guessing prompts the wrong conversation. + * That session has a recent UserPromptSubmit hook record. Provider output + is autonomous and is not consulted. A record more than five seconds in + the future is not evidence; a backwards clock must not manufacture a + person who is no longer there. + + Everything else -- no snapshot, a stale one, nothing attached, several + attached, or missing/stale/future input -- is away. + """ + document, where = read_snapshot(require_current=require_current) + if document is None: + return {"present": False, "why": f"presence unknown ({where})", "targets": []} + attached = [] + for row in document.get("sessions") or (): + if not isinstance(row, Mapping) or row.get("availability") != "attached": + continue + identity = row.get("identity") + attached.append( + { + "shpool_id": clean(row.get("shpool_id"), 80), + "provider": clean(row.get("provider"), 20), + "uuid": clean( + (identity or {}).get("uuid") if isinstance(identity, Mapping) else "", + 40, + ), + "title": clean(row.get("display_title") or row.get("title"), 80), + "needs_you": row.get("needs_you") is True, + "age": row.get("recent_output_age_seconds"), + } + ) + if not attached: + return { + "present": False, + "why": f"no terminal is attached to any session ({where})", + "targets": [], + } + if len(attached) > 1: + return { + "present": False, + "why": ( + f"{len(attached)} terminals are attached and nothing here says which " + f"one they are in; guessing would prompt the wrong conversation ({where})" + ), + "targets": [], + } + only = attached[0] + typed, since = last_human_input(str(only.get("uuid") or "")) + bound = active_within() + if not typed: + return { + "present": False, + "why": ( + f"the one attached session shows no sign of a person typing in it " + f"({where})" + ), + "targets": [], + } + if since > bound: + return { + "present": False, + "why": ( + f"nobody has typed in the one attached session for {int(since)}s, " + f"so it reads as a window left open ({where})" + ), + "targets": [], + } + return { + "present": True, + "why": f"a person typed in the one attached session {int(since)}s ago ({where})", + "targets": [only], + } + + +# --------------------------------------------------------------------------- +# the latch +# --------------------------------------------------------------------------- + + +def latch_path() -> Path: + return Path( + os.environ.get("SESSION_KIT_NOTICE_LATCH") or (state_dir() / "notice-latch.json") + ) + + +def read_latch() -> dict[str, Any]: + document = read_json(latch_path()) + if not isinstance(document, Mapping): + return {} + entries = document.get("notices") + return dict(entries) if isinstance(entries, Mapping) else {} + + +def write_latch(entries: Mapping[str, Any]) -> bool: + ordered = sorted( + entries.items(), + key=lambda item: item[1].get("at_unix", 0) if isinstance(item[1], Mapping) else 0, + ) + trimmed = dict(ordered[-LATCH_ENTRIES:]) + return write_json_private( + latch_path(), {"schema_version": SCHEMA_VERSION, "notices": trimmed} + ) + + +def is_new_statement(stored: str, incoming: str) -> bool: + """Is this a new thing to say about the same key? + + Deliberately the SAME rule `fix/attention-truth-line` gives the watchdog's + own record latch: strength, not novelty. A repeat of the same statement is + not new. An ESCALATION -- ordinary silence, and then the daemon's own + handoff failure for that session -- is. A downgrade is not: once the strong + thing has been said, saying the weak one afterwards adds nothing and would + let the two latches fight over the same event stream. + + Evidence this table does not rank (a stall reason, an alert subject) is + compared as text, where any change is a change. + """ + if stored == incoming: + return False + stored_rank = EVIDENCE_STRENGTH.get(stored) + incoming_rank = EVIDENCE_STRENGTH.get(incoming) + if stored_rank is not None and incoming_rank is not None: + return incoming_rank > stored_rank + return True + + +def already_said( + entry: object, + evidence: str, + repeat_after: float, + source_at: float | None = None, +) -> bool: + """One per incident AND per distinct evidence. + + ONE RULE FOR THE STREAM, AND IT IS THE WATCHDOG'S. The watchdog decides + what a new incident is: it compares a session's output against the time of + its last record, and output since then means the session RECOVERED, so + whatever went quiet afterwards is a new incident and earns a new record. + This file invented a second rule -- one key per session, unchanged evidence + is said forever while any record exists -- and the two disagreed exactly + where it hurts: a genuine recovery followed by a second real failure wrote + a watchdog record and produced NO notice. + + So the two are not reconciled by argument, they are reduced to one. A + record written AFTER the last thing said about that session is, by the + watchdog's own judgement, a new thing to say. `source_at` is that record's + own timestamp; when it is newer than the notice, this is not a repeat. + """ + if not isinstance(entry, Mapping): + return False + if is_new_statement(clean(entry.get("evidence"), 200), evidence): + return False + # The SOURCE timestamp we acted on, not the wall-clock of sending. Those + # differ by milliseconds, and comparing against the send time would make + # "is this the same record?" a race. + said_at = entry.get("source_at") + if not isinstance(said_at, (int, float)) or isinstance(said_at, bool): + said_at = entry.get("at_unix") + if ( + source_at is not None + and isinstance(said_at, (int, float)) + and not isinstance(said_at, bool) + and source_at > float(said_at) + ): + # The observer wrote this AFTER we last spoke about the session, which + # is its way of saying the last one was over. + return False + stamp = entry.get("at_unix") + if not isinstance(stamp, (int, float)) or isinstance(stamp, bool): + return False + if not entry.get("landed"): + # A failed attempt is evidence of no delivery, not evidence that this + # notice was already said. Suppressing it here made a failed sweep + # report quiet=true and exit zero on the very next pass. + return False + if entry.get("reconciled") is not True or entry.get("state") != "reconciled": + # A terminal frame is written to the latch as provisional before its + # receipt and attachment interval are reconciled. It is evidence that + # a send may have happened, never evidence that the person was reached. + # Old latch entries predate this distinction and are retried once in + # the conservative direction rather than silently treated as proof. + return False + age = time.time() - float(stamp) + if repeat_after <= 0: + return True + return age < repeat_after + + +# --------------------------------------------------------------------------- +# the two routes +# --------------------------------------------------------------------------- + + +def away_command() -> str: + """The away transport, and it is NOT inherited from the watchdog notifier. + + It would have been convenient to fall back to SESSION_KIT_WATCHDOG_NOTIFY. + It would also have been wrong: on the estate this was written for, that + notifier is an alert BOARD -- a page somebody goes and looks at -- and the + whole reason this router exists is that a board is not a person. Falling + back would have posted every notice to the same board twice and reported + the operator as reached. Unset means there is no away route, said out loud, + which is a wiring gap somebody can fix rather than a lie somebody believes. + """ + return os.environ.get("SESSION_KIT_NOTICE_AWAY_CMD") or "" + + +def send_away(notice_class: str, key: str, subject: str, body: str) -> tuple[bool, str]: + command = away_command() + if not command: + # Greppable and unmistakable. An estate with no away transport has no + # away route at all, and that has to read as a wiring fault rather than + # as a quiet night. + log( + "UNWIRED: SESSION_KIT_NOTICE_AWAY_CMD is not set, so nothing reaches " + f"them while they are away -- dropped: {subject}" + ) + return False, "no away transport is configured (SESSION_KIT_NOTICE_AWAY_CMD)" + if not os.access(command, os.X_OK): + return False, f"the away transport {command} is not executable" + # Severity is pinned to warning exactly as the watchdog pins it: a notice + # about a session, delivered or not, must never fire a critical. + argv = [ + command, + f"--type=session-kit.{notice_class}", + "--severity=warning", + f"--title={subject}", + f"--body={body}", + f"--class={notice_class}", + f"--key={key}", + ] + try: + finished = subprocess.run(argv, capture_output=True, text=True, timeout=120) + except subprocess.TimeoutExpired: + return False, "the away transport did not finish in 120s" + except OSError as error: + return False, f"the away transport could not run: {error!r}" + if finished.returncode != 0: + detail = clean(finished.stderr or finished.stdout, 160) + return False, f"the away transport exited {finished.returncode}: {detail}" + return True, "away transport accepted it" + + +def notice_timeout() -> float: + raw = os.environ.get("SESSION_KIT_NOTICE_TIMEOUT") or "5" + try: + return max(1.0, float(raw)) + except ValueError: + return 5.0 + + +def confirm_seconds() -> float: + """How long to wait for the message to appear in the target's transcript. + + A live session writes its transcript as it renders, so this is normally a + fraction of a second. The bound exists so a sweep is never held up: an + unconfirmed notice is not lost, it takes the away route. + """ + raw = os.environ.get("SESSION_KIT_NOTICE_CONFIRM_SECONDS") or "6" + try: + return max(0.0, float(raw)) + except ValueError: + return 6.0 + + +def terminal_targets(verdict: Mapping[str, Any]) -> list[dict[str, Any]]: + """The session a notice may be put into. Never a choice between several. + + `presence()` only ever hands one session over, and that is the point: this + used to rank several attached rows by provider output age, which is not + focus and would have prompted whichever conversation happened to be + printing. There is nothing to rank here now. + + A session that is ASKING is skipped: a delivery over an open question + rendered on top of the dialog and pushed the question off their screen + (observed 2026-08-13), and a notice is precisely the thing that can wait. + """ + return [ + row + for row in verdict.get("targets") or () + if isinstance(row, Mapping) and not row.get("needs_you") + ] + + +def same_terminal_target( + expected: Mapping[str, Any], current: Mapping[str, Any] +) -> bool: + """Is this still the exact managed conversation presence selected?""" + return all( + str(expected.get(field) or "") == str(current.get(field) or "") + for field in ("shpool_id", "provider", "uuid") + ) + + +def confirm_in_transcript(uuid: str, message_uuid: str, *, deadline: float) -> bool: + """Did the message actually land in the target's own conversation? + + The transport is explicit that it cannot answer this: an accepted message + sends no receipt, so `send()` returns `sent-no-receipt` with + `delivered=False` and says the receipt of record is the message appearing + in the TARGET's own transcript. Reporting that as delivered -- which this + file used to do -- is the sender believing its own claim, and for a swept + record it then stayed suppressed while the record lived. + + So the claim is checked where the transport says to check it. Read-only: + the conversation file is opened for reading and nothing else. + """ + if not message_uuid: + return False + while True: + path = transcripts.locate_transcript("claude", uuid, environ=os.environ) + if path is not None: + try: + with open(path, "rb") as handle: + # The tail is enough and is bounded: a transcript grows for + # the life of a conversation and this runs on a watchdog + # pass. Inspect the byte immediately before the window so + # a cut first line can be discarded without throwing away + # a complete line when the boundary happens to align. + handle.seek(0, os.SEEK_END) + size = handle.tell() + start = max(0, size - 262144) + if start: + handle.seek(start - 1) + boundary = handle.read(1) + else: + handle.seek(0) + boundary = b"\n" + tail = handle.read(262144) + if boundary != b"\n": + newline = tail.find(b"\n") + tail = b"" if newline < 0 else tail[newline + 1 :] + for raw in tail.splitlines(): + try: + entry = json.loads(raw) + except (ValueError, UnicodeError): + continue + if ( + isinstance(entry, dict) + and entry.get("type") == "user" + and entry.get("uuid") == message_uuid + and transcript_text.render_transcript_entry(entry) + ): + return True + except OSError: + pass + if time.time() >= deadline: + return False + time.sleep(0.25) + + +def send_terminal( + verdict: Mapping[str, Any], subject: str, body: str +) -> tuple[dict[str, Any] | None, str]: + """Attempt one terminal send and return an untrusted handoff record. + + The returned attempt is deliberately provisional. It says only what the + socket reported and whether the same target bracketed the send. It cannot + become delivery until ``reconcile_terminal`` establishes the receipt and + closes the attachment interval after that receipt is knowable. + """ + if (os.environ.get("SESSION_KIT_NOTICE_TERMINAL") or "on").strip().casefold() == "off": + return None, "the terminal route is switched off" + if claude_socket is None: + return None, "this tree has no session message client" + text = f"{subject}\n\n{body}" + reasons = [] + for row in terminal_targets(verdict): + provider = str(row.get("provider") or "") + uuid = str(row.get("uuid") or "") + if provider != "claude" or not uuid: + # Codex sessions are reachable only through the app-server steer + # path, which is not in this repository. Naming it is the point: + # the notice goes to the away transport instead of nowhere. + reasons.append(f"{provider or 'unknown'} session cannot be addressed here") + continue + report = claude_socket.capability(uuid) + if not report.get("available"): + reasons.append(f"{uuid[:8]}: {report.get('reason') or 'unreachable'}") + continue + # Capability says only that the conversation has a receiver. It says + # nothing about whether the person is still at this terminal. Re-read + # the live estate AFTER capability and immediately before the send: + # detach, a second attachment, or a newly opened question invalidates + # the first verdict and must take the away route. + current_verdict = presence() + current_targets = ( + terminal_targets(current_verdict) + if current_verdict.get("present") + else [] + ) + if ( + len(current_targets) != 1 + or not same_terminal_target(row, current_targets[0]) + ): + reasons.append( + "presence changed before delivery: " + + clean(current_verdict.get("why"), 180) + ) + continue + # Use the re-proved row from here onward. Any receipt or transcript + # confirmation below therefore occurs after the last presence check + # that authorised this delivery. + row = current_targets[0] + # A short receipt wait on purpose. A REFUSAL comes back over a local + # socket in milliseconds, and an acceptance sends nothing back at all, + # so waiting the module's full default would spend twenty seconds per + # notice learning nothing. Long enough to hear "held" or "denied"; + # short enough that a sweep is not held up by good news. + outcome = claude_socket.send( + uuid, + text, + priority=(os.environ.get("SESSION_KIT_NOTICE_PRIORITY") or "next"), + sender="session-kit-notice", + timeout=notice_timeout(), + ) + # This observation brackets the socket call, but it does NOT settle the + # handoff. Even a queue acknowledgement still needs the transcript's + # receipt of record, and that can take another six seconds. A current + # inventory can also have sampled attachment before its remaining + # collection work. Route records this attempt as provisional first; + # reconciliation makes a separate closing observation only after the + # transcript receipt is established. + delivered_verdict = presence(require_current=True) + delivered_targets = ( + terminal_targets(delivered_verdict) + if delivered_verdict.get("present") + else [] + ) + interval_held = len(delivered_targets) == 1 and same_terminal_target( + row, delivered_targets[0] + ) + closing_row = delivered_targets[0] if interval_held else row + return { + "target": dict(closing_row), + "uuid": uuid, + "queue_acknowledged": outcome.delivered is True, + "outcome_how": clean(outcome.how, 80), + "message_uuid": clean(outcome.extra.get("message_uuid"), 120), + "send_bracket_held": interval_held, + "send_bracket_why": clean(delivered_verdict.get("why"), 180), + }, f"terminal send is provisional ({clean(outcome.how, 80)})" + if not reasons: + return None, "every attached session is asking a question of its own" + return None, "; ".join(reasons[:4]) + + +def reconcile_terminal(attempt: Mapping[str, Any]) -> tuple[bool, str]: + """Promote only a transcript receipt followed by current same attachment.""" + target = attempt.get("target") + if not isinstance(target, Mapping): + return False, "terminal attempt has no exact target to reconcile" + uuid = clean(attempt.get("uuid"), 40) + how = clean(attempt.get("outcome_how"), 80) or "unknown socket outcome" + name = clean(target.get("title"), 80) or uuid[:8] + + if attempt.get("send_bracket_held") is not True: + return False, ( + "presence changed during terminal delivery: " + + clean(attempt.get("send_bracket_why"), 180) + + f"; socket outcome was {how} and may have accepted the notice, " + "so away will also be attempted" + ) + + queue_acknowledged = attempt.get("queue_acknowledged") is True + if not queue_acknowledged and how not in { + claude_socket.SENT_NO_RECEIPT, + claude_socket.CLOSED_AFTER_SEND, + }: + return False, f"{uuid[:8]}: {how}" + + # There is exactly one receipt of record on every promotion path: the + # target's own transcript. `delivered` from the socket means only that the + # session acknowledged queueing the frame; it earns this bounded wait but + # never bypasses it. The message UUID is present on every production shape + # that reached the queue or crossed the authenticated send boundary. + if not confirm_in_transcript( + uuid, + clean(attempt.get("message_uuid"), 120), + deadline=time.time() + confirm_seconds(), + ): + return False, ( + f"{uuid[:8]}: {how}, and it never appeared in the conversation " + "-- a queue acknowledgement is not counted as delivered" + ) + receipt_detail = f"confirmed in {name}'s own transcript ({how})" + + # Start this collection only after the transcript receipt exists. Its + # attachment sample therefore cannot predate the receipt, unlike a manager + # collection that began while the queue acknowledgement was still the only + # evidence. A provisional write already exists, so uncertainty here cannot + # become a success latch. + reconciled_verdict = presence(require_current=True) + reconciled_targets = ( + terminal_targets(reconciled_verdict) + if reconciled_verdict.get("present") + else [] + ) + if ( + len(reconciled_targets) != 1 + or not same_terminal_target(target, reconciled_targets[0]) + ): + return False, ( + "presence could not be proved through receipt confirmation: " + + clean(reconciled_verdict.get("why"), 180) + + f"; {receipt_detail}; away will also be attempted" + ) + return True, ( + f"{receipt_detail}; reconciled with the same attachment before send " + "and in a fresh read started after the transcript receipt" + ) + + +def route( + notice_class: str, + key: str, + subject: str, + body: str, + evidence: str = "", + repeat_after: float = DEFAULT_REPEAT_AFTER, + verdict: Mapping[str, Any] | None = None, + latch: dict[str, Any] | None = None, + record: bool = True, +) -> dict[str, Any]: + """Route one notice. Returns what happened; never raises. + + ``record=False`` sends without touching the latch. The sweep records even a + batch provisionally while its terminal handoff is being reconciled, then + removes that temporary batch entry and records the ITEMS the message was + about. A crash can therefore leave only a provisional entry, which + suppresses nothing and is discarded at the next sweep. + """ + notice_class = clean(notice_class, 40) or "notice" + key = clean(key, 200) + subject = clean(subject, 200) or "Session Kit" + body = clean(body, 1200, newlines=True) + evidence = clean(evidence, 200) + identity = f"{notice_class}:{key}" + + if not enabled(): + return {"routed": False, "route": "none", "why": "notices are switched off", + "class": notice_class, "key": key} + + owned_latch = record and latch is None + entries = (read_latch() if owned_latch else latch) if record else {} + if record and already_said(entries.get(identity), evidence, repeat_after): + return {"routed": False, "route": "latched", "why": "already said, same evidence", + "class": notice_class, "key": key} + + verdict = presence() if verdict is None else verdict + landed, detail, where = False, "", "none" + + terminal_authorized = bool(verdict.get("present")) + terminal_attempt: dict[str, Any] | None = None + if terminal_authorized: + terminal_attempt, detail = send_terminal(verdict, subject, body) + where = "terminal" + if terminal_attempt is not None: + provisional_recorded = True + if record: + entries[identity] = { + "evidence": evidence, + "at_unix": time.time(), + "route": "terminal", + "landed": False, + "reconciled": False, + "state": "provisional", + "detail": clean(detail, 300), + } + # The provisional state must be durable before confirmation, + # including a sweep batch whose final per-item records are + # written by its caller after this route returns. + provisional_recorded = write_latch(entries) + if provisional_recorded: + landed, detail = reconcile_terminal(terminal_attempt) + else: + landed = False + detail = ( + "terminal send remained provisional because its latch record " + "could not be written; away will also be attempted" + ) + if not landed: + # Fail SAFE. Not present, presence unknown, no reachable session, a + # refused socket: every one of them ends here, because a notice they see + # twice costs a glance and one they never see costs the night. + if ( + notice_class == CLASS_QUESTION + and not terminal_authorized + and not question_pushes_away() + ): + entries[identity] = { + "evidence": evidence, "at_unix": time.time(), + "route": "held", "landed": False, + "reconciled": True, "state": "reconciled", + "detail": "held: another window's question is not pushed automatically", + } + if owned_latch: + write_latch(entries) + log(f"HELD {identity}: {subject} -- a question is not pushed away from here") + return {"routed": False, "route": "held", "class": notice_class, "key": key, + "why": "another window's open question is not pushed automatically; " + "it stays in the question inbox with its own door", + "terminal_detail": detail} + away_landed, away_detail = send_away(notice_class, key, subject, body) + detail = f"terminal: {detail or 'not attempted'}; away: {away_detail}" + landed, where = away_landed, "away" + + if record: + entries[identity] = { + "evidence": evidence, + "at_unix": time.time(), + "route": where, + "landed": bool(landed), + "reconciled": True, + "state": "reconciled", + "detail": clean(detail, 300), + } + if owned_latch: + write_latch(entries) + log( + f"{'ROUTED' if landed else 'NOT ROUTED'} {identity} via {where}: " + f"{subject} -- {clean(detail, 200)}" + ) + return {"routed": bool(landed), "route": where, "class": notice_class, + "key": key, "why": detail} + + +def question_pushes_away() -> bool: + """Does another window's open question reach them when they are away? + + YES by default, and that default is a change worth naming. This shipped as + `hold`, on the reasoning that an automatic question push reverses a ruling + the operator made by name ("only when i ask"). Review's answer, and it is + the better one: a log line and a latch entry are not a delivery route, and + the ruling was made when the picker still carried the question -- that + resting place is being removed. A class with no route is exactly what this + branch exists to prevent. + + Two things make the push smaller than the one they objected to: it carries + that a decision is waiting and where, never the question or its options, + and it arrives inside the single coalesced message rather than as its own + buzz. `SESSION_KIT_NOTICE_QUESTION_AWAY=hold` restores the old ruling + exactly, in one word. + """ + return ( + os.environ.get("SESSION_KIT_NOTICE_QUESTION_AWAY") or "send" + ).strip().casefold() != "hold" + + +# --------------------------------------------------------------------------- +# the sweep: everything that lives in a durable record rather than a call +# --------------------------------------------------------------------------- + + +def watchdog_record_notices() -> list[dict[str, Any]]: + """The two watchdog outcomes the picker is losing: `failed` and `reported`. + + `reported` -- quiet, and nothing changed -- had no route off the picker at + all, and it is the big one: the estate's own file held fifty of them, all + `reported`, over 21 sessions, when this was written. `failed` had exactly + one other route, a board row somebody has to go and look at. + + `repaired` is not here on purpose. A repair that WORKED is not something a + person has to do anything about, and it keeps the route it already has. + + The key is the SESSION, not the record: one wedged session wrote five + records in a day, and five messages about one session is the flood this + whole file exists to avoid. + """ + path = Path( + os.environ.get("SESSION_KIT_WATCHDOG_REPAIRS") + or (state_dir() / "watchdog-repairs.json") + ) + document = read_json(path) + if not isinstance(document, Mapping): + return [] + notices: dict[str, dict[str, Any]] = {} + for item in document.get("repairs") or (): + if not isinstance(item, Mapping): + continue + outcome = clean(item.get("outcome"), 20) + if outcome not in {"reported", "failed"} or item.get("acknowledged"): + continue + session = clean(item.get("old_shpool_id"), 80) + if not session: + continue + title = clean(item.get("title"), 80) or "a session" + provider = (clean(item.get("provider"), 20) or "unknown").title() + stamp = item.get("at_unix_ms") + at_unix = ( + float(stamp) / 1000 + if isinstance(stamp, (int, float)) and not isinstance(stamp, bool) + else 0.0 + ) + reason = ( + clean(item.get("reason"), 200) + or "no output for far longer than any normal pause" + ) + if outcome == "failed": + notice = { + "class": "repair-failed", + "key": f"repair:{session}", + "evidence": "failed", + "line": f"{title} ({provider}), automatic repair FAILED", + "subject": f"Automatic repair failed: {title}", + "body": ( + f"{title} ({provider}) stopped working and the automatic repair " + f"did not succeed ({reason}). The conversation is saved." + ), + } + else: + notice = { + "class": "quiet-session", + "key": f"quiet:{session}", + # Strength, so an escalation from ordinary silence to the + # daemon's own handoff failure is a new thing to say. + "evidence": clean(item.get("evidence"), 60) or "silence-only", + "line": f"{title} ({provider}), quiet, nothing changed", + "subject": f"Session quiet, nothing changed: {title}", + "body": ( + f"{title} ({provider}) was reported quiet and nothing was changed: " + f"{reason}. Silence alone is never enough to act on, so no repair " + f"was attempted." + ), + } + notice["at_unix"] = at_unix + identity = f"{notice['class']}:{notice['key']}" + # Several records, one session. The STRONGEST evidence wins, and among + # equally strong ones the newest -- so a later ordinary-silence record + # can never bury the daemon-marker record that came before it. + previous = notices.get(identity) + if ( + previous is None + or is_new_statement(previous["evidence"], notice["evidence"]) + or ( + previous["evidence"] == notice["evidence"] + and at_unix >= previous["at_unix"] + ) + ): + notices[identity] = notice + return list(notices.values()) + + +def stall_notices() -> list[dict[str, str]]: + document = read_json(fleet_dir() / "stalls.json") + if not isinstance(document, Mapping): + return [] + generated = document.get("generated_at") + if not isinstance(generated, (int, float)) or isinstance(generated, bool): + return [] + if time.time() - float(generated) > FLEET_FLAG_MAX_AGE: + # The observer is not running. Its last opinion is not current fact. + return [] + said = { + "unanswered": "an answer is overdue", + "silent": "a response is overdue", + "orphan": "the session it belongs to is gone", + } + notices = [] + for item in document.get("stalled") or (): + if not isinstance(item, Mapping): + continue + key = clean(item.get("key"), 120) + if not key: + continue + reason = clean(item.get("reason"), 40).casefold() + told = said.get(reason, "attention is overdue") + since = item.get("since") + notices.append( + { + "class": "stall", + "key": f"stall:{key}", + "evidence": reason or "stalled", + "line": f"a stalled session, {told}", + "subject": "A session has stalled", + "body": ( + f"A session flagged as stalled: {told}. Nothing was changed. " + f"The session list has it when you are back." + ), + # A stall flag carries when the stall STARTED, which is what the + # age bound should read: the flag file is rewritten every minute. + "at_unix": ( + float(since) + if isinstance(since, (int, float)) and not isinstance(since, bool) + else time.time() + ), + } + ) + return notices + + +def question_notices() -> list[dict[str, str]]: + notices = [] + for path in sorted(glob.glob(str(fleet_dir() / "inbox" / "*.json")))[:200]: + record = read_json(Path(path), limit=65536) + if not isinstance(record, Mapping) or record.get("state") != "open": + continue + identifier = clean(record.get("id"), 80) or Path(path).stem + session = record.get("session") + who = clean( + (session or {}).get("title") if isinstance(session, Mapping) else "", 60 + ) or "another window" + header = clean(record.get("header"), 60) or "Decision" + asked = record.get("asked_at") + notices.append( + { + "class": CLASS_QUESTION, + "key": f"question:{identifier}", + "evidence": "open", + "line": f"{who} is waiting on a decision, {header}", + "subject": f"A window is waiting on a decision: {header}", + "body": f"{who} has an open question. It is in the question inbox.", + "at_unix": ( + float(asked) + if isinstance(asked, (int, float)) and not isinstance(asked, bool) + else time.time() + ), + } + ) + return notices + + +def max_record_age() -> float: + raw = os.environ.get("SESSION_KIT_NOTICE_MAX_AGE") or str(DEFAULT_MAX_AGE) + try: + return max(0.0, float(raw)) + except ValueError: + return float(DEFAULT_MAX_AGE) + + +def compose( + fresh: list[dict[str, Any]], older: list[dict[str, Any]] +) -> tuple[str, str]: + """One message for everything new in this pass. + + ONE MESSAGE, not one per item, and this is the whole design rather than a + nicety. Measured against the live estate: 50 records over 21 sessions, so a + notice-per-record router would have sent fifty messages in one night and a + notice-per-session one twenty-one. That is not a notification design, it is + a reason to turn notifications off. A person needs to know THAT something + wants looking at and roughly what; the list itself is a screen away. + + The age bound decides what gets NAMED, not what gets said: the recent ones + are listed, and anything older is counted in the same message. Age is not + resolution, so an old unresolved record is never dropped -- it is just not + what the five named lines are spent on. + """ + items = fresh + older + if len(items) == 1: + return items[0]["subject"], items[0]["body"] + counts: dict[str, int] = {} + for item in items: + counts[item["class"]] = counts.get(item["class"], 0) + 1 + said = { + "quiet-session": "quiet", + "repair-failed": "repair failed", + "stall": "stalled", + CLASS_QUESTION: "waiting on a decision", + } + parts = [ + f"{count} {said.get(name, name)}" + for name, count in sorted(counts.items(), key=lambda pair: -pair[1]) + ] + subject = f"{len(items)} sessions need a look: " + ", ".join(parts) + named = (fresh + older)[:NAMED_IN_A_BATCH] + lines = [f" · {item.get('line') or item['subject']}" for item in named] + body = "\n".join(lines) + rest = len(items) - len(named) + if rest: + body += f"\n · and {rest} more" + if older: + body += ( + f"\n\n{len(older)} of these have been unresolved for over " + f"{int(max_record_age() // 3600)}h." + ) + return subject, body + "\n\nNothing was changed. The session list has them all." + + +def sweep() -> dict[str, Any]: + """Route everything NEW in the durable records, in ONE message, and forget + what is gone. + + Three separate jobs, and the middle one is the interesting one: + + * decide what is new (the latch, per item); + * decide what is worth NAMING (the age bound). An older unresolved record + is still said -- it is counted in the message. It is NOT silently taken + into the latch and never sent, which is what this used to do: age is + not acknowledgement, and "we decided it was too old to mention" is the + silence-looks-like-success failure this whole branch exists to end. + * say it once (compose()). + + Pruning matters as much as routing: the latch's lifetime is the record's + lifetime, so a session that breaks again after its old record retires is + allowed to speak again instead of being silenced forever by bookkeeping + nobody can see. + """ + notices = watchdog_record_notices() + stall_notices() + question_notices() + entries = read_latch() + # A prior process may have stopped after sending a batch but before + # reconciliation. It is deliberately not a replay latch: the individual + # live records below still decide what must be said. + entries.pop("sessions:batch", None) + verdict = presence() + now = time.time() + bound = max_record_age() + fresh: list[dict[str, Any]] = [] + older: list[dict[str, Any]] = [] + latched = held = 0 + + for notice in notices: + identity = f"{notice['class']}:{notice['key']}" + if already_said( + entries.get(identity), + notice["evidence"], + 0, + source_at=float(notice.get("at_unix") or 0) or None, + ): + latched += 1 + continue + if notice["class"] == CLASS_QUESTION and not ( + verdict.get("present") or question_pushes_away() + ): + # Only when the operator has switched the push back off by name. + entries[identity] = { + "evidence": notice["evidence"], "at_unix": now, + "route": "held", "landed": False, + "reconciled": True, "state": "reconciled", + "detail": "held: SESSION_KIT_NOTICE_QUESTION_AWAY=hold", + } + log(f"HELD {identity}: {notice['subject']}") + held += 1 + continue + if bound and (now - float(notice.get("at_unix") or now)) > bound: + older.append(notice) + else: + fresh.append(notice) + + routed = False + detail = "" + speaking = fresh + older + if speaking: + subject, body = compose(fresh, older) + single = len(speaking) == 1 + result = route( + speaking[0]["class"] if single else "sessions", + speaking[0]["key"] if single else "batch", + subject, + body, + evidence=f"batch:{len(speaking)}", + repeat_after=0, + verdict=verdict, + latch=entries, + record=True, + ) + routed = bool(result.get("routed")) + detail = str(result.get("why") or "") + if not single: + entries.pop("sessions:batch", None) + # Each item carries its own latch entry whatever the batch did, so one + # message covering ten sessions is ten things said -- and a batch that + # did NOT land leaves ten entries marked as not landed, which is what + # brings them back on the next pass. + for notice in speaking: + entries[f"{notice['class']}:{notice['key']}"] = { + "evidence": notice["evidence"], "at_unix": now, + # What we spoke ABOUT, so the next pass can tell the same + # record from a newer one exactly rather than by a clock race. + "source_at": float(notice.get("at_unix") or 0), + "route": result.get("route") or "none", "landed": routed, + "reconciled": True, "state": "reconciled", + "detail": clean(detail, 200), + } + + live = {f"{notice['class']}:{notice['key']}" for notice in notices} + owned = ("quiet-session:", "repair-failed:", "stall:", f"{CLASS_QUESTION}:") + swept = { + identity: entry + for identity, entry in entries.items() + if not identity.startswith(owned) or identity in live + } + write_latch(swept) + return { + "seen": len(notices), + "new": len(speaking), + "messages": 1 if speaking else 0, + "routed": len(speaking) if routed else 0, + "held": held, + "latched": latched, + "older": len(older), + # ONE key, so a caller testing for "nothing happened" cannot be broken + # by JSON key ordering. The watchdog's suppression test read two keys in + # an order sort_keys never produces, so every idle pass logged a line + # that was supposed to be suppressed. + "quiet": not speaking and not held, + "away_transport": "set" if away_command() else "UNSET", + "why": detail if fresh and not routed else "", + "presence": verdict.get("why"), + "forgotten": len(entries) - len(swept), + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="session_kit_notice", description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("presence", help="print the presence verdict; send nothing") + one = commands.add_parser("route", help="route one notice") + one.add_argument("--class", dest="notice_class", required=True) + one.add_argument("--key", required=True) + one.add_argument("--subject", required=True) + one.add_argument("--body", default="") + one.add_argument("--evidence", default="") + one.add_argument("--repeat-after", type=float, default=DEFAULT_REPEAT_AFTER) + commands.add_parser("sweep", help="route what is new in the durable records") + arguments = parser.parse_args(argv) + + if arguments.command == "presence": + print(json.dumps(presence(), sort_keys=True)) + return 0 + if arguments.command == "sweep": + if not enabled(): + print(json.dumps({"seen": 0, "routed": 0, "quiet": True, + "why": "notices are switched off"})) + return 0 + answer = sweep() + print(json.dumps(answer, sort_keys=True)) + # A sweep that had something to say and reached nobody is a FAILURE, + # and it used to exit 0 -- so an unwired away transport looked exactly + # like a healthy quiet night to systemd, to the watchdog, and to anyone + # reading a status. Silence has to be distinguishable from success. + if answer.get("messages") and not answer.get("routed"): + return 1 + return 0 + result = route( + arguments.notice_class, + arguments.key, + arguments.subject, + arguments.body, + evidence=arguments.evidence, + repeat_after=arguments.repeat_after, + ) + print(json.dumps(result, sort_keys=True)) + # Latched is not a failure: it means the thing was already said. + return 0 if result.get("routed") or result.get("route") in {"latched", "held"} else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bin/session_kit_watchdog b/bin/session_kit_watchdog index bb67476..b96feb9 100755 --- a/bin/session_kit_watchdog +++ b/bin/session_kit_watchdog @@ -8,12 +8,13 @@ # The session can never be attached again, nothing drains its terminal, # and whatever runs inside it blocks on its next write, forever. # 2. A session stays wired to an SSH window that no longer exists. The -# terminal fills, the program blocks, and the session list still says +# terminal fills, the program blocks, and the picker still says # "running". # -# Both look identical from outside: a session that claims to be running and -# has produced nothing for hours. This watchdog finds them, proves they are -# dead rather than merely busy, and moves the conversation to a fresh session. +# Both can look identical from outside: a session that claims to be running and +# has produced nothing for hours. This watchdog reports the evidence by default. +# Its explicit Linux-only repair mode can move a directly proven conversation +# to a fresh session. # # It is read-only until every proof holds, and it never touches a session that # is simply quiet for a while. @@ -24,11 +25,6 @@ SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) # shellcheck source=session_kit_common source "$SCRIPT_DIR/session_kit_common" -if [[ $(sk_platform) == Darwin ]]; then - sk_die "watchdog repair is unavailable in the macOS preview; nothing changed" - exit 1 -fi - SP_CMD=${SESSION_KIT_SP_CMD:-"$SCRIPT_DIR/sp"} STATUS_CMD=${SESSION_KIT_STATUS_CMD:-"$SCRIPT_DIR/shpool_status"} STATE_ROOT=${XDG_STATE_HOME:-"$HOME/.local/state"} @@ -36,6 +32,9 @@ LOG_FILE=${SESSION_KIT_WATCHDOG_LOG:-"$STATE_ROOT/session-kit/watchdog.log"} REPAIR_FILE=${SESSION_KIT_WATCHDOG_REPAIRS:-"$STATE_ROOT/session-kit/watchdog-repairs.json"} SENTINEL=${SESSION_KIT_WATCHDOG_SENTINEL:-"$HOME/.no_shpool_watchdog"} NOTIFY_CMD=${SESSION_KIT_WATCHDOG_NOTIFY:-} +# Where a notice goes when it is not a picker row: the terminal the operator is +# sitting in, or the away transport when they are not. See bin/session_kit_notice. +NOTICE_CMD=${SESSION_KIT_NOTICE_CMD:-"$SCRIPT_DIR/session_kit_notice"} ALERT_TYPE=${SESSION_KIT_WATCHDOG_ALERT_TYPE:-session-kit.watchdog} WATCHDOG_MODE=${SESSION_KIT_WATCHDOG_MODE:-report} JOURNAL_UNIT=${SESSION_KIT_WATCHDOG_UNIT:-shpool} @@ -48,19 +47,65 @@ DEAD_QUIET_SECONDS=${SESSION_KIT_WATCHDOG_DEAD_SECONDS:-2700} # How long a quiet-but-unproven session stays reported before being mentioned # again. QUIET_REPORT_COOLDOWN_SECONDS=${SESSION_KIT_WATCHDOG_QUIET_REPORT_COOLDOWN:-21600} +# How long a record about a session that no longer exists stays readable before +# it retires itself. Two days: long enough that a person who was away for a day +# still sees what happened, short enough that the review list is about the +# estate as it is now. 0, or SESSION_KIT_WATCHDOG_RETIRE=off, disables it. +RETIRE_DAYS=${SESSION_KIT_WATCHDOG_RETIRE_DAYS:-2} +# How long a condition stays quiet after an alert that was NOT delivered. Short +# enough that a notifier down for a minute costs one retry, long enough that a +# notifier nobody has configured cannot fill the log. +QUIET_RETRY_SECONDS=${SESSION_KIT_WATCHDOG_QUIET_RETRY:-900} # Seconds allowed for the read-only "is the manager alive" question. MANAGER_PROBE_TIMEOUT=${SESSION_KIT_WATCHDOG_MANAGER_TIMEOUT:-20} +# A snapshot older than this while a kit state lock has waiters is a jam, not +# load: collections run in seconds and retry constantly, so five quiet minutes +# with a queue means the queue is not moving. +STALE_SNAPSHOT_SECONDS=${SESSION_KIT_WATCHDOG_STALE_SNAPSHOT_SECONDS:-300} # sha256 of the session-manager binary this machine is supposed to run. Absent # means the check is skipped, so nothing breaks if it was never recorded. BINARY_FINGERPRINT=${SESSION_KIT_WATCHDOG_BINARY_FINGERPRINT:-"$STATE_ROOT/session-kit/shpool-binary.sha256"} REPORT_STATE=${SESSION_KIT_WATCHDOG_REPORT_STATE:-"$STATE_ROOT/session-kit/watchdog-reported.json"} +# The account handoff rule's own kill switch, shared with the manual switch and +# with enrolment. Its presence stops every automatic move; nothing here creates +# it, because it also disables the operator's own manual switch and taking that +# away automatically would remove their escape hatch at the worst moment. +# Both of these live beside the account state itself, so they resolve to the +# same directory the rest of the kit uses. Deriving them from $STATE_ROOT +# instead was wrong: SESSION_KIT_STATE_DIR moves the account registry, the +# ledger and this sentinel together, and a kill switch that resolves somewhere +# else is a kill switch that silently does nothing. +ACCOUNT_SWITCH_OFF=${SESSION_KIT_ACCOUNT_SWITCH_OFF:-"$SK_STATE_DIR/account-switching-off"} +# Accounts move over hours, not seconds. Checking every minute would re-read +# the usage feed sixty times for every window that can possibly have changed. +ACCOUNT_GUARD_SECONDS=${SESSION_KIT_ACCOUNT_GUARD_SECONDS:-300} +ACCOUNT_GUARD_LAST=0 +ACCOUNT_GUARD_LOCK=${SESSION_KIT_ACCOUNT_GUARD_LOCK:-"$SK_STATE_DIR/account-guard.lock"} +ACCOUNT_GUARD_FD= ONCE=0 +watchdog_usage() { + cat <<'EOF' +usage: session_kit_watchdog [--once] + + (no mode) run continuously, checking on the interval its service sets + --once run one check and exit; this is what the installed service calls + +SESSION_KIT_WATCHDOG_MODE selects report (the default, evidence only) or the +Linux-only repair mode. Statuses: 0 done, 1 refused or failed, 2 wrong +arguments. `sp help exit-codes` documents them all. +EOF +} + case "${1:-}" in "") ;; --once) ONCE=1 ;; + -h | --help | help) + watchdog_usage + exit 0 + ;; *) - echo "usage: session_kit_watchdog [--once]" >&2 + watchdog_usage >&2 exit 2 ;; esac @@ -68,6 +113,10 @@ esac echo "session-kit: SESSION_KIT_WATCHDOG_MODE must be report or repair" >&2 exit 2 } +if [[ $(sk_platform) == Darwin && $WATCHDOG_MODE == repair ]]; then + sk_die "watchdog repair requires Linux daemon-thread evidence; use report mode on macOS" + exit 1 +fi sk_require_shpool || exit 1 sk_prepare_state || exit 1 @@ -77,19 +126,23 @@ umask 077 chmod 600 "$LOG_FILE" 2>/dev/null || true log() { - printf '%s %s\n' "$(date -Is)" "$1" >> "$LOG_FILE" + printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$1" >> "$LOG_FILE" } # Records every repair so the picker can tell the human what happened even if # all notification paths are down. record_repair() { - python3 - "$REPAIR_FILE" "$1" "$2" "$3" "$4" "$5" "$6" <<'PY' + python3 - "$REPAIR_FILE" "$1" "$2" "$3" "$4" "$5" "$6" "${7:-}" <<'PY' import json import os import sys import time path, old_id, new_id, title, provider, outcome, reason = sys.argv[1:8] +# What the sweep KNEW when it wrote this, not just what it said. The latch +# compares evidence, so a record that cannot name its own evidence must never +# be read as having had the strong kind. +evidence = sys.argv[8] if len(sys.argv) > 8 else "" try: with open(path, encoding="utf-8") as handle: data = json.load(handle) @@ -107,6 +160,7 @@ entries.append( "provider": provider, "outcome": outcome, "reason": reason, + "evidence": evidence, "acknowledged": False, } ) @@ -121,56 +175,347 @@ os.replace(tmp, path) PY } +# Retire records whose session no longer exists. +# +# Nothing ever removed a record: acknowledging one only flipped a flag, and the +# only bound was a 50-slot cap that dropped the OLDEST -- so the list the picker +# offered filled up with sessions that had not existed for a day, and the +# operator was asked to clear them one at a time with `d<number>`. Thirty-eight +# of their fifty named ids that were already gone. +# +# A record retires when BOTH hold: its session is absent from a snapshot that +# proved itself live, and it is older than the retention window. Both, because +# either alone is unsafe -- a snapshot that momentarily cannot see a session +# would erase a live warning, and age alone would erase a warning about a +# session still sitting there broken. A repaired session's record names the OLD +# id, which is gone by definition, so age is what keeps it readable afterwards. +# +# Never touched: a record whose session is still live, a record inside the +# window, and anything at all when the snapshot is not live. Own-uid, +# regular-file, no symlink followed. SESSION_KIT_WATCHDOG_RETIRE=off, or +# SESSION_KIT_WATCHDOG_RETIRE_DAYS=0, disables it; a dry run reports and writes +# nothing. +retire_repairs() { + local snapshot=$1 dry=${2:-0} + [[ ${SESSION_KIT_WATCHDOG_RETIRE:-on} != off ]] || return 0 + python3 - "$REPAIR_FILE" "$snapshot" "$RETIRE_DAYS" "$dry" \ + "${SESSION_KIT_STATE_DIR:-$STATE_ROOT/session-kit}/inventory.json" <<'PY' +import json +import os +import stat +import sys +import time + +path, snapshot_path, days_raw, dry_raw = sys.argv[1:5] +published_path = sys.argv[5] if len(sys.argv) > 5 else "" +dry = dry_raw == "1" +try: + days = float(days_raw) +except ValueError: + days = 2.0 +if days <= 0: + sys.exit(0) + +# No live snapshot, no retirement. A sweep that cannot see the estate must +# never be the thing that erases what the estate was warned about. +try: + with open(snapshot_path, encoding="utf-8") as handle: + inventory = json.load(handle) +except (OSError, ValueError): + sys.exit(0) +if inventory.get("source") != "live" or inventory.get("stale") is not False: + sys.exit(0) +def names(document): + rows = document.get("sessions") if isinstance(document, dict) else None + return { + str(row.get("shpool_id")) + for row in (rows or []) + if isinstance(row, dict) and row.get("shpool_id") + } + + +live = names(inventory) +# A snapshot proves what existed when it was captured; it cannot prove that no +# restore finished afterwards. The published inventory is what a restore +# updates, so read it too and treat the union as live -- a record retired +# against a stale view is a warning erased about a session that came back +# (found in review, 2026-08-15). +if published_path: + try: + with open(published_path, encoding="utf-8") as handle: + live |= names(json.load(handle)) + except (OSError, ValueError): + pass + +flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) +try: + descriptor = os.open(path, flags) +except OSError: + sys.exit(0) +try: + info = os.fstat(descriptor) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_uid != os.geteuid() + or info.st_size > 1024 * 1024 + ): + sys.exit(0) + with os.fdopen(descriptor, encoding="utf-8") as handle: + data = json.load(handle) +except (OSError, ValueError): + sys.exit(0) +entries = data.get("repairs") if isinstance(data, dict) else None +if not isinstance(entries, list): + sys.exit(0) + +cutoff_ms = (time.time() - days * 86400) * 1000 +kept = [] +retired = [] +for item in entries: + if not isinstance(item, dict): + continue + stamp = item.get("at_unix_ms") + old = str(item.get("old_shpool_id") or "") + # A repaired record names its REPLACEMENT, and that replacement is the + # session still carrying the conversation. Retiring on the old id alone + # deleted records whose live successor was right there in the snapshot. + replacement = str(item.get("new_shpool_id") or "") + aged = isinstance(stamp, (int, float)) and not isinstance(stamp, bool) and stamp < cutoff_ms + if old and old not in live and replacement not in live and aged: + retired.append(old) + continue + kept.append(item) +if not retired: + sys.exit(0) +print(f"retired {len(retired)} record(s) for sessions that no longer exist", end="") +print("" if not dry else " (dry run, nothing written)") +if dry: + sys.exit(0) +tmp = f"{path}.tmp.{os.getpid()}" +descriptor = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) +with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump({"schema_version": 1, "repairs": kept[-50:]}, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) +os.replace(tmp, path) +PY +} + +# One record per session per incident. +# +# The 6-hour cooldown was a rate limit, not a latch: a session that stayed +# quiet earned a FRESH record every six hours, which is how twenty-one distinct +# sessions became fifty records, one of them listed five times. A second record +# for a session already on the list tells a person nothing new; it only +# inflates a count (operator ruling, 2026-08-15). +# +# Same incident, or recovered and broke again? The session's own output answers +# it. A record was written at time T. If the session has produced output SINCE +# T, it recovered -- whatever went quiet afterwards is a new incident and earns +# its own record. If its last output still predates T, this is the same +# unbroken silence and the record already on the list is the whole story. +# `recent_output_age_seconds` is the same field the detection uses, so the +# latch can never disagree with what put the session on the list. +# +# Unknown output age suppresses: a duplicate is worse than a late record, and +# an unacknowledged record is already saying this session needs looking at. +# Acknowledged and repaired records never latch -- a person who cleared the row +# has said they are done with it, so the next incident is allowed to speak. +already_recorded_incident() { + local wanted=$1 age=$2 evidence=${3:-} + python3 - "$REPAIR_FILE" "$wanted" "$age" "$evidence" <<'INNERPY' +import json +import sys +import time + +path, wanted, age_raw = sys.argv[1:4] +incoming = sys.argv[4] if len(sys.argv) > 4 else "" + +# One record per incident AND per statement. Time alone cannot tell "same +# silence, same story" from "same silence, and we have since learned why": once +# a session had any unacknowledged `reported` row, the DAEMON-MARKER verdict -- +# the strongest per-session evidence the watchdog has, and the whole reason it +# exists -- was suppressed for that session. A wedged terminal never stops +# being quiet and retirement only clears records whose session is GONE, so that +# was in practice forever, and the operator's list said "reported quiet, no +# repair attempted" about a terminal the daemon had already proven dead (found +# in review, 2026-08-15). +# +# Strength, not novelty: a repeat of the SAME statement is still one record, +# but anything stronger is a new thing to say and gets its own row. +# +# A record written before this field existed reads as SILENCE-ONLY, not as +# nothing. Reading it as nothing would let an ordinary silence report escalate +# past it, and every one of the fifty rows already on the operator's disk would +# duplicate itself on the next sweep -- the exact defect the latch was added to +# stop. Unknown means "the weak kind", which is what those rows all were. +STRENGTH = {"silence-only": 1, "daemon-marker": 2} +incoming_strength = STRENGTH.get(incoming, 1) +try: + with open(path, encoding="utf-8") as handle: + entries = json.load(handle)["repairs"] + if not isinstance(entries, list): + raise ValueError +except (OSError, ValueError, KeyError, TypeError): + raise SystemExit(1) + +try: + age = float(age_raw) +except ValueError: + age = -1.0 +last_output_ms = None if age < 0 else (time.time() - age) * 1000 + +for item in entries: + if not isinstance(item, dict): + continue + if item.get("old_shpool_id") != wanted or item.get("acknowledged"): + continue + if item.get("outcome") == "repaired": + continue + stamp = item.get("at_unix_ms") + if not isinstance(stamp, (int, float)) or isinstance(stamp, bool): + continue + if STRENGTH.get(str(item.get("evidence") or ""), 1) < incoming_strength: + # We have learned something stronger about this session than anything + # already on the list. Say it. + continue + if last_output_ms is None or last_output_ms <= stamp: + raise SystemExit(0) +raise SystemExit(1) +INNERPY +} + # Always "warning": an automatic repair, successful or not, must never fire a # critical and wake a phone. The picker banner is the guaranteed record. notify() { - local subject=$1 body=$2 + local subject=$1 body=$2 alert_type=${3:-$ALERT_TYPE} + # An unset notifier is a configuration gap, not a broken path. Reporting it + # as "'' is not executable" reads like a permissions problem and hides the + # fact that NOTHING is wired up: on 2026-08-06 the watchdog correctly + # detected a daemon-wide freeze and the alert went nowhere, because + # SESSION_KIT_WATCHDOG_NOTIFY had never been set. Name the two cases apart so + # the log says which one it is. + # Non-zero means nothing was reached. Callers that record a condition as + # reported depend on this: a notifier that always returned success is how a + # dropped alert came to be stamped as delivered and suppressed for six hours + # (it happened on 2026-08-06, and the state file said the operator was told). + if [[ -z $NOTIFY_CMD ]]; then + log "notify skipped: SESSION_KIT_WATCHDOG_NOTIFY is not set, so no alert is delivered anywhere" + return 1 + fi if [[ ! -x $NOTIFY_CMD ]]; then log "notify skipped: $NOTIFY_CMD is not executable" - return 0 + return 1 fi - "$NOTIFY_CMD" \ - "--type=$ALERT_TYPE" \ + if ! "$NOTIFY_CMD" \ + "--type=$alert_type" \ "--severity=warning" \ "--title=$subject" \ - "--body=$body" >/dev/null 2>&1 || - log "notify not delivered for $ALERT_TYPE (hub down or non-critical drop)" + "--body=$body" >/dev/null 2>&1; then + log "notify not delivered for $alert_type (hub down or non-critical drop)" + return 1 + fi } -# Sessions the daemon has reported a terminal-handoff failure for. Only these -# lines carry the session name; the accompanying ERROR line does not, so it is -# deliberately not used as a trigger. -flagged_sessions() { +# Timestamped attach outcomes from the current journal window. The daemon's +# failure line carries the session name, and a later successful attach for that +# same name cancels it. Keep PID and monotonic timestamps so old daemon +# generations can never be blamed on the current process. +flagged_events() { local since=$1 - journalctl --user -u "$JOURNAL_UNIT" --since "$since" -o cat --no-pager 2>/dev/null | - grep -F 'failed to tell shell->client to disconnect' | - grep -oE 's="[^"]+"' | - sed 's/^s="//; s/"$//' | - sort -u + [[ $(sk_platform) != Darwin ]] || return 0 + journalctl --user -u "$JOURNAL_UNIT" --since "$since" -o json --no-pager 2>/dev/null | + python3 -c ' +import json +import re +import sys + +events = [] +for raw in sys.stdin: + try: + row = json.loads(raw) + message = str(row.get("MESSAGE", "")) + pid = int(row.get("_PID", 0)) + monotonic_us = int(row.get("__MONOTONIC_TIMESTAMP", 0)) + realtime_us = int(row.get("__REALTIME_TIMESTAMP", 0)) + except (TypeError, ValueError, json.JSONDecodeError): + continue + match = re.search(r"s=\"([^\"]+)\"", message) + if not match or pid <= 0 or monotonic_us <= 0 or realtime_us <= 0: + continue + if "failed to tell shell->client to disconnect" in message: + kind = "failure" + elif ( + "initial_attach_lock(shell_to_client_ctl): client connection status=" + in message + ): + kind = "success" + else: + continue + events.append( + { + "kind": kind, + "monotonic_us": monotonic_us, + "pid": pid, + "realtime_us": realtime_us, + "session": match.group(1), + } + ) +print(json.dumps(events, separators=(",", ":"))) +' } -# Prints one JSON verdict per candidate session that is proven dead. -# Proofs: still present; claims to be running; silent past its threshold; and -# either the daemon has fewer terminal-serving threads than sessions, or the -# session was flagged, or the silence is past the long no-marker threshold. +# Prints one JSON verdict per candidate session plus any manager-level warning. +# A current-generation, sufficiently aged failure marker is direct evidence; +# silence alone is only a report, and a global thread-count mismatch is never +# attributed to one session. dead_sessions() { - local flagged=$1 snapshot status=0 - snapshot=$(mktemp "$SK_STATE_DIR/watchdog-snapshot.XXXXXX.json") || return 3 + local events=$1 snapshot status=0 + snapshot=$(mktemp "$SK_STATE_DIR/watchdog-snapshot.json.XXXXXX") || return 3 if ! "$STATUS_CMD" --json > "$snapshot" 2>/dev/null; then command rm -f -- "$snapshot" return 3 fi - python3 - "$snapshot" "$flagged" \ + # Retirement runs FIRST, before a single verdict leaves this function. + # It was at the end, after the verdict stream had already started: the + # caller reads that stream through a process substitution and calls + # record_repair as verdicts arrive, so two read-modify-replace writers were + # live on the same file at once and either could clobber the other (found in + # review, 2026-08-15). Running it here serializes them with no lock: nothing + # can be recorded until retirement has finished. Best-effort either way -- + # retiring stale bookkeeping must never fail the sweep that finds real + # faults. + local retired + retired=$(retire_repairs "$snapshot" "${SESSION_KIT_WATCHDOG_RETIRE_DRY_RUN:-0}" 2>/dev/null) || retired="" + [[ -z $retired ]] || log "$retired" + python3 - "$snapshot" "$events" \ "$FLAGGED_QUIET_SECONDS" "$DEAD_QUIET_SECONDS" <<'PY' import json import os import sys +import time -snapshot_path, flagged_raw, flagged_quiet_raw, dead_quiet_raw = sys.argv[1:5] -flagged = {name for name in flagged_raw.split() if name} +snapshot_path, events_raw, flagged_quiet_raw, dead_quiet_raw = sys.argv[1:5] flagged_quiet = int(flagged_quiet_raw) dead_quiet = int(dead_quiet_raw) +# The provider's own words for "the turn's computation is finished and only a +# person advances it". Deliberately the SAME three the provider bounce already +# trusts for this exact judgement (lib/sh/sp_provider_bounce.sh), so the estate +# has one definition of a waiting session rather than two that can drift. +WAITING_STATES = frozenset({"idle", "needs your reply", "reply optional"}) +# `needs_you` is NOT purely a turn-state field. The snapshot publisher folds +# fleet stall flags into it (lib/session_inventory.py publish_view_fields, and +# SNAPSHOT.md "Fleet stalls fold into needs_you"): a session another observer +# believes is stuck comes back with needs_you true and its reason listed. Those +# reasons are FAULTS, not a person's turn, so a genuinely wedged session was +# being excluded from detection precisely because something else had already +# noticed it was stuck (found in review, 2026-08-15). A live fault also beats a +# provider status word, which can be frozen at whatever the conversation last +# said before the terminal died. +FLEET_FAULT_REASONS = frozenset({"unsurfaced", "unanswered", "silent"}) + try: with open(snapshot_path, encoding="utf-8") as handle: inventory = json.load(handle) @@ -181,9 +526,51 @@ if inventory.get("source") != "live" or inventory.get("stale") is not False: daemon = inventory.get("daemon_generation") or {} daemon_pid = daemon.get("pid") -if not isinstance(daemon_pid, int) or daemon_pid <= 0: +daemon_start_ticks = daemon.get("process_start_ticks") +if ( + not isinstance(daemon_pid, int) + or daemon_pid <= 0 + or not isinstance(daemon_start_ticks, int) + or daemon_start_ticks <= 0 +): sys.exit(3) +try: + events = json.loads(events_raw or "[]") +except (TypeError, ValueError): + events = [] +if not isinstance(events, list): + events = [] + +# Linux process start ticks and journal monotonic timestamps share the same +# boot-relative clock. This excludes matching text from an earlier daemon even +# if the kernel later reuses its PID. +clock_ticks = os.sysconf("SC_CLK_TCK") +daemon_start_us = daemon_start_ticks * 1_000_000 // clock_ticks +latest_event = {} +now_us = int(time.time() * 1_000_000) +for event in events: + if not isinstance(event, dict) or event.get("pid") != daemon_pid: + continue + monotonic_us = event.get("monotonic_us") + realtime_us = event.get("realtime_us") + name = event.get("session") + kind = event.get("kind") + if ( + not isinstance(monotonic_us, int) + or monotonic_us < daemon_start_us + or not isinstance(realtime_us, int) + or realtime_us <= 0 + or not isinstance(name, str) + or kind not in {"failure", "success"} + ): + continue + previous = latest_event.get(name) + if previous is None or monotonic_us > previous["monotonic_us"]: + latest_event[name] = event + +sessions = list(inventory.get("sessions", ())) + # Count the daemon threads that serve session terminals. One per session is # healthy; fewer means at least one session has no thread left to serve it. # The override exists so the count can be fixed in tests and inspected by @@ -191,6 +578,8 @@ if not isinstance(daemon_pid, int) or daemon_pid <= 0: override = os.environ.get("SESSION_KIT_WATCHDOG_SERVING_THREADS", "") if override.isdigit(): serving = int(override) +elif sys.platform == "darwin": + serving = len(sessions) else: serving = 0 try: @@ -204,9 +593,23 @@ else: except OSError: sys.exit(3) -sessions = list(inventory.get("sessions", ())) missing_thread = serving < len(sessions) +# A process-wide count cannot identify which session lost its serving thread. +# Emit one manager-level warning and never attribute or repair a specific +# session from this count alone. +if missing_thread: + print( + json.dumps( + { + "evidence": "manager-missing-thread", + "serving_threads": serving, + "sessions": len(sessions), + }, + sort_keys=True, + ) + ) + for row in sessions: shpool_id = row.get("shpool_id") provider = row.get("provider") @@ -216,25 +619,60 @@ for row in sessions: continue if row.get("mutation_allowed") is not True: continue - if isinstance(age, bool) or not isinstance(age, int): - continue # Only a detached session can be probed safely. A session that shows as # attached may have a live human on it, and forcing it off to run a test # would be exactly the damage this watchdog exists to prevent. if row.get("availability") != "ready": continue - was_flagged = shpool_id in flagged - # Only the first two are evidence that the terminal is actually dead. The - # third is silence, which proves nothing on its own and is reported only. - if was_flagged and age >= flagged_quiet: + event = latest_event.get(shpool_id) + marker_age = None + if event is not None and event["kind"] == "failure": + marker_age = max(0, (now_us - event["realtime_us"]) // 1_000_000) + was_flagged = marker_age is not None and marker_age >= flagged_quiet + age_known = isinstance(age, int) and not isinstance(age, bool) + # A sufficiently aged failure from this exact daemon generation is direct + # evidence. Unknown output age is safe to report but not safe to repair. + # Quiet because it is WAITING, not because it is wedged. The snapshot this + # sweep already fetched carries the provider's own answer to that question, + # and the watchdog was not reading it: `needs_you` is set from the + # conversation's turn state (the collector sets it when the turn state is + # "needs your reply"), and `agent_status` is the provider's own state word. + # A session parked at its prompt for a person is in its NORMAL resting + # state -- a research session can sit there for days -- and 45 minutes of + # journal silence is exactly what that looks like from the outside. + # + # Reporting it was not a harmless extra. Fifty records, every one + # outcome=reported, twelve of them written about sessions that were alive + # minutes earlier and merely waiting, is what the operator's picker was + # counting as "50 repair failures" (2026-08-15). Silence plus "it is your + # turn" is not evidence of a fault, so it is not a candidate at all. + # + # SILENCE-ONLY PATH ONLY. The daemon-marker branch rests on the daemon's + # own handoff-failure line for this exact session and this exact + # generation; a genuinely wedged terminal is still caught there whatever + # the conversation's turn state says, which is why this test sits after it. + raw_reasons = row.get("needs_you_reasons") + fleet_fault = bool( + FLEET_FAULT_REASONS + & { + str(reason).strip().casefold() + for reason in (raw_reasons if isinstance(raw_reasons, list) else ()) + } + ) + waiting_for_a_human = not fleet_fault and ( + row.get("needs_you") is True + or str(row.get("agent_status") or "").strip().casefold() in WAITING_STATES + ) + if was_flagged: evidence = "daemon-marker" reason = "the daemon logged a terminal handoff failure for this session" - elif missing_thread and age >= flagged_quiet: - evidence = "missing-thread" - reason = "the daemon has no thread left to serve this terminal" - elif age >= dead_quiet: + repair_allowed = age_known and age >= flagged_quiet + elif waiting_for_a_human: + continue + elif age_known and age >= dead_quiet: evidence = "silence-only" reason = "no output for far longer than any normal pause" + repair_allowed = False else: continue print( @@ -247,8 +685,10 @@ for row in sessions: "serving_threads": serving, "sessions": len(sessions), "flagged": was_flagged, + "marker_age_seconds": marker_age, "evidence": evidence, "reason": reason, + "repair_allowed": repair_allowed, }, sort_keys=True, ) @@ -273,11 +713,14 @@ json_field() { # the login picker went blind, and the only way out was restarting the daemon, # which ends every managed session. A watchdog must never be able to do that. # -# So confirmation is read-only. Two passive signals are precise enough, and -# both were present in every wedge observed: +# So confirmation is read-only. A current-daemon handoff failure that has not +# been followed by a successful attach is direct per-session evidence: # -# * the daemon logged a terminal handoff failure naming the session, or -# * the daemon has fewer terminal-serving threads than it has sessions. +# * the daemon logged a terminal handoff failure naming the session. +# +# A daemon-wide serving-thread count mismatch identifies manager trouble but +# cannot identify which individual session is affected. It is reported once +# and never used for an automatic repair. # # Silence alone is never sufficient, because it carries no information: Codex # reports "running" for its entire life whether it is working or waiting at its @@ -288,38 +731,70 @@ passive_evidence_only=1 repair_session() { local verdict=$1 id title provider reason new_id + local diagnosis status=0 retry_line recovery_hint id=$(json_field "$verdict" shpool_id) title=$(json_field "$verdict" title) provider=$(json_field "$verdict" provider) reason=$(json_field "$verdict" reason) [[ -n $id ]] || return 0 log "repairing $id ($provider: $title): $reason -- $verdict" - new_id=$("$SP_CMD" repair "$id" 2>&1 | tail -n 1) + # `sp repair` says whether it worked with its exit status; the new session ID + # is what it prints on success. Reading success off the shape of the last + # line called a restored session failed whenever `sp` printed a closing note + # after the ID, and would have called a failure a success if any diagnostic + # happened to start the same way. Keep the streams apart: stdout is the + # answer, stderr is the diagnosis. + local diagnosis_file + diagnosis_file=$(mktemp "$SK_STATE_DIR/watchdog-repair.XXXXXX") || return 0 + # Repair preserves the original session's ownership. A watchdog may start a + # new drill of its own as a machine session, but fixing a person's session + # must never hide the replacement from the list that person opens. + new_id=$("$SP_CMD" repair "$id" 2>"$diagnosis_file") || status=$? + diagnosis=$(tr '\n' ' ' < "$diagnosis_file") + command rm -f -- "$diagnosis_file" + new_id=${new_id##*$'\n'} # A loaded daemon can miss the inventory timeout, and the mutation guard then # correctly refuses to act on stale data. That is a blind spot, not a failed # repair: say so in the log and try again next sweep rather than recording a # failure and announcing one. - case "$new_id" in + case "$diagnosis$new_id" in *"snapshot unavailable"*|*"stale"*|*"guard live inventory unavailable"*) - log "repair deferred for $id: live inventory unavailable ($new_id)" + log "repair deferred for $id: live inventory unavailable ($diagnosis$new_id)" return 0 ;; esac - if [[ $new_id == s2* ]]; then + if (( status == 0 )) && [[ $new_id == s2* ]]; then log "repaired $id -> $new_id" - record_repair "$id" "$new_id" "$title" "$provider" repaired "$reason" || true - notify "Session recovered: ${title:-$id}" \ - "The terminal for \"${title:-$id}\" stopped working, so the session could not be opened and its work had stopped ($reason). The conversation was moved to a new session and is running again. Nothing else changed." + record_repair "$id" "$new_id" "$title" "$provider" repaired "$reason" daemon-marker || + log "repair record could not be written for $id -> $new_id" + notify "Session restored: ${title:-$id}" \ + "\"${title:-$id}\" stopped working, so it could not be opened and its work had stopped ($reason). The conversation moved to a new session and is running again." || + true else - log "repair FAILED for $id: $new_id" - record_repair "$id" "" "$title" "$provider" failed "$reason" || true - notify "Session could not be recovered: ${title:-$id}" \ - "The terminal for \"${title:-$id}\" stopped working and the automatic repair did not succeed. The conversation is still saved -- open the session list to recover it by hand." + log "repair FAILED for $id (status $status): ${diagnosis:-$new_id}" + record_repair "$id" "" "$title" "$provider" failed "$reason" daemon-marker || + log "repair record could not be written for the failed repair of $id" + # The original session is closed by the time most repairs fail, so "open + # the picker" is not a route back. When `sp` printed the exact restore + # command, carry it: it is the one thing that does work. + recovery_hint="The conversation is saved. Open the picker to restore it." + retry_line=$(grep -o 'sp restore-exact [^ ]* [^ ]* [^ ]*' <<<"$diagnosis" | head -n 1) + if [[ -n $retry_line ]]; then + recovery_hint="The session is already closed, so the picker has nothing to open. The conversation is saved. Restore it with: $retry_line" + fi + notify "Session could not be restored: ${title:-$id}" \ + "\"${title:-$id}\" stopped working and the automatic repair did not succeed. $recovery_hint" || + true fi } -# Remembers which sessions have already been reported as quiet, so a session -# parked for days produces one report rather than one a minute. +# The time-based cooldown, now used ONLY by the manager-level `health:` alerts +# in report_once. Per-session records latch on the incident instead (see +# already_recorded_incident above): a rate limit was the wrong shape for them, +# because it let one unbroken silence write a fresh record every six hours. It +# remains the right shape here -- these are notifier alerts about a condition +# with no session to ask about recovery, so elapsed time is the only thing that +# can license repeating one. already_reported_quiet() { local id=$1 python3 - "$REPORT_STATE" "$id" "$QUIET_REPORT_COOLDOWN_SECONDS" <<'PY' @@ -339,15 +814,19 @@ raise SystemExit(0 if time.time() - seen < int(cooldown_text) else 1) PY } +# The hold is how long the condition stays quiet. The default is the full +# cooldown -- one message per condition per six hours. A delivery that failed +# passes the short retry window instead, so the next sweep speaks again rather +# than the fault going quiet on the strength of an alert nobody received. remember_quiet_report() { - local id=$1 - python3 - "$REPORT_STATE" "$id" <<'PY' + local id=$1 hold=${2:-$QUIET_REPORT_COOLDOWN_SECONDS} + python3 - "$REPORT_STATE" "$id" "$QUIET_REPORT_COOLDOWN_SECONDS" "$hold" <<'PY' import json import os import sys import time -path, wanted = sys.argv[1:3] +path, wanted, cooldown_text, hold_text = sys.argv[1:5] try: with open(path, encoding="utf-8") as handle: entries = json.load(handle)["reported"] @@ -355,7 +834,11 @@ try: entries = {} except (OSError, ValueError, KeyError): entries = {} -entries[wanted] = time.time() +cooldown = int(cooldown_text) +hold = min(int(hold_text), cooldown) +# already_reported_quiet asks whether now - stamp is under the cooldown, so a +# shorter hold is a stamp placed that much further back. +entries[wanted] = time.time() - (cooldown - hold) # Keep the file small; a stale id can never match a live session anyway. if len(entries) > 200: entries = dict(sorted(entries.items(), key=lambda item: item[1])[-200:]) @@ -369,37 +852,44 @@ PY } sweep() { - local since=$1 flagged verdict id + local since=$1 events verdict id evidence if [[ -e $SENTINEL ]]; then return 0 fi - flagged=$(flagged_sessions "$since" | tr '\n' ' ') + events=$(flagged_events "$since") while IFS= read -r verdict; do [[ -n $verdict ]] || continue + evidence=$(json_field "$verdict" evidence) + if [[ $evidence == manager-missing-thread ]]; then + report_once serving-thread-mismatch \ + "Session manager is serving fewer sessions than it lists" \ + "The manager serves fewer sessions than it lists. The count cannot say which session is affected, so nothing was changed. The manager log and session history show the detail." + continue + fi id=$(json_field "$verdict" shpool_id) [[ -n $id ]] || continue - if [[ $(json_field "$verdict" evidence) == silence-only ]]; then + if [[ $evidence == silence-only ]]; then # Nothing but silence. Report it and let a human decide; closing a # session on this alone would eventually close an idle one. - if ! already_reported_quiet "$id"; then + if ! already_recorded_incident "$id" \ + "$(json_field "$verdict" recent_output_age_seconds)" "$evidence"; then log "candidate $id is quiet but there is no evidence its terminal is dead; reporting only" record_repair "$id" "" "$(json_field "$verdict" title)" \ "$(json_field "$verdict" provider)" reported \ - "$(json_field "$verdict" reason)" || true - remember_quiet_report "$id" || true + "$(json_field "$verdict" reason)" "$evidence" || true fi continue fi log "candidate $id has direct evidence its terminal cannot be served" - if [[ $WATCHDOG_MODE == repair ]]; then + if [[ $WATCHDOG_MODE == repair && $(json_field "$verdict" repair_allowed) == True ]]; then repair_session "$verdict" - elif ! already_reported_quiet "$id"; then + elif ! already_recorded_incident "$id" \ + "$(json_field "$verdict" recent_output_age_seconds)" "$evidence"; then record_repair "$id" "" "$(json_field "$verdict" title)" \ "$(json_field "$verdict" provider)" reported \ - "$(json_field "$verdict" reason)" || true - remember_quiet_report "$id" || true + "$(json_field "$verdict" reason)" "$evidence" || true fi - done < <(dead_sessions "$flagged") + done < <(dead_sessions "$events") return 0 } @@ -409,8 +899,207 @@ sweep() { # open, close and create hung behind it for 73 minutes. Nothing noticed; it was # found by walking into it. `list` is read-only and is what the inventory # already calls constantly, so asking is free and cannot disturb a session. +# Its bytes are discarded: this is liveness/report-only and never nominates a +# repair or any other action. Repair uses guard-live inventory, and `sp repair` +# binds its close to that guard's exact socket-holder generation. manager_answers() { - timeout "$MANAGER_PROBE_TIMEOUT" "$SK_SHPOOL" list >/dev/null 2>&1 + sk_timeout "$MANAGER_PROBE_TIMEOUT" "$SK_SHPOOL" list >/dev/null 2>&1 +} + +# Carry conversations off accounts that have run dry, and say so afterwards. +# +# Ordering matters and is deliberate. The cheap account-level question comes +# first: `account auto-spent` answers from the usage feed alone, and answers +# "nothing" whenever the feed is stale or the kill switch is set. Only when an +# account really is spent does this look at sessions at all, so the ordinary +# minute costs one feed read and nothing else. +# +# The move itself is `sp account-auto-switch`, which re-proves everything from +# its own fresh snapshot. Nothing about safety is decided here. +account_guard_pass() { + [[ -e $SENTINEL ]] && return 0 + local now spent inventory targets id line outcome marker provider + now=$(date -u '+%s') + (( now - ACCOUNT_GUARD_LAST >= ACCOUNT_GUARD_SECONDS )) || return 0 + ACCOUNT_GUARD_LAST=$now + # One pass at a time across the machine, not just within this process. The + # resident loop, a hand-run `--once` and the macOS launchd agent can all be + # alive together, and "one move per pass" has to mean one move. The ledger + # refuses a second reservation anyway; this stops two passes racing for the + # same target's remaining reserve before either move is visible in the feed. + exec {ACCOUNT_GUARD_FD}>>"$ACCOUNT_GUARD_LOCK" || return 0 + if ! flock -n "$ACCOUNT_GUARD_FD"; then + exec {ACCOUNT_GUARD_FD}>&- + log "account guard: another pass holds the lock; skipping this one" + return 0 + fi + account_guard_locked_pass + local status=$? + flock -u "$ACCOUNT_GUARD_FD" 2>/dev/null || true + exec {ACCOUNT_GUARD_FD}>&- + return "$status" +} + +account_guard_locked_pass() { + local now spent inventory targets id line outcome marker provider + now=$(date -u '+%s') + # Debts first, and before the kill switch is consulted. Switching automatic + # moves off stops future moves; it does not un-owe the operator a message + # about one that already happened. + deliver_account_notices + [[ -e $ACCOUNT_SWITCH_OFF ]] && return 0 + for provider in claude codex; do + spent=$(python3 "$SK_INVENTORY_CORE" account auto-spent "$provider" 2>/dev/null) || + continue + python3 -c ' +import json +import sys +try: + value = json.load(sys.stdin) +except ValueError: + raise SystemExit(1) +raise SystemExit(0 if value.get("fresh") is True and value.get("aliases") else 1) +' <<<"$spent" || continue + # --guard-json, not --json: the mutation guards' snapshot is live-only and + # writes nothing, so a periodic account check cannot republish the cached + # inventory as a side effect, and cached rows can never name a target. + inventory=$("$STATUS_CMD" --guard-json 2>/dev/null) || continue + targets=$(python3 -c ' +import json +import sys + +spent = json.loads(sys.argv[1]) +aliases = set(spent.get("aliases") or []) +provider = spent.get("provider") +try: + data = json.load(sys.stdin) +except ValueError: + raise SystemExit(0) +for row in data.get("sessions", []): + if not isinstance(row, dict): + continue + identity = row.get("identity") or {} + if ( + row.get("provider") == provider + and row.get("account_alias") in aliases + and identity.get("uuid") + and row.get("mutation_allowed") is True + ): + raw = str(row.get("shpool_id_raw") or "") + if raw: + print(raw) +' "$spent" <<<"$inventory" 2>/dev/null) || continue + while IFS= read -r id; do + [[ -n $id ]] || continue + # One move per pass across the whole estate. If several conversations + # share a spent account, the rest are looked at again next time -- with + # the target's usage re-read -- so a burst can never push one account + # through the reserve in a single sweep. + if [[ $WATCHDOG_MODE == repair ]]; then + outcome=$(SESSION_KIT_AUTO_SWITCH_MARKER=1 \ + "$SP_CMD" account-auto-switch "$id" --apply 2>&1) || { + log "account guard: $id was not moved: ${outcome//$'\n'/ }" + continue + } + else + outcome=$("$SP_CMD" account-auto-switch "$id" --dry-run 2>&1) || { + log "account guard: $id could not be judged: ${outcome//$'\n'/ }" + continue + } + fi + marker=$(grep -m1 '^SESSION_KIT_AUTO_SWITCH ' <<<"$outcome" || true) + line=$(grep -m1 -v '^SESSION_KIT_AUTO_SWITCH ' <<<"$outcome" || true) + if [[ -n $marker ]]; then + log "account guard: $line" + announce_account_move "$id" "$line" "$marker" + return 0 + fi + case "$line" in + "Would move "*) + # Report mode: the watchdog is not permitted to act on live + # sessions here, so say what the rule decided and change nothing. + if ! already_reported_quiet "account:$id"; then + log "account guard (report mode): $line" + if notify "A session is on an account that has run dry" \ + "$line Automatic account changes need SESSION_KIT_WATCHDOG_MODE=repair; nothing was changed." \ + "$ALERT_TYPE"; then + remember_quiet_report "account:$id" || true + else + remember_quiet_report "account:$id" "$QUIET_RETRY_SECONDS" || true + fi + fi + return 0 + ;; + *"waits for you"*|*"stood down"*) + if ! already_reported_quiet "account:$id"; then + log "account guard: $line" + if notify "A session ran out of account quota" "$line" "$ALERT_TYPE"; then + remember_quiet_report "account:$id" || true + else + remember_quiet_report "account:$id" "$QUIET_RETRY_SECONDS" || true + fi + fi + ;; + esac + done <<<"$targets" + done + return 0 +} + +# Tell the operator about a move that already happened, exactly once. +# +# The claim is taken in the ledger, never here: a watchdog restarted between +# the move and the message must not send it twice, and two passes must not +# both claim one move. `account auto-announce` succeeds for exactly one caller. +# +# There is deliberately no picker banner row. The picker renders every +# unacknowledged repair record as "automatic repair failed" +# (lib/sh/shpool_login_render.sh), so a successful account move written there +# would read to the operator as a failure. The durable records are this log, +# the hop ledger, and the action log. +announce_account_move() { + local id=$1 sentence=$2 marker=$3 provider uuid source target token + IFS=$'\t' read -r _ _ provider uuid source target token <<<"$marker" + [[ -n $provider && -n $uuid && -n $token ]] || { + log "account guard: a move was reported without its identity; it cannot be queued for the operator" + return 1 + } + python3 "$SK_INVENTORY_CORE" account auto-queue-notice "$provider" "$uuid" \ + "$token" "$sentence" >/dev/null 2>&1 || true + deliver_account_notices +} + +# Try to deliver every notice still owed, oldest first. Runs on every pass, so +# a notice queued while the notifier was down is retried until it lands. +deliver_account_notices() { + local owed provider uuid token sentence + owed=$(python3 "$SK_INVENTORY_CORE" account auto-pending-notices 2>/dev/null) || return 0 + while IFS=$'\t' read -r provider uuid token sentence; do + [[ -n $token ]] || continue + if notify "A session moved to another subscription account" "$sentence" \ + "$ALERT_TYPE"; then + python3 "$SK_INVENTORY_CORE" account auto-notice-delivered \ + "$provider" "$uuid" "$token" >/dev/null 2>&1 || true + log "account guard: told the operator about move $token" + else + # Still owed. Nothing is marked delivered, so the next pass retries. + log "account guard: the notice for move $token was not delivered anywhere; it stays queued" + return 0 + fi + done < <(printf '%s' "$owed" | python3 -c ' +import json +import sys + +try: + value = json.load(sys.stdin) +except ValueError: + raise SystemExit(0) +for item in value.get("notices", [])[:8]: + row = [str(item.get(key) or "") for key in ("provider", "uuid", "token", "sentence")] + if any("\t" in field or "\n" in field for field in row): + continue + print("\t".join(row)) +') } # Is the running daemon still the binary we expect? @@ -424,6 +1113,21 @@ daemon_binary_matches() { local expected actual pid IFS= read -r expected < "$BINARY_FINGERPRINT" || return 0 [[ $expected =~ ^[0-9a-f]{64}$ ]] || return 0 + if [[ $(sk_platform) == Darwin ]]; then + python3 "$SK_INVENTORY_CORE" platform process-table 2>/dev/null | + python3 -c ' +import hashlib,json,pathlib,sys +expected=sys.argv[1] +payload=json.load(sys.stdin) +rows=[] +for row in payload.get("processes",[]): + argv=row.get("cmdline") or [] + if argv and pathlib.Path(str(argv[0])).name=="shpool" and "daemon" in argv[1:]: + rows.append(pathlib.Path(str(argv[0])).resolve()) +raise SystemExit(0 if len(rows)==1 and hashlib.sha256(rows[0].read_bytes()).hexdigest()==expected else 1) +' "$expected" + return + fi pid=$(pgrep -f "^${SK_SHPOOL} daemon$" | head -n 1) [[ -n $pid && -r /proc/$pid/exe ]] || return 0 actual=$(sha256sum "/proc/$pid/exe" 2>/dev/null | cut -d' ' -f1) @@ -437,12 +1141,45 @@ report_once() { local key=$1 subject=$2 body=$3 already_reported_quiet "health:$key" && return 0 log "health: $key" - notify "$subject" "$body" - remember_quiet_report "health:$key" || true + if notify "$subject" "$body"; then + remember_quiet_report "health:$key" || true + else + remember_quiet_report "health:$key" "$QUIET_RETRY_SECONDS" || true + fi +} + +# The 2026-08-17 estate jam: a self-name held config.lock and then waited on +# its own second descriptor for it, so every collector, picker refresh and +# name attempt queued behind one process for twenty minutes, while the +# session manager answered normally, so manager_answers saw nothing. The +# durable, cheap symptom of that whole class: the snapshot has stopped aging +# forward AND a kit state lock has waiters. Report the holder by pid so the +# person (or an agent) can end it. config.lock also serialises atomic config +# writes (colors, names), so ending the holder can abort that one command; +# the write itself stays whole either way, and collections retry on their +# own. The message says exactly that instead of promising universal safety +# (review lane rv-pdn-2, 2026-08-17). +state_lock_jam_check() { + local snapshot="$SK_STATE_DIR/inventory.json" + local age lockfile inode rows holder + [[ -f $snapshot ]] || return 0 + age=$(( $(date +%s) - $(stat -c %Y "$snapshot" 2>/dev/null || echo 0) )) + (( age > STALE_SNAPSHOT_SECONDS )) || return 0 + for lockfile in "$SK_STATE_DIR/inventory-v2.lock" "$SK_STATE_DIR/config.lock"; do + [[ -f $lockfile ]] || continue + inode=$(stat -c %i "$lockfile" 2>/dev/null) || continue + rows=$(grep -F ":$inode " /proc/locks 2>/dev/null) || continue + printf '%s\n' "$rows" | grep -q -- '->' || continue + holder=$(printf '%s\n' "$rows" | grep -v -- '->' | head -1 | awk '{print $5}') + report_once state-lock-jam "Session Kit state locks are jammed" \ + "The session snapshot has not refreshed for $((age / 60)) minutes and ${lockfile##*/} has processes queued behind pid ${holder:-unknown} ($(ps -o args= -p "${holder:-0}" 2>/dev/null | cut -c1-120)). Sessions keep running, but the picker, collectors and naming are all waiting. Ending the holder clears the jam; atomic writes stay whole, collections retry on their own, and at worst the holder's own command reports a failure and can be rerun: kill ${holder:-<pid>}" + return 0 + done } health_check() { [[ -e $SENTINEL ]] && return 0 + state_lock_jam_check if ! manager_answers; then # Confirm before speaking: a single slow answer under load is not a jam. sleep 5 @@ -454,20 +1191,72 @@ health_check() { fi if ! daemon_binary_matches; then report_once binary-changed "Session manager is running a different build" \ - "The running session manager is not the build recorded for this machine. The most likely cause is a routine reinstall replacing it, which silently undoes the fix that stops sessions breaking. See shpool-patch/ in the session-kit repository to rebuild and reinstall it." + "The running session manager is not the build recorded for this machine. The most likely cause is a routine reinstall replacing it, which silently undoes the fix that stops sessions breaking. Rebuild it from shpool-patch/ in the Session Kit source." + fi +} + +# Everything the sweep RECORDED rather than announced -- a session reported +# quiet, a stall flag, another window's open question -- has a durable record +# and no announcement. This is where those records get a route to a person. +# Best effort by construction: a router that cannot run must never fail the +# sweep that finds real faults. +notice_sweep() { + local answer + [[ ${SESSION_KIT_NOTICE:-on} != off ]] || return 0 + # The watchdog's own off switch turns this off too: a person who parked the + # watchdog did not ask to keep hearing from it by another road. + [[ ! -e $SENTINEL ]] || return 0 + [[ -x $NOTICE_CMD ]] || return 0 + if ! answer=$("$NOTICE_CMD" sweep 2>&1); then + # Non-zero means it had something to say and reached nobody. That is the + # loudest line this function writes, because it is the one that means a + # person was not told. + log "notice sweep DID NOT REACH THEM: ${answer//$'\n'/ }" + return 0 fi + # ONE key, not two read in an order the encoder never emits. This tested for + # '"routed": 0' followed by '"held": 0', and the router sorts its keys -- so + # the quiet case could never match and every idle pass wrote a line. At a + # 60-second poll that is 1,440 lines a day saying nothing happened. + case "$answer" in + *'"quiet": true'*) ;; + *) log "notice sweep: ${answer//$'\n'/ }" ;; + esac + return 0 } if (( ONCE )); then health_check sweep "${SESSION_KIT_WATCHDOG_SINCE:--60 min}" + notice_sweep + account_guard_pass exit 0 fi log "watchdog started (mode=${WATCHDOG_MODE} poll=${POLL_SECONDS}s flagged_quiet=${FLAGGED_QUIET_SECONDS}s dead_quiet=${DEAD_QUIET_SECONDS}s)" -trap 'log "watchdog stopping"; exit 0' HUP INT TERM +# A stop signal must never cut a repair in half. Exiting from the handler did +# exactly that: the trap fired the moment the in-flight `sp repair` returned, +# so a session that had just been closed and relaunched got no record and no +# notification. The signal is remembered instead and acted on between sweeps, +# which is the behaviour the unit's TimeoutStopSec was written for. +WATCHDOG_STOPPING=0 +stop_after_this_sweep() { + WATCHDOG_STOPPING=1 + log "watchdog stop requested; finishing the sweep in flight" +} +trap stop_after_this_sweep HUP INT TERM while true; do health_check sweep "-$(( (POLL_SECONDS * 3 + 59) / 60 )) min" + notice_sweep + account_guard_pass + if (( WATCHDOG_STOPPING )); then + log "watchdog stopping" + exit 0 + fi sleep "$POLL_SECONDS" + if (( WATCHDOG_STOPPING )); then + log "watchdog stopping" + exit 0 + fi done diff --git a/bin/shpool_login b/bin/shpool_login index 964bcc4..f81b59d 100755 --- a/bin/shpool_login +++ b/bin/shpool_login @@ -9,1697 +9,319 @@ source "$SCRIPT_DIR/session_kit_common" STATUS_CMD=${SESSION_KIT_STATUS_CMD:-"$SCRIPT_DIR/shpool_status"} SP_CMD=${SESSION_KIT_SP_CMD:-"$SCRIPT_DIR/sp"} +PROJECTS_TOOL=${SESSION_KIT_PROJECTS_TOOL:-"$SCRIPT_DIR/../lib/sessionkit_inventory/projects.py"} +# Leaving the picker on purpose is 0; every way it cannot run is 1, with the +# reason on stderr (`sp help exit-codes`). 2 belongs to arguments and +# selectors, which this program has none of. if [[ ! -t 0 || ! -t 1 || ${SESSION_KIT_NONINTERACTIVE:-0} == 1 ]]; then - exit 2 + # Still a picker exit: without a logged reason, a window that lands in a + # bare shell is undiagnosable from the action log. + sk_prepare_state >/dev/null 2>&1 && sk_log_action picker_exit noninteractive 2>/dev/null + echo "session-kit: the picker needs a terminal." >&2 + exit 1 fi -[[ -x $STATUS_CMD && -x $SP_CMD ]] || exit 1 +# The third descriptor was never checked here, and it is the one that decides +# whether a session opened from this window can be typed into at all. shpool's +# client leaves the terminal in line-typing mode unless stdin, stdout AND +# stderr are all a terminal, and it does that silently, so a stray redirect on +# fd 2 -- inherited from a parent, or left behind by a picker that upgraded +# itself -- reaches a person as "my keyboard is dead", with nothing anywhere +# saying why. Put it back on the screen and say so, rather than drawing a +# picker whose every session will be unusable. +if [[ ! -t 2 ]]; then + # Try the reopen in a subshell first: a failed redirection on a bare `exec` + # ends a non-interactive shell, and no picker should die because a window + # has no controlling terminal to reopen. + if ( exec 2>/dev/tty ) 2>/dev/null; then + exec 2>/dev/tty + fi + if [[ -t 2 ]]; then + sk_prepare_state >/dev/null 2>&1 && sk_log_action picker_stderr rebound 2>/dev/null + echo "session-kit: this window's error output was not the screen, which would have left every session opened here unable to receive typing. It is back on the screen now." >&2 + else + sk_prepare_state >/dev/null 2>&1 && sk_log_action picker_stderr unrepairable 2>/dev/null + echo "session-kit: this window's error output is not the screen and could not be put back, so a session opened here may not receive typing. Open a fresh window, or repair it with: exec 2>/dev/tty" >&2 + fi +fi +[[ -x $STATUS_CMD && -x $SP_CMD ]] || { + echo "session-kit: the picker's helpers are missing from this release." >&2 + exit 1 +} sk_require_shpool || exit 1 -sk_prepare_state || exit 1 +sk_prepare_state || { + echo "session-kit: the picker's state directory could not be prepared." >&2 + exit 1 +} SNAPSHOT="" VIEW="" RECOVERY="" +# Set when the Closed screen could not write down what it printed beside each +# row. The screen still draws; it says the check `sp restore` makes against it +# is missing rather than letting it look as though one was made. +RECOVERY_UNRECORDED="" QUERY="" PAGE=1 +# The d-number to watchdog repair array index, built only inside Needs you. +REPAIR_INDEX="" PAGE_SIZE=1 +# How many rows the last page sizing counted, for the page arithmetic that +# follows it. Empty means "not counted yet", which costs one read of the view. +VIEW_ITEM_COUNT= +# The numbers the last paint put on the screen, in display order, and whether +# a repaint has changed that set since the prompt appeared. `k all` closes the +# drawn set and only while it is still the drawn set: the background collector +# re-sorts the list every few seconds (the sort follows recent output and +# needs-you), so the page a person read and the page that exists when they +# press Enter are not always the same page. +PAGE_RENDERED_NUMBERS="" +PAGE_RENDER_CHANGED=0 +# How the list is grouped, whether rows are drawn compact, and which row the +# jump key last landed on. All three are view-only: they change what the +# screen shows, never which sessions exist or what a number does. The two +# environment variables let a person keep a preference; the defaults are the +# list this picker has always drawn. +PICKER_GROUP_MODE=${SESSION_KIT_PICKER_GROUP:-state} +case "$PICKER_GROUP_MODE" in + state | provider | project) ;; + *) PICKER_GROUP_MODE=state ;; +esac +PICKER_COMPACT=${SESSION_KIT_PICKER_COMPACT:-0} +[[ $PICKER_COMPACT == 1 ]] || PICKER_COMPACT=0 +PICKER_MACHINE_EXPANDED=${SESSION_KIT_PICKER_MACHINE_EXPANDED:-0} +[[ $PICKER_MACHINE_EXPANDED == 1 ]] || PICKER_MACHINE_EXPANDED=0 +PICKER_JUMP_NUMBER="" +# Set only between `g` choosing a row and the next paint. Page size belongs to +# the new view, so render_main positions the row after recomputing that size. +PICKER_JUMP_PENDING=0 PICKER_REFUSED_STATUS=74 PICKER_ATTACH_FAILED_STATUS=75 TEMP_FILES=() NEW_TEMP="" PICKER_STYLE=0 +PICKER_SCREEN=0 PICKER_BOLD="" PICKER_GREEN="" PICKER_RESET="" - -if [[ -t 1 && - -n ${TERM:-} && - ${TERM,,} != dumb && - ! -v NO_COLOR && - ! -v SESSION_KIT_NO_COLOR ]]; then - PICKER_STYLE=1 - PICKER_BOLD=$'\033[1m' - PICKER_GREEN=$'\033[32m' - PICKER_RESET=$'\033[0m' -fi - -picker_bold() { - if (( PICKER_STYLE )); then - printf '%s%s%s' "$PICKER_BOLD" "$1" "$PICKER_RESET" - else - printf '%s' "$1" +PICKER_TTY_STATE="" + +if [[ -t 1 && -n ${TERM:-} && ${TERM,,} != dumb ]]; then + PICKER_SCREEN=1 + if [[ ! -v NO_COLOR && ! -v SESSION_KIT_NO_COLOR ]]; then + PICKER_STYLE=1 + PICKER_BOLD=$'\033[1m' + PICKER_GREEN=$'\033[32m' + PICKER_RESET=$'\033[0m' fi -} +fi -picker_green() { - if (( PICKER_STYLE )); then - printf '%s%s%s%s' "$PICKER_BOLD" "$PICKER_GREEN" "$1" "$PICKER_RESET" - else - printf '%s' "$1" - fi -} +# The picker's function groups live beside this program under ../lib/sh, in a +# Git checkout and in an installed release alike: the launcher always execs +# this file from inside its own release, so SCRIPT_DIR resolves to that same +# tree. Every module defines functions only. They are sourced here, ahead of +# the first trap, so no handler is ever installed before the function it names +# exists; the picker globals assigned below are read at call time, which never +# happens before the boot sequence at the end of this file. +MODULE_DIR=$SCRIPT_DIR/../lib/sh +# shellcheck source=lib/sh/shpool_login_theme.sh +source "$MODULE_DIR/shpool_login_theme.sh" +# shellcheck source=lib/sh/shpool_login_live.sh +source "$MODULE_DIR/shpool_login_live.sh" +# shellcheck source=lib/sh/shpool_login_view.sh +source "$MODULE_DIR/shpool_login_view.sh" +# shellcheck source=lib/sh/shpool_login_render.sh +source "$MODULE_DIR/shpool_login_render.sh" +# shellcheck source=lib/sh/shpool_login_projects.sh +source "$MODULE_DIR/shpool_login_projects.sh" +# shellcheck source=lib/sh/shpool_login_actions.sh +source "$MODULE_DIR/shpool_login_actions.sh" +# shellcheck source=lib/sh/shpool_login_recovery.sh +source "$MODULE_DIR/shpool_login_recovery.sh" -cleanup() { - local path - for path in "${TEMP_FILES[@]}"; do - [[ -z $path || ! -f $path ]] || command rm -- "$path" - done -} trap cleanup EXIT # A stray Ctrl-C must never dump the human to a bare terminal: interrupts # redraw the menu instead of killing the picker. Hangup and terminate still # end it, with the reason recorded for later diagnosis. PICKER_INTERRUPTED=0 trap 'PICKER_INTERRUPTED=1' INT -trap 'sk_log_action picker_exit hangup || true; exit 2' HUP TERM - -# Every deliberate or forced picker exit records why, so "it dropped me to a -# regular terminal" is diagnosable from the action log after the fact. -picker_exit() { - local reason=$1 - sk_log_action picker_exit "$reason" || true - exit 2 -} - -# Shared read wrapper: interrupt = redraw (rc 1), end-of-input = picker ends. -picker_read() { - local __sk_read_var=$1 __sk_read_prompt=$2 - PICKER_INTERRUPTED=0 - # The indirection is deliberate: callers pass the DESTINATION variable name. - # shellcheck disable=SC2229 - if IFS= read -r -p "$__sk_read_prompt" "$__sk_read_var"; then - return 0 - fi - if (( PICKER_INTERRUPTED )); then - PICKER_INTERRUPTED=0 - echo - return 1 - fi - picker_exit input_closed -} - -new_temp() { - local label=$1 path - path=$(mktemp "$SK_STATE_DIR/${label}.XXXXXX.json") || return 1 - chmod 600 "$path" || { - command rm -- "$path" - return 1 - } - TEMP_FILES+=("$path") - NEW_TEMP=$path -} - -refresh_snapshot() { - local fresh - new_temp login-snapshot || return 1 - fresh=$NEW_TEMP - if ! "$STATUS_CMD" --json > "$fresh"; then - command rm -- "$fresh" - return 1 - fi - SNAPSHOT=$fresh - QUERY="" - PAGE=1 - build_view -} - -build_view() { - local fresh - new_temp login-view || return 1 - fresh=$NEW_TEMP - if ! python3 - "$SNAPSHOT" "$fresh" "$QUERY" <<'PY' -import json -import os -import re -import sys -import unicodedata - -source, destination, query = sys.argv[1:4] -with open(source, encoding="utf-8") as handle: - data = json.load(handle) - -needle = query.casefold() -provider_order = {"claude": 0, "codex": 1, "shell": 2, "unknown": 3} -availability_order = {"ready": 0, "attached": 1} - -def clean(value, limit=4096): - text = str(value or "") - text = "".join( - " " if unicodedata.category(character).startswith("C") else character - for character in text - ) - return " ".join(text.split())[:limit] - -def matches(row): - identity = row.get("identity") or {} - fields = ( - row.get("shpool_id"), - row.get("provider"), - row.get("display_provider"), - row.get("title"), - row.get("display_title"), - row.get("native_title"), - row.get("cwd"), - identity.get("uuid"), - row.get("terminal_number"), - ) - return not needle or any(needle in str(value or "").casefold() for value in fields) - -def natural_id(value): - return tuple( - int(part) if part.isdigit() else part.casefold() - for part in re.split(r"([0-9]+)", str(value or "")) - ) - -rows = [] -unavailable_total = 0 -for original in data.get("sessions", []): - if not isinstance(original, dict): - continue - number = original.get("terminal_number") - if ( - isinstance(number, bool) - or not isinstance(number, int) - or number <= 0 - ): - unavailable_total += 1 - continue - if not matches(original): - continue - row = dict(original) - rows.append(row) - -rows.sort( - key=lambda row: ( - availability_order.get(row.get("availability"), 9), - not bool(row.get("needs_you")), - provider_order.get( - row.get("display_provider") or row.get("provider"), 9 - ), - row.get("recent_output_at_unix_ms") is None, - -(row.get("recent_output_at_unix_ms") or 0), - natural_id(row.get("shpool_id_raw")), - ) -) -for row in rows: - row["title"] = clean(row.get("title"), 120) - row["display_title"] = clean( - row.get("display_title") or row.get("title"), 120 - ) - row["display_shpool_id"] = clean( - row.get("display_shpool_id") or row.get("shpool_id"), 96 - ) - row["native_title"] = clean(row.get("native_title"), 120) - -outside = [] -for original in data.get("outside_agents", []): - if not isinstance(original, dict) or not matches(original): - continue - item = dict(original) - item["title"] = clean(item.get("title"), 120) - item["display_title"] = clean( - item.get("display_title") or item.get("title"), 120 - ) - item["cwd"] = clean(item.get("cwd"), 4096) - outside.append(item) -outside.sort( - key=lambda item: ( - provider_order.get( - item.get("display_provider") or item.get("provider"), 9 - ), - str(item.get("display_title") or "").casefold(), - str((item.get("identity") or {}).get("uuid") or ""), - ) -) - -projected = dict(data) -projected["sessions"] = rows -projected["outside_agents"] = outside -projected["_picker"] = { - "query": query, - "source_total": len(data.get("sessions", [])), - "source_outside_total": len(data.get("outside_agents", [])), - "unavailable_total": unavailable_total, -} -with open(destination, "w", encoding="utf-8") as handle: - json.dump(projected, handle, sort_keys=True, separators=(",", ":")) - handle.write("\n") - handle.flush() - os.fsync(handle.fileno()) -os.chmod(destination, 0o600) -PY - then - command rm -- "$fresh" - return 1 - fi - VIEW=$fresh -} - -terminal_height() { - local height=${LINES:-} - if [[ ! $height =~ ^[0-9]+$ ]]; then - height=$(tput lines 2>/dev/null || true) - fi - [[ $height =~ ^[0-9]+$ ]] || height=24 - (( height >= 12 )) || height=12 - (( height <= 200 )) || height=200 - printf '%s\n' "$height" -} - -page_size() { - local pending=${1:-0} banner=${2:-0} height - height=$(terminal_height) - python3 - "$VIEW" "$height" "$pending" "$banner" <<'PY' -import json -import sys - -path, height_text, pending_text, banner_text = sys.argv[1:5] -height = int(height_text) -has_recovery = pending_text.isdigit() and int(pending_text) > 0 -banner_lines = int(banner_text) if banner_text.isdigit() else 0 -with open(path, encoding="utf-8") as handle: - data = json.load(handle) -rows = data.get("sessions", []) -outside = data.get("outside_agents", []) -picker = data.get("_picker") or {} -total = len(rows) - -def page_lines(first, last, paged): - selected = rows[first:min(last, len(rows))] - - # Summary/search, optional other-provider shortcut/stale notices. - lines = 2 if picker.get("query") else 1 - if outside: - lines += 1 - if picker.get("unavailable_total"): - lines += 1 - if data.get("stale"): - lines += 1 - - previous_availability = None - previous_attention = None - previous_provider = None - for row in selected: - availability = row.get("availability") - attention = bool(row.get("needs_you")) - provider = str( - row.get("display_provider") or row.get("provider") or "unknown" - ) - if availability != previous_availability: - lines += 1 # action heading - previous_availability = availability - previous_attention = None - previous_provider = None - if attention != previous_attention: - previous_attention = attention - previous_provider = None - if provider != previous_provider: - lines += 1 - previous_provider = provider - lines += 1 - - if not selected: - lines += 1 - if paged: - lines += 1 - if has_recovery: - lines += 1 - lines += banner_lines # watchdog repair notice - lines += 3 # spacer and two compact help lines - lines += 1 # input prompt - return lines - -if total == 0: - print(1) - raise SystemExit - -for size in range(total, 0, -1): - pages = (total + size - 1) // size - if all( - page_lines(first, min(first + size, total), pages > 1) <= height - for first in range(0, total, size) - ): - print(size) - break -else: - # Very short terminals still get one item and can scroll safely. - print(1) -PY -} - -view_item_count() { - python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(len(d.get("sessions",[])))' "$VIEW" -} - -page_count() { - local total - total=$(view_item_count) - printf '%s\n' "$(( total == 0 ? 1 : (total + PAGE_SIZE - 1) / PAGE_SIZE ))" -} - -clamp_page() { - local pages - pages=$(page_count) - (( PAGE >= 1 )) || PAGE=1 - (( PAGE <= pages )) || PAGE=$pages -} - -number_on_page() { - local number=$1 - [[ $number =~ ^[0-9]+$ ]] || return 1 - python3 - "$VIEW" "$PAGE" "$PAGE_SIZE" "$number" <<'PY' -import json -import sys - -path, page_text, size_text, wanted_text = sys.argv[1:5] -page, page_size = int(page_text), int(size_text) -wanted = int(wanted_text) -with open(path, encoding="utf-8") as handle: - data = json.load(handle) -rows = data.get("sessions", []) -item_count = len(rows) -pages = max(1, (item_count + page_size - 1) // page_size) -page = min(max(page, 1), pages) -first = (page - 1) * page_size -last = first + page_size -selected = rows[first:min(last, len(rows))] -matches = [ - row - for row in selected - if row.get("terminal_number") == wanted -] -raise SystemExit(0 if len(matches) == 1 else 1) -PY -} - -view_is_live() { - python3 - "$VIEW" <<'PY' -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - data = json.load(handle) -raise SystemExit( - 0 - if data.get("source") == "live" and data.get("stale") is False - else 1 -) -PY -} - -require_live_actions() { - view_is_live || { - echo " Live session state is unavailable. Cached rows are read-only; refresh and try again." - echo " Nothing changed." - return 1 - } -} - -render_page() { - python3 - "$VIEW" "$PAGE" "$PAGE_SIZE" "$PICKER_STYLE" <<'PY' -import json -import os -import shutil -import sys -import unicodedata - -path, page_text, size_text, style_text = sys.argv[1:5] -page, page_size = int(page_text), int(size_text) -style_enabled = style_text == "1" -with open(path, encoding="utf-8") as handle: - data = json.load(handle) -rows = data.get("sessions", []) -picker = data.get("_picker") or {} -source_total = int(picker.get("source_total") or 0) -query = str(picker.get("query") or "") -ready = sum(row.get("availability") == "ready" for row in rows) -attached = sum(row.get("availability") == "attached" for row in rows) -item_count = len(rows) -pages = max(1, (item_count + page_size - 1) // page_size) -page = min(max(page, 1), pages) -first = (page - 1) * page_size -last = first + page_size -selected = rows[first:min(last, len(rows))] -terminal_columns = max( - 1, shutil.get_terminal_size(fallback=(100, 24)).columns -) -width = max(1, min(239, terminal_columns - 1)) -# Long enough that no normal pause reaches it. Codex sessions report "running" -# for their entire life, working or waiting, so a short threshold would label -# every parked session and the warning would stop meaning anything. -stall_seconds = 2700 -_raw_stall = os.environ.get("SESSION_KIT_STALL_SECONDS", "") -if _raw_stall.isdigit() and int(_raw_stall) > 0: - stall_seconds = int(_raw_stall) -provider_labels = { - "claude": "Claude", - "codex": "Codex", - "shell": "Shell", - "unknown": "Unknown", -} -BOLD = "\033[1m" -GREEN = "\033[32m" -CYAN = "\033[36m" -YELLOW = "\033[33m" -RESET = "\033[0m" -# One palette across the kit: same codes the inventory renderer uses, and the -# exact truecolor values Codex RENDERS for the sk-<color> theme accents -# (measured from captured frames — Codex contrast-adjusts the raw theme hex), -# so a session's name is pixel-identical here, in `sp`, and in its own Codex -# status bar. Remeasure if the theme anchors ever change. -SESSION_PALETTE = { - "red": "\033[38;2;237;93;93m", - "blue": "\033[38;2;97;166;240m", - "green": "\033[38;2;63;221;115m", - "yellow": "\033[38;2;249;215;108m", - "purple": "\033[38;2;173;115;239m", - "orange": "\033[38;2;242;144;81m", - "pink": "\033[38;2;240;113;177m", - "cyan": "\033[38;2;64;216;209m", -} - -def style(text, *codes): - if not style_enabled or not text: - return text - return "".join(codes) + text + RESET - -def display_width(text): - return sum( - 0 if unicodedata.combining(character) - else 2 if unicodedata.east_asian_width(character) in {"W", "F"} - else 1 - for character in text - ) - -def clean_display(text): - text = "".join( - " " if unicodedata.category(character).startswith("C") else character - for character in str(text or "") - ) - return " ".join(text.split()) - -def shorten(text, room): - text = clean_display(text) - if display_width(text) <= room: - return text - kept = [] - used = 0 - for character in text: - cells = display_width(character) - if used + cells > max(0, room - 1): - break - kept.append(character) - used += cells - return "".join(kept) + "…" - -def provider_label(item): - provider = str( - item.get("display_provider") - or item.get("provider") - or "unknown" - ).casefold() - return provider_labels.get(provider, "Unknown") - -def age(seconds): - if seconds < 60: - return "now" - if seconds < 3600: - return f"{seconds // 60}m" - if seconds < 86400: - return f"{seconds // 3600}h {(seconds % 3600) // 60}m" - return f"{seconds // 86400}d {(seconds % 86400) // 3600}h" - -if query: - match_word = "match" if len(rows) == 1 else "matches" - session_word = "session" if source_total == 1 else "sessions" - summary = f"{len(rows)} {match_word} of {source_total} {session_word} · {ready} ready here · {attached} open elsewhere" - print(" " + style(shorten(summary, max(1, width - 2)), BOLD)) - search_label = "Search:" - search_value = shorten(query, max(1, width - 2 - len(search_label) - 1)) - print(" " + style(search_label, BOLD) + " " + search_value) -else: - session_word = "session" if len(rows) == 1 else "sessions" - summary = f"{len(rows)} {session_word} · {ready} ready here · {attached} open elsewhere" - print(" " + style(shorten(summary, max(1, width - 2)), BOLD)) -if data.get("stale"): - source = str(data.get("source") or "").casefold() - source_label = { - "cache": "cached", - "cached": "cached", - "last-known-good": "last-known-good cached", - }.get(source, "cached") - warning = f"Warning: showing {source_label} inventory; actions are disabled." - print(" " + style(shorten(warning, max(1, width - 2)), BOLD, YELLOW)) -if not selected: - print(" No matches.") -if selected: - rendered_rows = [] - for row in selected: - number = row.get("terminal_number") - if ( - isinstance(number, bool) - or not isinstance(number, int) - or number <= 0 - ): - continue - title = row.get("display_title") or row.get("title") or "" - details = [] - needs_attention = bool(row.get("needs_you")) - warning_status = False - if row.get("setup_incomplete"): - primary_status = "! setup incomplete" - warning_status = True - elif row.get("needs_you"): - primary_status = "! needs your reply" - warning_status = True - elif row.get("reply_optional"): - primary_status = "reply optional" - else: - primary_status = clean_display( - row.get("agent_status") or "state unavailable" - ) - if primary_status.casefold() == "state unavailable": - primary_status = "status unavailable" - recent_age = row.get("recent_output_age_seconds") - has_age = isinstance(recent_age, int) and not isinstance(recent_age, bool) - age_text = age(max(0, recent_age)) if has_age else "" - if not warning_status and primary_status.casefold() != "reply optional": - if ( - primary_status.casefold() in {"running", "working"} - and has_age - and recent_age >= stall_seconds - ): - primary_status = f"quiet {age_text}" - details.append(primary_status) - if ( - has_age - and 60 <= recent_age < stall_seconds - and not primary_status.casefold().startswith("quiet ") - ): - details.append(f"last output {age_text}") - automatic_name_state = str( - row.get("automatic_name_state") or "" - ).casefold() - if automatic_name_state in {"pending", "failed"}: - name_detail = f"name {automatic_name_state}" - if needs_attention or primary_status.casefold().startswith("quiet "): - details.append(name_detail) - else: - details.insert(0, name_detail) - if automatic_name_state == "failed": - warning_status = True - status_details = ( - [primary_status] - if needs_attention - else [ - detail - for detail in details - if not detail.startswith("last output ") - ] - ) - agents = len(row.get("subagents") or []) - if agents: - details.append(f"{agents} subagent{'s' if agents != 1 else ''}") - # Narrow terminals drop the subagent count first and the spelled-out - # age second. How long a session has been quiet is the one thing that - # is never dropped. - compact_details = [ - detail - for detail in details - if not detail.endswith("subagent") and not detail.endswith("subagents") - ] - rendered_rows.append( - ( - row, - number, - title, - " | ".join(details), - " | ".join(compact_details), - " | ".join(status_details), - needs_attention, - warning_status, - ) - ) - number_width = max(3, max(len(str(item[1])) for item in rendered_rows)) - prefix_width = len(" ") + number_width - available_width = max(1, width - prefix_width) - ideal_label_width = max(display_width(item[2]) for item in rendered_rows) - # Optional subagent counts never take width away from titles or the primary - # status. They are included only when the compact row already has room. - ideal_detail_width = max(display_width(item[4]) for item in rendered_rows) - if ideal_label_width + 3 + ideal_detail_width <= available_width: - label_width = ideal_label_width - else: - detail_reserve = min(ideal_detail_width, max(18, available_width // 3)) - label_width = max(8, available_width - 3 - detail_reserve) - - previous_availability = None - previous_attention = None - previous_provider = None - for row, number, title, details, compact_details, status_details, needs_attention, warning_status in rendered_rows: - availability = row.get("availability") - provider = provider_label(row) - if availability != previous_availability: - heading = ( - "Ready to open" - if availability == "ready" - else "Open elsewhere" - ) - print(" " + style(heading, BOLD)) - previous_availability = availability - previous_attention = None - previous_provider = None - if needs_attention != previous_attention: - previous_attention = needs_attention - previous_provider = None - if provider != previous_provider: - if needs_attention: - heading = ( - style("Needs your reply", BOLD, YELLOW) - + " · " - + style(provider, BOLD, CYAN) - ) - else: - heading = style(provider, BOLD, CYAN) - print(" " + heading) - previous_provider = provider - number_text = f"{number:>{number_width}}" - prefix = f" {number_text} " - compact_label = shorten(title, label_width) - label_padding = " " * max(0, label_width - display_width(compact_label)) - # The name itself carries the session's color; padding stays unstyled - # so column math keeps working. - name_code = SESSION_PALETTE.get(str(row.get("display_color") or "")) - if name_code: - compact_label = style(compact_label, name_code) - compact_label += label_padding - detail_room = max( - 1, - width - display_width(prefix) - label_width - 3, - ) - if compact_details and display_width(details) > detail_room: - details = compact_details - if status_details and display_width(details) > detail_room: - details = status_details - detail_text = shorten(details, detail_room) - if warning_status: - detail_text = style(detail_text, BOLD, YELLOW) - styled_prefix = " " + style(number_text, BOLD, GREEN) + " " - print(styled_prefix + compact_label + " | " + detail_text) -if pages > 1: - directions = [] - if page > 1: - directions.append("prev") - if page < pages: - directions.append("next") - page_label = f"Page {page}/{pages}" - styled_directions = [style(item, BOLD, CYAN) for item in directions] - print(" " + style(page_label, BOLD) + " | " + " | ".join(styled_directions)) -PY -} - -# Sessions the watchdog repaired while nobody was here. Shown once, then -# marked as seen, so the human always learns what changed even when every -# notification path was down. +# A resize changes how many rows fit and how wide every row is drawn, and the +# repaint fingerprint deliberately hashes only session content -- so on a quiet +# estate nothing ever repainted and the list kept the old geometry until a key +# was pressed. On a quiet estate that is forever. +PICKER_RESIZED=0 +trap 'PICKER_RESIZED=1' WINCH +# What an unknown key on the home menu is answered with: every key the +# dispatcher below actually takes, and nothing else. Kept next to that +# dispatcher so a new key is added in one place; `?` has the whole table. +PICKER_MAIN_KEYS="Enter, a session number, n, a, g, x, m, p, r, c, o, u, \ +h number, k numbers, name number, name reset number, fork number, group, \ +next, prev, model number, /text, ?, b, and q" +trap 'sk_log_action picker_exit hangup || true; exit 1' HUP TERM + +# ---- live menu --------------------------------------------------------- +# The list used to be a photograph: whatever was true when the window opened, +# until a key was pressed. A snapshot costs ~1.5s, so it is collected in the +# background and adopted only when it changes something on screen. +LIVE_PID= +LIVE_FILE= +LIVE_DONE= +LIVE_PENDING= +LIVE_FINGERPRINT= +LIVE_WARNING= + +# The main prompt, kept live. Identical to picker_read from the keyboard's +# point of view: a read that times out consumes nothing, and a line that is +# still being typed stays queued in the terminal untouched. +# Returns 0 with a line to act on, or non-zero when the read was interrupted +# and the caller should draw the menu again without acting. +PICKER_LIVE_STATUS=0 +# Repair failures are read into the explicit Needs-you view. Merely drawing a +# menu never acknowledges or changes them. REPAIR_FILE=${SESSION_KIT_WATCHDOG_REPAIRS:-"$SK_STATE_DIR/watchdog-repairs.json"} -REPAIR_BANNER="" -REPAIR_BANNER_LINES=0 - -# Read once per picker run and mark as seen, so the message survives every -# redraw of this session but is not repeated at the next login. -load_repair_banner() { - [[ -r $REPAIR_FILE ]] || return 0 - REPAIR_BANNER=$(read_repair_banner) || REPAIR_BANNER="" - if [[ -n $REPAIR_BANNER ]]; then - REPAIR_BANNER_LINES=$(printf '%s\n' "$REPAIR_BANNER" | wc -l) - fi -} - -show_repair_banner() { - [[ -n $REPAIR_BANNER ]] || return 0 - printf '%s\n' "$REPAIR_BANNER" -} - -read_repair_banner() { - python3 - "$REPAIR_FILE" <<'PY' || true -import json -import os -import sys - -path = sys.argv[1] -try: - with open(path, encoding="utf-8") as handle: - data = json.load(handle) - entries = data["repairs"] - if not isinstance(entries, list): - raise ValueError -except (OSError, ValueError, KeyError): - sys.exit(0) - -unseen = [ - item - for item in entries - if isinstance(item, dict) and not item.get("acknowledged") -] -if not unseen: - sys.exit(0) - -for item in unseen[-3:]: - title = str(item.get("title") or item.get("old_shpool_id") or "a session") - if item.get("outcome") == "repaired": - print(f' Recovered while you were away: "{title}" had stopped working; its conversation continues in a new session.') - else: - print(f' Needs you: "{title}" stopped working and could not be recovered automatically.') -extra = len(unseen) - 3 -if extra > 0: - print(f" ({extra} more repair{'s' if extra != 1 else ''} in the log.)") -for item in entries: - if isinstance(item, dict): - item["acknowledged"] = True -tmp = f"{path}.tmp.{os.getpid()}" -try: - descriptor = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - json.dump({"schema_version": 1, "repairs": entries}, handle, indent=2, sort_keys=True) - handle.write("\n") - os.replace(tmp, path) -except OSError: - try: - os.unlink(tmp) - except OSError: - pass -PY -} - -render_main() { - local pending other_count unavailable_count - pending=$(recovery_count 2>/dev/null || printf '0') - [[ $pending =~ ^[0-9]+$ ]] || pending=0 - other_count=$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("outside_agents",[])))' "$VIEW" 2>/dev/null || printf '0') - [[ $other_count =~ ^[0-9]+$ ]] || other_count=0 - unavailable_count=$(python3 -c 'import json,sys; print(int((json.load(open(sys.argv[1])).get("_picker") or {}).get("unavailable_total") or 0))' "$VIEW" 2>/dev/null || printf '0') - [[ $unavailable_count =~ ^[0-9]+$ ]] || unavailable_count=0 - PAGE_SIZE=$(page_size "$pending" "$REPAIR_BANNER_LINES") - clamp_page - render_page - show_repair_banner - echo - local more_total=$(( other_count + pending + unavailable_count )) - printf ' Open: %s · New: %s · Kill: %s number\n' \ - "$(picker_green number)" "$(picker_green n)" "$(picker_green k)" - if (( more_total > 0 )); then - printf ' Terminal: %s · Search: %s · More: %s (%s) · Help: %s\n' \ - "$(picker_green Enter)" "$(picker_green /text)" "$(picker_green m)" \ - "$more_total" "$(picker_green '?')" - else - printf ' Terminal: %s · Search: %s · Help: %s\n' \ - "$(picker_green Enter)" "$(picker_green /text)" "$(picker_green '?')" - fi -} - -# Everything that used to clutter the main screen (recovery, other-provider, -# unavailable records) lives here behind one key. -show_more_menu() { - local pending other_count unavailable_count - pending=$(recovery_count 2>/dev/null || printf '0') - [[ $pending =~ ^[0-9]+$ ]] || pending=0 - other_count=$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("outside_agents",[])))' "$VIEW" 2>/dev/null || printf '0') - [[ $other_count =~ ^[0-9]+$ ]] || other_count=0 - unavailable_count=$(python3 -c 'import json,sys; print(int((json.load(open(sys.argv[1])).get("_picker") or {}).get("unavailable_total") or 0))' "$VIEW" 2>/dev/null || printf '0') - [[ $unavailable_count =~ ^[0-9]+$ ]] || unavailable_count=0 - while true; do - echo - echo " More" - if (( other_count > 0 )); then - printf ' %s Other provider sessions: %s (read-only view)\n' \ - "$(picker_green o)" "$other_count" +# Recovery pending count is refreshed with the snapshot, not on every +# keystroke: recovery state only changes through actions or refreshes, and +# recomputing it per redraw ran a full status build each keypress, most of +# the visible menu lag on a loaded box. +PENDING_CACHE=0 + +# Start from a clean frame: after a daemon restart the previous attachment's +# dead screen is still in the terminal, and menus painted over it read as +# corruption. Then treat a briefly-unavailable manager as a wait, not a +# failure, a planned restart takes a few seconds. +picker_clear_screen +if ! refresh_snapshot; then + echo " The session manager is not responding. Retrying for a minute." + SK_BOOT_OK=0 + # Short per-try bound: five 10s-capped refreshes keep the whole retry + # window near one minute even against a fully jammed manager. + for _sk_boot_try in 1 2 3 4 5; do + sleep 2 + if SK_REFRESH_TIMEOUT=10 refresh_snapshot; then + SK_BOOT_OK=1 + break fi - if (( pending > 0 )); then - printf ' %s Recovery: %s conversation%s to review\n' \ - "$(picker_green u)" "$pending" \ - "$([[ $pending == 1 ]] && printf '' || printf 's')" - fi - if (( unavailable_count > 0 )); then - printf ' Unavailable: %s session record%s without a live shell (no actions; a reaper clears them)\n' \ - "$unavailable_count" \ - "$([[ $unavailable_count == 1 ]] && printf '' || printf 's')" - fi - if (( other_count == 0 && pending == 0 && unavailable_count == 0 )); then - echo " Nothing extra right now." - fi - echo " Enter Back" - echo - local answer - picker_read answer " more ❯ " || return 0 - case "$answer" in - "") return 0 ;; - o|O) (( other_count > 0 )) && show_other_provider_sessions || echo " Nothing to show." ;; - u|U) (( pending > 0 )) && review_recovery || echo " Nothing to review." ;; - *) echo " Unknown choice. Use o, u, or Enter." ;; - esac done -} - -show_other_provider_sessions() { - python3 - "$VIEW" <<'PY' -import json -import os -import re -import shutil -import sys -import unicodedata - -with open(sys.argv[1], encoding="utf-8") as handle: - data = json.load(handle) -rows = data.get("outside_agents", []) -width = max(1, min(239, shutil.get_terminal_size(fallback=(100, 24)).columns - 1)) - -def clean(value): - text = "".join( - " " if unicodedata.category(character).startswith("C") else character - for character in str(value or "") - ) - return " ".join(text.split()) - -def cells(text): - return sum( - 0 if unicodedata.combining(character) - else 2 if unicodedata.east_asian_width(character) in {"W", "F"} - else 1 - for character in text - ) - -def shorten(text, room): - text = clean(text) - if cells(text) <= room: - return text - kept = [] - used = 0 - for character in text: - size = cells(character) - if used + size > max(0, room - 1): - break - kept.append(character) - used += size - return "".join(kept) + "…" - -def project_hint(value): - path = clean(value) - if not path.startswith("/"): - return "" - parts = [part for part in path.split("/") if part] - account_names = { - parts[index + 1].casefold() - for index, part in enumerate(parts[:-1]) - if part.casefold() in {"home", "users"} - } - account_names.add(os.path.basename(os.path.expanduser("~")).casefold()) - generic = { - "app", "apps", "code", "current", "home", "repo", "repos", "src", - "srv", "users", "v2", "var", "workspace", "workspaces", - } - for part in reversed(parts): - folded = part.casefold() - if ( - part - and not part.startswith(".") - and folded not in generic - and folded not in account_names - ): - return part - return "" - -def private_names(value): - path = clean(value) - parts = [part for part in path.split("/") if part] if path.startswith("/") else [] - names = { - parts[index + 1] - for index, part in enumerate(parts[:-1]) - if part.casefold() in {"home", "users"} - } - home_name = os.path.basename(os.path.expanduser("~")) - if home_name: - names.add(home_name) - return {name for name in names if name} - -def safe_title(value, cwd, fallback): - title = clean(value) - path = clean(cwd) - hint = project_hint(cwd) - if path: - title = title.replace(path, hint or fallback) - for name in sorted(private_names(cwd), key=len, reverse=True): - title = re.sub(re.escape(name), "account", title, flags=re.IGNORECASE) - return clean(title) or fallback - -print() -print(" Other provider sessions") -if data.get("stale"): - print(" Last-known provider roots outside the session manager; they are not confirmed live or attachable here.") -else: - print(" Detected live provider roots outside the session manager; they are not attachable here.") -if not rows: - print(" No matching other provider sessions.") -else: - previous_provider = None - labels = {"claude": "Claude", "codex": "Codex"} - for row in rows: - provider = labels.get( - str(row.get("display_provider") or row.get("provider") or "").casefold(), - "Unknown", - ) - if provider != previous_provider: - print(f" {provider}") - previous_provider = provider - hint = project_hint(row.get("cwd")) - title = safe_title( - row.get("display_title") or row.get("title"), - row.get("cwd"), - provider, - ) - detail = "not attachable here" - if hint: - detail = f"project: {hint} | {detail}" - prefix = " - " - title_room = max(1, width - cells(prefix) - cells(detail) - 3) - print(prefix + shorten(title, title_room) + " | " + detail) -print() -PY - local ignored - picker_read ignored " Enter: Back ❯ " || return 0 -} - -number_metadata() { - local number=$1 - python3 - "$VIEW" "$number" <<'PY' -import base64 -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - data = json.load(handle) -wanted = int(sys.argv[2]) -matches = [ - row - for row in data.get("sessions", []) - if row.get("terminal_number") == wanted -] -if len(matches) != 1: - raise SystemExit(2) -row = matches[0] -values = ( - row.get("availability"), - "true" if row.get("mutation_allowed") is True else "false", - row.get("display_shpool_id"), - row.get("provider"), - row.get("title"), - row.get("shpool_id_raw"), - (row.get("identity") or {}).get("uuid"), - (row.get("identity") or {}).get("confidence"), -) -for value in values: - print(base64.b64encode(str(value or "").encode()).decode()) -PY -} - -decode64() { - printf '%s' "$1" | base64 -d -} - -create_proof() { - local number=$1 - python3 - "$VIEW" "$number" "$SK_STATE_DIR" <<'PY' -import json -import os -from pathlib import Path -import stat -import sys -import tempfile - -view_path, number_text, state_dir = sys.argv[1:4] -with open(view_path, encoding="utf-8") as handle: - data = json.load(handle) -if data.get("stale") or data.get("source") != "live": - raise SystemExit(3) -wanted = int(number_text) -matches = [ - row - for row in data.get("sessions", []) - if row.get("terminal_number") == wanted -] -if len(matches) != 1: - raise SystemExit(2) -row = matches[0] -if row.get("mutation_allowed") is not True: - raise SystemExit(3) -identity = row.get("identity") or {} -shell = row.get("shpool_shell") or {} -daemon = data.get("daemon_generation") or {} -proof = { - "daemon_pid": daemon.get("pid"), - "daemon_process_start_ticks": daemon.get("process_start_ticks"), - "proof_type": "session-kit-picker-session-v1", - "provider": row.get("provider"), - "provider_pid": identity.get("pid"), - "provider_process_start_ticks": identity.get("process_start_ticks"), - "schema_version": 1, - "shell_pid": shell.get("pid"), - "shell_process_start_ticks": shell.get("process_start_ticks"), - "shpool_id": row.get("shpool_id_raw"), - "started_at_unix_ms": row.get("started_at_unix_ms"), - "uuid": identity.get("uuid") or "", -} -payload = (json.dumps(proof, sort_keys=True, separators=(",", ":")) + "\n").encode() -descriptor, name = tempfile.mkstemp(prefix="picker-proof.", suffix=".json", dir=state_dir) -try: - os.fchmod(descriptor, 0o600) - with os.fdopen(descriptor, "wb") as handle: - descriptor = -1 - handle.write(payload) - handle.flush() - os.fsync(handle.fileno()) - metadata = os.lstat(name) - if ( - not stat.S_ISREG(metadata.st_mode) - or stat.S_IMODE(metadata.st_mode) != 0o600 - or metadata.st_uid != os.geteuid() - ): - raise OSError("unsafe proof") - print(name) -except BaseException: - if descriptor >= 0: - os.close(descriptor) - try: - os.unlink(name) - except OSError: - pass - raise -PY -} - -run_proof_action() { - local command=$1 number=$2 - shift 2 - local proof status - proof=$(create_proof "$number") || { - echo " The displayed session is stale or unsafe. Nothing changed." - return 1 - } - TEMP_FILES+=("$proof") - "$SP_CMD" "$command" "$proof" "$@" - status=$? - command rm -f -- "$proof" - if (( status != 0 )); then - # The caller explains the failure itself when it has something better to - # say than a bare refusal. - if [[ ${PROOF_ACTION_QUIET:-0} != 1 ]]; then - echo " The action was refused. Nothing changed." - fi - return "$status" + if (( SK_BOOT_OK == 0 )); then + echo "session-kit: the session manager did not answer; the picker could not start." >&2 + picker_exit startup_refresh_failed fi -} +fi +# Only ever set by this picker's own self-upgrade, and consumed here so the +# window comes back to the list it was showing. +picker_resume_view -refresh_after_action() { - # A failed refresh must never kill the picker: the previous snapshot stays - # on screen (marked stale, actions disabled) and the next loop retries. - if ! refresh_snapshot; then - sk_log_action picker_refresh failed || true - echo " Live sessions could not be refreshed; showing the previous view." - fi -} +# Compute-first frames: renders are captured here and emitted as one write +# (picker_frame_emit_file), so a slow render never paints progressively. +PICKER_FRAME_FILE="" +if new_temp login-frame; then PICKER_FRAME_FILE=$NEW_TEMP; fi -choose_number() { - local number=$1 metadata - require_live_actions || return - number_on_page "$number" || { - echo " Choose a number shown here. Nothing changed." - return - } - metadata=$(number_metadata "$number") || { - echo " The displayed row changed format. Nothing changed." - return - } - local -a fields=() - mapfile -t fields <<<"$metadata" - if (( ${#fields[@]} != 8 )); then - echo " The displayed row changed format. Nothing changed." - return - fi - local availability mutation display_id provider title - availability=$(decode64 "${fields[0]}") - mutation=$(decode64 "${fields[1]}") - display_id=$(decode64 "${fields[2]}") - provider=$(decode64 "${fields[3]}") - title=$(decode64 "${fields[4]}") - if [[ $mutation != true ]]; then - printf ' %s is display-only. Nothing changed.\n' "$display_id" - return - fi - if [[ $availability == ready ]]; then - # The window's tab carries the thread name for the whole attachment — - # the only in-window name surface Codex has (Claude's own title hook - # re-asserts the same name in-session). Cleared again on return. - if [[ -t 1 && -n $title ]]; then - printf '\033]0;%s\007' "$title" +while true; do + picker_self_upgrade + if [[ -n $PICKER_FRAME_FILE && -f $PICKER_FRAME_FILE ]]; then + # Same width pinning as picker_redraw_home: stdout is a file during the + # capture, so probes that ioctl stdout would lay rows out for a 100-col + # fallback terminal. tput reads the real size from stderr. + # The kernel's winsize on stderr is the one honest answer here: macOS + # tput answers 80 from terminfo when it cannot ioctl the captured stdout, + # and an exported COLUMNS can be stale after a resize. A pty that was + # never sized reports 0x0, treat 0 as no answer, then fall back to tput + # (ncurses honours $COLUMNS), then to the exported size itself. + __sk_size=$(command stty size <&2 2>/dev/null) || __sk_size= + __sk_cols=${__sk_size##* }; __sk_rows=${__sk_size%% *} + [[ $__sk_cols =~ ^[0-9]+$ && $__sk_cols -gt 0 ]] || __sk_cols= + [[ $__sk_rows =~ ^[0-9]+$ && $__sk_rows -gt 0 ]] || __sk_rows= + if [[ -z $__sk_cols ]]; then + __sk_cols=$(tput cols 2>/dev/null) && [[ $__sk_cols =~ ^[0-9]+$ ]] || __sk_cols= fi - local open_status=0 - PROOF_ACTION_QUIET=1 run_proof_action picker-open "$number" || open_status=$? - case "$open_status" in - 0) ;; - "$PICKER_REFUSED_STATUS") - echo " The displayed session changed or failed a safety check. Nothing changed." - ;; - "$PICKER_ATTACH_FAILED_STATUS") - echo " The session manager could not connect. This does not prove the session is dead." - echo " The session was left alone; refresh, inspect its history, or try again." - ;; - *) - echo " The open command failed without a verified cause. The session was left alone." - ;; - esac - if [[ -t 1 ]]; then - printf '\033]0;%s\007' "session kit" + if [[ -z $__sk_rows ]]; then + __sk_rows=$(tput lines 2>/dev/null) && [[ $__sk_rows =~ ^[0-9]+$ ]] || __sk_rows= fi - refresh_after_action - return - fi - - while true; do - echo - printf ' %s [%s]\n' "$title" "$display_id" - echo " Already open in another SSH window." - echo - echo " 1 Move it here (the other window disconnects)" - echo " 2 View full terminal history" - echo " 3 Close it (the shell and everything inside will end)" - echo " Enter Back" - echo - local action - picker_read action " action ❯ " || return 0 - case "$action" in - "") return ;; - 1) - run_proof_action picker-takeover "$number" || true - refresh_after_action - return - ;; - 2) - run_proof_action picker-history "$number" || true - refresh_after_action - return - ;; - 3) - run_proof_action picker-close "$number" || true - refresh_after_action - return - ;; - *) echo " Unknown choice. Nothing changed." ;; - esac - done -} - -direct_close() { - local number=$1 - require_live_actions || return - number_on_page "$number" || { - echo " Choose a number shown here. Nothing changed." - return - } - run_proof_action picker-close "$number" || true - refresh_after_action -} - -rename_number() { - local number=$1 metadata provider - require_live_actions || return - number_on_page "$number" || { - echo " Choose a number shown here. Nothing changed." - return - } - metadata=$(number_metadata "$number") || return - local -a fields=() - mapfile -t fields <<<"$metadata" - if (( ${#fields[@]} != 8 )); then - echo " The displayed row changed format. Nothing changed." - return - fi - provider=$(decode64 "${fields[3]}") - if [[ $provider != claude && $provider != codex ]]; then - echo " Only Claude and Codex conversations can have a custom name." - return - fi - local title - picker_read title " New name (Enter: cancel) ❯ " || return 0 - [[ -n ${title//[[:space:]]/} ]] || { - echo " Rename cancelled." - return - } - run_proof_action picker-name "$number" "$title" || true - refresh_after_action -} - -reset_name_number() { - local number=$1 metadata provider uuid confidence - require_live_actions || return - number_on_page "$number" || { - echo " Choose a number shown here. Nothing changed." - return - } - metadata=$(number_metadata "$number") || return - local -a fields=() - mapfile -t fields <<<"$metadata" - if (( ${#fields[@]} != 8 )); then - echo " The displayed row changed format. Nothing changed." - return - fi - provider=$(decode64 "${fields[3]}") - uuid=$(decode64 "${fields[6]}") - confidence=$(decode64 "${fields[7]}") - if [[ ( $provider != claude && $provider != codex ) || - $confidence != exact || -z $uuid ]]; then - echo " Only exact Claude and Codex conversations can reset a custom name." - return - fi - run_proof_action picker-name-reset "$number" || true - refresh_after_action -} - -fork_number() { - local number=$1 metadata provider uuid confidence - require_live_actions || return - number_on_page "$number" || { - echo " Choose a number shown here. Nothing changed." - return - } - metadata=$(number_metadata "$number") || return - local -a fields=() - mapfile -t fields <<<"$metadata" - if (( ${#fields[@]} != 8 )); then - echo " The displayed row changed format. Nothing changed." - return - fi - provider=$(decode64 "${fields[3]}") - uuid=$(decode64 "${fields[6]}") - confidence=$(decode64 "${fields[7]}") - if [[ ( $provider != claude && $provider != codex ) || - $confidence != exact || -z $uuid ]]; then - echo " Only exact Claude and Codex conversations can be forked." - return - fi - run_proof_action picker-fork "$number" || true - refresh_after_action -} - -project_file() { - local destination=$1 - python3 - "$SK_PROJECTS_FILE" "$destination" <<'PY' -import json -import os -from pathlib import Path -import re -import sys -import unicodedata - -source, destination = map(Path, sys.argv[1:3]) -projects = [] -if source.is_file() and not source.is_symlink(): - for line in source.read_text(encoding="utf-8").splitlines(): - if not line.strip() or line.lstrip().startswith("#"): - continue - fields = line.split("\t") - if len(fields) < 3: - continue - alias, configured_provider, cwd = fields[:3] - if ( - re.fullmatch(r"[a-z0-9_-]+", alias) - and configured_provider in {"claude", "codex", "shell"} - and cwd.startswith("/") - and not any( - unicodedata.category(character).startswith("C") - for character in cwd - ) - and os.path.isdir(cwd) - ): - projects.append({"alias": alias, "cwd": cwd}) -default = next((index for index, row in enumerate(projects, 1) if row["alias"] == "sl"), 1 if projects else None) -with destination.open("w", encoding="utf-8") as handle: - json.dump({"projects": projects, "default": default}, handle, sort_keys=True, separators=(",", ":")) - handle.write("\n") - handle.flush() - os.fsync(handle.fileno()) -os.chmod(destination, 0o600) -PY -} - -guided_new() { - require_live_actions || return - echo - echo " New session" - echo " 1 Claude Code" - echo " 2 Codex" - echo " 3 Regular managed shell" - echo " Enter Back" - echo - local answer provider - picker_read answer " provider ❯ " || return 0 - case "$answer" in - "") return ;; - 1) provider=claude ;; - 2) provider=codex ;; - 3) provider=shell ;; - *) echo " Unknown choice. Nothing changed."; return ;; - esac - - local projects - new_temp login-projects || return - projects=$NEW_TEMP - project_file "$projects" || { - echo " Project list is unavailable. Nothing started." - return - } - echo - python3 - "$projects" <<'PY' -import json,sys,unicodedata -d=json.load(open(sys.argv[1])) -default=d.get("default") -print(" Project") -for number,row in enumerate(d.get("projects",[]),1): - print(f" {number:2} {row['alias']}: {row['cwd']}") -if default: - row=d["projects"][default-1] - print(f" Enter Use {row['alias']}: {row['cwd']} | b Back") -else: - print(" Enter Current directory | b Back") -PY - echo - local project_choice project_alias - picker_read project_choice " project ❯ " || return 0 - [[ $project_choice != b && $project_choice != B ]] || return - project_alias=$(python3 - "$projects" "$project_choice" <<'PY' -import json,sys -d=json.load(open(sys.argv[1])) -choice=sys.argv[2].strip() -if not choice: - default=d.get("default") - if not default: - print("") - raise SystemExit(0) - choice=str(default) -if not choice.isdigit(): - raise SystemExit(2) -number=int(choice) -rows=d.get("projects",[]) -if number < 1 or number > len(rows): - raise SystemExit(2) -print(rows[number-1]["alias"]) -PY -) || { - echo " Unknown project. Nothing started." - return - } - if [[ $provider == claude || $provider == codex ]]; then - echo " After exact startup proof, use name <number> to assign an optional conversation name." - fi - if [[ -n $project_alias ]]; then - "$SP_CMD" new "$provider" "$project_alias" || true + COLUMNS=${__sk_cols:-${COLUMNS:-}} LINES=${__sk_rows:-${LINES:-}} \ + render_main > "$PICKER_FRAME_FILE" + picker_frame_emit_file "$PICKER_FRAME_FILE" append else - "$SP_CMD" new "$provider" || true + render_main fi - refresh_after_action -} - -recovery_list() { - local fresh - new_temp login-recovery || return 1 - fresh=$NEW_TEMP - "$STATUS_CMD" --recovery-pending-list > "$fresh" || { - command rm -- "$fresh" - return 1 - } - RECOVERY=$fresh -} - -recovery_count() { - local payload - payload=$("$STATUS_CMD" --recovery-pending-list) || return 1 - python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("entries",[])))' \ - <<<"$payload" -} - -active_target_for_uuid() { - local provider=$1 uuid=$2 - python3 - "$SNAPSHOT" "$provider" "$uuid" <<'PY' -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - data = json.load(handle) -wanted_provider = sys.argv[2] -wanted = sys.argv[3].casefold() -managed = [] -outside = [] -for row in data.get("sessions", []): - identity = row.get("identity") or {} - if ( - row.get("provider") == wanted_provider - and str(identity.get("uuid") or "").casefold() == wanted - and identity.get("confidence") == "exact" - ): - number = row.get("terminal_number") - if ( - isinstance(number, int) - and not isinstance(number, bool) - and number > 0 - ): - managed.append(number) -for row in data.get("outside_agents", []): - identity = row.get("identity") or {} - if ( - row.get("provider") == wanted_provider - and str(identity.get("uuid") or "").casefold() == wanted - and identity.get("confidence") == "exact" - ): - outside.append(row) -if len(managed) == 1 and not outside: - print(f"managed:{managed[0]}") -elif not managed and len(outside) == 1: - print("outside") -elif managed or outside: - print("ambiguous") -else: - print("none") -PY -} - -review_recovery() { - require_live_actions || return - recovery_list || { - echo " Recovery records are unavailable. Nothing changed." - return - } - local count - count=$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("entries",[])))' "$RECOVERY") - if (( count == 0 )); then - echo " No conversations are waiting for recovery." - return - fi - echo - echo " Conversations available for exact recovery" - python3 - "$RECOVERY" <<'PY' -import json,sys,unicodedata -for number,row in enumerate(json.load(open(sys.argv[1])).get("entries",[]),1): - provider=str(row.get("provider") or "unknown").title() - clean=lambda value: " ".join( - "".join( - " " if unicodedata.category(character).startswith("C") else character - for character in str(value or "") - ).split() - ) - title=clean(row.get("title")) - old_id=clean(row.get("display_old_shpool_id")) - print(f" {number:2} [{provider}] {title} [{old_id}]") -PY - echo - echo " numbers/ranges: restore selected | a: restore all" - echo " Enter: Back" - echo - local answer selected - picker_read answer " recovery ❯ " || return 0 - [[ -n $answer ]] || return - selected=$(python3 - "$RECOVERY" "$answer" <<'PY' -import json,sys -entries=json.load(open(sys.argv[1])).get("entries",[]) -answer=sys.argv[2].strip().lower() -if answer == "a": - chosen=list(range(1,len(entries)+1)) -else: - values=set() - for part in answer.split(","): - part=part.strip() - if not part: - raise SystemExit(2) - if "-" in part: - first,last=part.split("-",1) - if not first.isdigit() or not last.isdigit(): - raise SystemExit(2) - a,b=int(first),int(last) - if a > b: - raise SystemExit(2) - values.update(range(a,b+1)) - elif part.isdigit(): - values.add(int(part)) - else: - raise SystemExit(2) - chosen=sorted(values) -if any(number < 1 or number > len(entries) for number in chosen): - raise SystemExit(2) -print("\n".join(map(str,chosen))) -PY -) || { - echo " Invalid recovery selection. Nothing restored." - return - } - - local number encoded generation old_id provider uuid cwd launched attempt acknowledged active_target active_number - local -a values=() - while IFS= read -r number; do - [[ $number =~ ^[0-9]+$ ]] || continue - encoded=$(python3 - "$RECOVERY" "$number" <<'PY' -import base64,json,sys -row=json.load(open(sys.argv[1])).get("entries",[])[int(sys.argv[2])-1] -for key in ("source_generation_key","old_shpool_id","provider","uuid","cwd"): - print(base64.b64encode(str(row.get(key) or "").encode()).decode()) -PY -) - mapfile -t values <<<"$encoded" - if (( ${#values[@]} != 5 )); then - printf ' Recovery record %s is incomplete; it was retained.\n' "$number" >&2 - continue - fi - generation=$(decode64 "${values[0]}") - old_id=$(decode64 "${values[1]}") - provider=$(decode64 "${values[2]}") - uuid=$(decode64 "${values[3]}") - cwd=$(decode64 "${values[4]}") - active_target=$(active_target_for_uuid "$provider" "$uuid") || active_target=ambiguous - case "$active_target" in - managed:*) - active_number=${active_target#managed:} - if "$STATUS_CMD" --recovery-pending-ack \ - "$generation" "$old_id" "$uuid" >/dev/null 2>&1; then - printf ' Cleared the recovery record for exact active session %s.\n' \ - "$active_number" - else - printf ' Exact active session %s was found, but its recovery record could not be cleared; the record was retained.\n' \ - "$active_number" >&2 - fi - printf ' %s is already active as session %s; opening the existing session instead.\n' \ - "$old_id" "$active_number" - printf ' Use fork %s from the picker to start a separate writable fork.\n' \ - "$active_number" - QUERY=$uuid - PAGE=1 - if build_view; then - choose_number "$active_number" - else - echo " The existing session could not be shown. Nothing restored." - fi - continue - ;; - outside) - printf ' %s is already active outside the session manager; no duplicate was started.\n' \ - "$old_id" - continue - ;; - ambiguous) - printf ' Active identity for %s is ambiguous; no duplicate was started.\n' \ - "$old_id" - continue - ;; - esac - if launched=$("$SP_CMD" restore-exact "$provider" "$uuid" "$cwd"); then - printf ' Started %s as %s\n' "$old_id" "$launched" - acknowledged=0 - for attempt in {1..20}; do - if "$STATUS_CMD" --recovery-pending-ack \ - "$generation" "$old_id" "$uuid" >/dev/null 2>&1; then - acknowledged=1 - break - fi - sleep 0.25 - done - if (( acknowledged == 0 )); then - printf ' Exact live proof is pending for %s; its recovery record was retained.\n' "$old_id" >&2 - fi - else - printf ' Could not restore %s; its recovery record was retained.\n' "$old_id" >&2 - fi - done <<<"$selected" - refresh_after_action -} - -show_help() { - echo - echo " Session picker help" - echo - echo " number Open an available session or show actions for one open elsewhere" - echo " n Guided Claude, Codex, or managed-shell creation" - echo " k <number> Kill the exact displayed session after exact-ID confirmation" - echo " x <number> Compatibility alias for k" - echo " /text Search names, providers, projects, IDs, and conversation UUIDs" - echo " r Refresh live state and clear search" - echo " o View detectable provider roots outside the session manager (read-only)" - echo " u Review exact conversation recovery; opening it changes nothing" - echo " name <number> Rename an exact Claude or Codex conversation" - echo " name reset # Remove the local name and show the provider name" - echo " fork <number> Start a separate fork of an exact Claude or Codex conversation" - echo " next / prev Move between pages; only visible page numbers accept actions" - echo " Enter / EOF Return to a regular terminal without a session action" - echo " Ctrl-C Return to a regular terminal without a session action" - echo - echo " Inside a managed session: use bye to close it, or disconnect SSH to leave it running." - echo " Full raw terminal history is available from an open-session action menu." -} - -load_repair_banner - -refresh_snapshot || { - echo " Live sessions could not be loaded. Returning to a regular terminal." - exit 2 -} - -# A menu window can sit open across release rollovers, silently running old -# code against new state — every fix ships and the standing windows never see -# it. Replace this picker with the current release at the next safe point. -picker_self_upgrade() { - [[ -n ${SESSION_KIT_RELEASE_DIR:-} ]] || return 0 - local launcher="$HOME/.local/bin/shpool_login" current - [[ -x $launcher ]] || return 0 - current=$(cd -P -- "${SESSION_KIT_ROOT:-$HOME/.local/lib/session-kit}/current" 2>/dev/null && pwd) || return 0 - [[ -n $current && $current != "$SESSION_KIT_RELEASE_DIR" ]] || return 0 - sk_log_action picker_exit release_upgrade || true - cleanup - exec "$launcher" -} - -while true; do - picker_self_upgrade - render_main choice="" - picker_read choice " $(picker_green '❯') " || continue + picker_read_live choice " $(picker_green '❯') " + live_status=$? + (( live_status == 0 )) || continue + # One frame per keystroke. The menu used to be appended below whatever was + # already on the screen, so a working session ended up as a column of stale + # menus -- while the background repaint replaced the screen in place, making + # the same key leave a different screen depending on when it was pressed. + # Clearing here, after the key is read and before it is acted on, gives + # every keystroke the same shape: what the action says, then the menu. + picker_clear_screen case "$choice" in - "") picker_exit terminal_requested ;; - q|Q|p|P) picker_exit quit ;; + "") + # Enter takes the most likely option on every screen (operator ruling, + # 2026-08-16). Here that is the top row of the list as drawn -- the + # session sorted most in need -- and on an empty list, a new session. + # The footer names the number it will open, from the same page slice. + if number=$(first_number_on_page); then + choose_number "$number" + else + guided_new + fi + ;; + # The words people type when a key does not come to mind. `exit` and + # `quit` meant nothing here, which is a strange thing for a menu whose + # whole job is leaving. `p` is not among them: it opens Projects below, + # one meaning per key. + # `b` is Back on every screen in the picker. This is the top screen, so + # back from here is out -- the same door q already opens, not a new one. + q|Q|quit|QUIT|Quit|exit|EXIT|Exit|b|B|back|BACK|Back) picker_exit quit ;; r|R) refresh_snapshot || { sk_log_action picker_refresh failed || true - echo " Live sessions could not be refreshed; showing the previous view." + echo " Live refresh failed. Showing the last confirmed list." } ;; m|M) show_more_menu ;; + p|P) + # p used to be a second way to leave, one keystroke away from the More + # menu where p means Projects. One meaning per key: q leaves. + show_projects_menu + ;; + a|A) + show_attention_menu + ;; + g|G) + jump_next_attention + ;; + x|X) + toggle_machine_sessions + ;; + c|C|compact|COMPACT|Compact) + toggle_compact + ;; + group|GROUP|Group) + cycle_grouping + ;; + group\ *|GROUP\ *|Group\ *) + cycle_grouping "${choice#* }" + ;; + h|H) + echo " That needs a session number, such as h 19. Numbers shown here work." + ;; + h[0-9]*|H[0-9]*) + # History straight from the list: playback of the recording when one + # exists, the conversation transcript when it does not. + history_number "${choice#[hH]}" + ;; + h\ *|H\ *) + history_number "${choice#* }" + ;; + k[0-9]*|K[0-9]*) + # A row key takes its number with or without the space: k5 and k 5 are + # the same close. + direct_close "${choice#[kK]}" + ;; \?) show_help ;; @@ -1727,59 +349,69 @@ while true; do echo " Already on the first page." fi ;; + name\ reset[0-9]*|NAME\ RESET[0-9]*|Name\ reset[0-9]*) + number=${choice##*[rR][eE][sS][eE][tT]} + reset_name_number "$number" + ;; /*) QUERY=${choice#/} PAGE=1 build_view || { - echo " Search failed. Returning to the unfiltered list." + echo " The search failed. Showing the whole list." QUERY="" build_view || { sk_log_action picker_refresh failed || true - echo " The list could not be rebuilt; showing the previous view." + echo " Live refresh failed. Showing the last confirmed list." } } ;; - k\ *|K\ *|x\ *|X\ *) - number=${choice#* } - if [[ $number =~ ^[0-9]+$ ]]; then - direct_close "$number" - else - echo " Use k followed by a visible number. Nothing changed." - fi + k\ *|K\ *) + direct_close "${choice#* }" ;; name\ reset\ *|NAME\ RESET\ *|Name\ reset\ *) number=${choice##* } if [[ $number =~ ^[0-9]+$ ]]; then reset_name_number "$number" else - echo " Use name reset followed by a visible number. Nothing changed." + echo " That needs a session number, such as name reset 19. Numbers shown here work." fi ;; - name\ *|NAME\ *|Name\ *) + name\ *|NAME\ *|Name\ *|name[0-9]*|NAME[0-9]*|Name[0-9]*) number=${choice#* } + number=${number#[nN][aA][mM][eE]} if [[ $number =~ ^[0-9]+$ ]]; then rename_number "$number" else - echo " Use name followed by a visible number. Nothing changed." + echo " That needs a session number, such as name 19. Numbers shown here work." fi ;; - fork\ *|FORK\ *|Fork\ *) + fork\ *|FORK\ *|Fork\ *|fork[0-9]*|FORK[0-9]*|Fork[0-9]*) number=${choice#* } + number=${number#[fF][oO][rR][kK]} if [[ $number =~ ^[0-9]+$ ]]; then fork_number "$number" else - echo " Use fork followed by a visible number. Nothing changed." + echo " That needs a session number, such as fork 19. Numbers shown here work." + fi + ;; + model\ *|MODEL\ *|Model\ *|model[0-9]*|MODEL[0-9]*|Model[0-9]*) + number=${choice#* } + number=${number#[mM][oO][dD][eE][lL]} + if [[ $number =~ ^[0-9]+$ ]]; then + change_model_number "$number" + else + echo " That needs a session number, such as model 19. Numbers shown here work." fi ;; [0-9]*) if [[ $choice =~ ^[0-9]+$ ]]; then choose_number "$choice" else - echo " Unknown choice. Nothing changed." + picker_unknown_choice "$PICKER_MAIN_KEYS" fi ;; *) - echo " Unknown choice. Nothing changed." + picker_unknown_choice "$PICKER_MAIN_KEYS" ;; esac done diff --git a/bin/shpool_login_launcher b/bin/shpool_login_launcher new file mode 100755 index 0000000..7c4f1d8 --- /dev/null +++ b/bin/shpool_login_launcher @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# The picker's front door, and its way back. +# +# The new screen runs only where someone turned it on: `SESSION_KIT_TUI=on` +# in the environment, or a `tui-on` file in the state directory. Everywhere +# else a login window gets the picker that shipped before it. Where the new +# screen is on and ends any way other than a person deliberately leaving, +# this records one line and hands the window to that same picker, so a screen +# that cannot draw is a worse picker for one login rather than a login with +# no picker at all. + +set -u + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +TUI=${SESSION_KIT_TUI_CMD:-"$SCRIPT_DIR/shpool_login_tui"} +FALLBACK=${SESSION_KIT_PICKER_FALLBACK_CMD:-"$SCRIPT_DIR/shpool_login"} +STATE_DIR=${SESSION_KIT_STATE_DIR:-${XDG_STATE_HOME:-"$HOME/.local/state"}/session-kit} +LOG=${SESSION_KIT_TUI_FALLBACK_LOG:-"$STATE_DIR/tui-fallback.log"} + +tui_switch=${SESSION_KIT_TUI:-} +if [[ -z $tui_switch && -e "$STATE_DIR/tui-on" ]]; then + tui_switch=on +fi + +if [[ $tui_switch != on || ! -x $TUI ]]; then + exec "$FALLBACK" "$@" +fi + +mkdir -p -- "$STATE_DIR" 2>/dev/null || true +stderr_root=$(mktemp -d "$STATE_DIR/tui-stderr.XXXXXXXX" 2>/dev/null || true) +stderr_capture= +stderr_pipe= +stderr_drain= +if [[ -n $stderr_root ]]; then + chmod 700 "$stderr_root" 2>/dev/null || true + stderr_capture=$stderr_root/tail + stderr_pipe=$stderr_root/stream + if command mkfifo -- "$stderr_pipe" 2>/dev/null; then + # Forward stderr as it arrives and retain only its useful tail. This is a + # shell reader so a login PATH without python3 cannot strand the FIFO. + # The parent gives it a short bounded drain below; a picker descendant + # that inherited stderr therefore cannot hold the login window forever. + drain_stderr() { + local chunk= size=0 chunk_size=0 keep=0 + local limit=$((4 * 1024 * 1024)) trim=$stderr_capture.trim + : > "$stderr_capture" || return + exec 3< "$stderr_pipe" || return + while IFS= read -r -N 65536 chunk <&3 || [[ -n $chunk ]]; do + printf '%s' "$chunk" >&2 + size=$(stat -c %s "$stderr_capture" 2>/dev/null || echo 0) + chunk_size=$(printf '%s' "$chunk" | wc -c) + if (( size + chunk_size > limit )); then + keep=$((limit - chunk_size)) + (( keep < 0 )) && keep=0 + if command tail -c "$keep" -- "$stderr_capture" > "$trim" 2>/dev/null; then + command mv -f -- "$trim" "$stderr_capture" || : > "$stderr_capture" + else + : > "$stderr_capture" + fi + fi + printf '%s' "$chunk" >> "$stderr_capture" || return + chunk= + done + exec 3<&- + command rm -f -- "$trim" + } + drain_stderr & + stderr_drain=$! + else + command rmdir -- "$stderr_root" 2>/dev/null || true + stderr_root= + fi +fi +if [[ -n $stderr_drain ]]; then + "$TUI" "$@" 2> "$stderr_pipe" + status=$? + # Normally the FIFO reaches EOF immediately. Give pending bytes a bounded + # grace period, then stop the reader if a detached child still owns stderr. + for (( drain_poll = 0; drain_poll < 20; drain_poll++ )); do + kill -0 "$stderr_drain" 2>/dev/null || break + sleep 0.01 + done + kill "$stderr_drain" 2>/dev/null || true + wait "$stderr_drain" 2>/dev/null || true +else + "$TUI" "$@" + status=$? +fi + +# Leaving on purpose is the one status that means the screen did its job. +if (( status == 0 )); then + if [[ -n $stderr_root ]]; then + command rm -f -- "$stderr_capture" "$stderr_pipe" + command rmdir -- "$stderr_root" 2>/dev/null || true + fi + exit 0 +fi + +mkdir -p -- "$(dirname -- "$LOG")" 2>/dev/null || true +stderr_text=- +if [[ -n $stderr_capture ]]; then + # Preserve the useful end of a traceback without copying unbounded picker + # output into a shell variable or one fallback-log record. + stderr_text=$(command tail -c 4096 -- "$stderr_capture" 2>/dev/null) + stderr_text=${stderr_text//\\/\\\\} + stderr_text=${stderr_text//$'\t'/\\t} + stderr_text=${stderr_text//$'\r'/\\r} + stderr_text=${stderr_text//$'\n'/\\n} + [[ -n $stderr_text ]] || stderr_text=- + command rm -f -- "$stderr_capture" "$stderr_pipe" + command rmdir -- "$stderr_root" 2>/dev/null || true +fi +printf '%s\texit %s\t%s\tstderr=%s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$status" "$TUI" "$stderr_text" \ + >> "$LOG" 2>/dev/null || true + +exec "$FALLBACK" "$@" diff --git a/bin/shpool_login_tui b/bin/shpool_login_tui new file mode 100755 index 0000000..ce0ddf8 --- /dev/null +++ b/bin/shpool_login_tui @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""The picker. + +One list of sessions, driven by the arrow keys, numbers, and the mouse. All +of its behaviour lives in ../lib/sessionkit_tui; this file only points Python +at that package and reports the exit code the launcher reads. +""" + +from __future__ import annotations + +import os +import sys + +_HERE = os.path.dirname(os.path.realpath(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(_HERE), "lib")) + +from sessionkit_tui.app import main # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bin/shpool_reaper b/bin/shpool_reaper index a34a06b..dea8821 100755 --- a/bin/shpool_reaper +++ b/bin/shpool_reaper @@ -1,9 +1,13 @@ #!/usr/bin/env bash # Safe session-kit reaper. # -# The normal report records verified seven-day empty-shell candidates for -# confirmed `sp prune`. `--auto-close` is narrower and may close only a -# generated, provider-exited terminal after 72 continuous hours of exact proof. +# The normal report records verified idle empty-shell candidates for confirmed +# `sp prune`, seven days by default, SESSION_KIT_PRUNE_DAYS otherwise, and the +# window travels with the list it produces. `--auto-close` is narrower, is now +# a BACKSTOP (see WHAT --auto-close IS FOR NOW, under the variable block), and +# may close only a generated, provider-exited terminal after 72 continuous +# hours of exact proof, plus a shell-less manager phantom on its first complete +# absence proof. Neither touches a terminal whose record says keep. set -u @@ -15,42 +19,168 @@ if [[ ${SESSION_KIT_TESTING:-0} == 1 && -n ${SESSION_KIT_TEST_PLATFORM:-} ]]; th else REAPER_PLATFORM=$(uname -s 2>/dev/null) fi -if [[ $REAPER_PLATFORM == Darwin ]]; then - echo "session-kit: reaper is unavailable in the macOS preview; nothing changed" >&2 - exit 1 -fi -unset REAPER_PLATFORM - STATE_DIR=${SESSION_KIT_STATE_DIR:-${XDG_STATE_HOME:-"$HOME/.local/state"}/session-kit} -PROC_ROOT=${SESSION_KIT_PROC_ROOT:-/proc} +# The reaper closes terminals, and /proc is the evidence it closes them on. A +# fixture root is honoured only inside the isolated test harness; production +# reads the real process table so no environment variable can dress up a live +# session as a seven-day corpse. +if [[ ${SESSION_KIT_TESTING:-0} == 1 && -n ${SESSION_KIT_PROC_ROOT:-} ]]; then + PROC_ROOT=$SESSION_KIT_PROC_ROOT +else + PROC_ROOT=/proc +fi SENTINEL=${SESSION_KIT_REAPER_SENTINEL:-"$HOME/.no_shpool_reaper"} MAX_AGE_DAYS=${SESSION_KIT_PRUNE_DAYS:-7} SHPOOL=${SESSION_KIT_SHPOOL_CMD:-} DRY_RUN=${SESSION_KIT_REAPER_DRY_RUN:-0} +sweep_subagents() { + # The operator's off switch stops THIS too. It used to, only because the + # sweep sat below the sentinel check on the hourly path; giving the sweep its + # own entry point walked the most destructive pass in the kit out from behind + # the one file they reach for when something gets closed (lane A F1). The + # check lives inside the function now, so it covers every caller by + # construction rather than by where the call happens to sit. + if [[ -e $SENTINEL ]]; then + printf '%s sub-agent sweep disabled by %s\n' "$(timestamp)" "$SENTINEL" >&2 + return 3 + fi + # Completion means closure (X20): close provider sub-agent worker processes + # whose own output transcript has stayed unchanged past the threshold. Runs + # first and best-effort -- zombie workers are independent of the shpool + # inventory, so a broken guard-live snapshot must not keep them alive, and + # a sweep failure must never block the terminal reaper sharing this timer. + # On the hourly path this runs after the shpool checks, so a down daemon does + # delay it there; the five-minute timer has no such gate, which is where the + # independence above is actually delivered (lane A F7). + # Linux only, hard: Darwin's generated proc-shaped tree is a one-time + # process snapshot. Its mtimes say when snapshot files were written, not + # when worker output moved, and it cannot re-read a live PID generation at + # delivery time. The transcript supplies the idle evidence directly, but + # exact TERM/KILL delivery still requires live procfs (re-derived X20-F1). + # Its own switches, on top of the sentinel above: + # SESSION_KIT_SUBAGENT_SWEEP=off, a subagent-sweep-off file in the state + # directory, or a window of 0 -- set with SESSION_KIT_SUBAGENT_IDLE_MINUTES, + # the older SESSION_KIT_SUBAGENT_IDLE_HOURS, or a subagent-sweep-minutes + # file in the state directory, which is the only one of the three that + # reaches the timer. A window that cannot be read turns the sweep off. The + # module docstring enumerates every one of them, and `session-kit doctor` + # reports which are active. A dry pass touches nothing, the directory + # included. + if [[ $REAPER_PLATFORM != Darwin && + -f $RELEASE_DIR/lib/sessionkit_inventory/subagent_sweep.py ]]; then + sweep_args=(sweep --proc "$PROC_ROOT" --state-dir "$STATE_DIR") + if [[ $DRY_RUN == 1 ]]; then + sweep_args+=(--dry-run) + else + mkdir -p "$STATE_DIR" 2>/dev/null || true + chmod 700 "$STATE_DIR" 2>/dev/null || true + fi + PYTHONPATH="$RELEASE_DIR/lib" \ + python3 -m sessionkit_inventory.subagent_sweep "${sweep_args[@]}" || true + fi +} + +# The backstop window, not the normal one: see WHAT --auto-close IS FOR NOW. AUTO_CLOSE_HOURS=${SESSION_KIT_AUTO_CLOSE_HOURS:-72} STATUS=${SESSION_KIT_STATUS_CMD:-"$SCRIPT_DIR/shpool_status"} +INVENTORY_CORE=${SESSION_KIT_INVENTORY_CORE:-"$RELEASE_DIR/lib/session_inventory.py"} +DARWIN_PROC_SNAPSHOT= +DARWIN_DAEMON_PID= +# Shared holder and exact-shell proof primitives. Sourcing defines functions +# and paths only; it does not contact a daemon or mutate state. +# shellcheck source=bin/session_kit_common +source "$SCRIPT_DIR/session_kit_common" + +# WHAT --auto-close IS FOR NOW +# +# (This is here rather than in the file header because the header may not grow: +# tests/test_testing_gates.py sources the first 30 lines to prove PROC_ROOT is +# chosen there, and one added line cuts that `if` in half.) +# +# It is a BACKSTOP, not the way provider-exited terminals normally go +# (operator ruling, 2026-08-15: a session whose provider exits closes AT ONCE +# and lands in Closed sessions under its real name). The managed shell closes +# itself the moment its provider is gone for good, so a row that reaches 72 +# hours means one of three things, and each is a reason to keep this engine +# rather than retire it: +# +# 1. The session was started by an earlier release. A shell sources its +# bashrc once, at session start; installing a release does not restart a +# live session, so every session already running when the new close +# landed keeps the old path for the rest of its life. +# 2. The close itself failed, an unwritable ledger, a python3 that was +# momentarily unavailable. The shell keeps such a session on purpose and +# says so; the record and the conversation are both intact, which is +# exactly what this engine needs, so it finishes the job three days on. +# 3. The shell-less phantom class, which the ruling never touched: the +# daemon still advertises a session whose shell process is gone, no +# picker action can reach it, and only a daemon restart or this pass +# clears it. (Stock shpool leaves such a row behind whenever a session +# nobody is attached to loses its shell, measured on shpool 0.11.) This +# class clears on its first complete pass; the 72-hour window protects +# live empty shells and has no safety value once every session process is +# proved absent. +# +# It cannot cover the fourth case, and must not be widened to. A session whose +# provider-exit record was never written does not say "provider exited", and a +# session with no exact conversation has no uuid; reaper.py `_safe_candidate` +# refuses both. Those stay VISIBLE until a person closes them from the picker, +# which is the standing rule for a row whose state cannot be proven. + +reaper_usage() { + cat <<'EOF' +usage: shpool_reaper [mode] + + (no mode), --candidates report terminals that meet every close + predicate, and change nothing + --auto-close close the candidates that have held every + predicate for the full quiet period + --sweep-subagents close finished sub-agent workers only, and + touch no terminal (its own five-minute + timer; the hourly pass also does this) + --verify-candidate <private-json> [guard-json] + re-prove one candidate against a bound + guard document (or a fresh one) + +Statuses: 0 done, 1 refused or failed, 2 wrong arguments, 3 disabled by its +sentinel file. `sp help exit-codes` documents them all. +EOF +} MODE=report VERIFY_FILE= +VERIFY_GUARD_FILE= case "${1:-}" in + -h | --help | help) + reaper_usage + exit 0 + ;; ""|--candidates) ;; --auto-close) [[ -z ${2:-} ]] || { - echo "usage: shpool_reaper [--candidates|--auto-close|--verify-candidate <private-json>]" >&2 + reaper_usage >&2 exit 2 } MODE=auto ;; + --sweep-subagents) + [[ -z ${2:-} ]] || { + reaper_usage >&2 + exit 2 + } + MODE=sweep + ;; --verify-candidate) - [[ -n ${2:-} && -z ${3:-} ]] || { - echo "usage: shpool_reaper [--candidates|--auto-close|--verify-candidate <private-json>]" >&2 + [[ -n ${2:-} && -z ${4:-} ]] || { + reaper_usage >&2 exit 2 } MODE=verify VERIFY_FILE=$2 + VERIFY_GUARD_FILE=${3:-} ;; *) - echo "usage: shpool_reaper [--candidates|--auto-close|--verify-candidate <private-json>]" >&2 + reaper_usage >&2 exit 2 ;; esac @@ -62,14 +192,66 @@ if [[ -z $SHPOOL ]]; then done fi -timestamp() { date -Is 2>/dev/null || date; } +timestamp() { date -u '+%Y-%m-%dT%H:%M:%SZ'; } fail() { - printf '%s reaper report failed: %s\n' "$(timestamp)" "$*" >&2 + printf 'session-kit: %s\n' "$*" >&2 exit 1 } +# One stable, machine-distinguishable line per successful maintenance pass. +# The timer runs --auto-close, whose successful exits used to bypass the +# report-mode append at the bottom of this file and leave reaper.log silent. +# Keep the format version on every line so future readers can distinguish the +# original unversioned history from this key/value format. +log_reaper_run() { + local run_mode=$1 run_summary=$2 + [[ $DRY_RUN != 1 ]] || return 0 + umask 077 + mkdir -p "$STATE_DIR" || fail "state directory creation" + chmod 700 "$STATE_DIR" 2>/dev/null || true + if ! printf 'schema_version=1 timestamp=%s mode=%s %s\n' \ + "$(timestamp)" "$run_mode" "$run_summary" >> "$STATE_DIR/reaper.log"; then + printf 'session-kit: reaper log write failed; maintenance summary follows\n' >&2 + return 0 + fi + chmod 600 "$STATE_DIR/reaper.log" 2>/dev/null || true +} + +# The scheduled half of "a copy comes back when its session closes". A close +# that lands while the worker is still writing keeps the directory on purpose, +# and without this pass that decision would be permanent. Every copy still goes +# through the whole work check, so this can only collect what a close would +# have collected. +# +# "Nothing is alive" and "I could not find out what is alive" produce the same +# empty list, and one of those two means remove every copy. Reading the payload +# here in shell had no way to tell them apart, so the payload goes to the sweep +# verb itself, which refuses outright on anything it cannot fully account for +# and treats only a cleanly-parsed empty list as "none alive". A pass that +# skipped its work says so, because silence reads exactly like a pass that had +# nothing to do. +# +# Sets `swept` and `swept_kept` for the caller's summary. +run_worktree_sweep() { + local live_json= sweep_output= + swept=0 + swept_kept=0 + bound_shpool_list "worktree sweep" || return 0 + live_json=$BOUND_SHPOOL_JSON + if sweep_output=$(printf '%s' "$live_json" | + python3 "$INVENTORY_CORE" worktree sweep --active-stdin); then + swept=$(printf '%s' "$sweep_output" | grep -c '^Removed the ' || true) + swept_kept=$(printf '%s' "$sweep_output" | grep -c '^Kept the ' || true) + [[ -z $sweep_output ]] || printf '%s' "$sweep_output" + else + printf 'session-kit: no working copy was collected this pass; the sweep refused.\n' + fi + return 0 +} + expire_picker_temp_files() { [[ $MODE != verify && $DRY_RUN != 1 ]] || return 0 + [[ $REAPER_PLATFORM != Darwin ]] || return 0 local expired expired=$(python3 - "$STATE_DIR" "$PROC_ROOT" <<'PY' import os @@ -82,9 +264,87 @@ state_raw, proc_raw = sys.argv[1:] state = os.path.abspath(state_raw) proc = os.path.abspath(proc_raw) uid = os.geteuid() + +def stale_tui_capture(directory: int, name: str, cutoff_ns: int) -> bool: + if re.fullmatch(r"tui-stderr\.[A-Za-z0-9_]{8,16}", name) is None: + return False + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC + flags |= getattr(os, "O_NOFOLLOW", 0) + try: + capture = os.open(name, flags, dir_fd=directory) + except OSError: + return False + try: + info = os.fstat(capture) + capture_root = os.path.join(state, name) + if ( + info.st_uid != uid + or stat.S_IMODE(info.st_mode) != 0o700 + or info.st_mtime_ns > cutoff_ns + ): + return False + for pid_name in os.listdir(proc): + if not pid_name.isdigit(): + continue + descriptors = os.path.join(proc, pid_name, "fd") + try: + descriptor_names = os.listdir(descriptors) + except OSError: + continue + for descriptor_name in descriptor_names: + try: + target = os.readlink(os.path.join(descriptors, descriptor_name)) + except OSError: + continue + if target == capture_root or target.startswith(capture_root + os.sep): + return False + children = set(os.listdir(capture)) + if not children.issubset({"tail", "tail.trim", "stream"}): + return False + for child in children: + child_info = os.stat(child, dir_fd=capture, follow_symlinks=False) + if child_info.st_uid != uid or stat.S_IMODE(child_info.st_mode) != 0o600: + return False + if child == "stream": + if not stat.S_ISFIFO(child_info.st_mode): + return False + elif not stat.S_ISREG(child_info.st_mode) or child_info.st_nlink != 1: + return False + for child in children: + os.unlink(child, dir_fd=capture) + os.rmdir(name, dir_fd=directory) + return True + except OSError: + return False + finally: + os.close(capture) +# mktemp writes <label>.json.XXXXXX, suffix AFTER .json. The old +# <label>.SUFFIX.json form never matched a real file (the inversion leaked +# every picker temp ever written); both spellings are accepted so any +# stragglers from older releases are still collected. +# Every mktemp label the kit writes into this directory. A label absent from +# this list has no backstop at all: `sp-snapshot` files from 15 days back +# survived every hourly sweep, and an interrupted account switch left a +# `account-switch-wait` file no trap knew about. pattern = re.compile( - r"^(?:login-(?:snapshot|view|projects|recovery)|picker-proof|sp-guard)" - r"\.[A-Za-z0-9_]{6,16}\.json$" + r"^(?:(?:login-(?:snapshot|view|projects|projects-state|recovery" + r"|live-done|live-pending|live|frame|accounts" + # The picker's event subscription writes both of these for as long as it + # runs, and a picker that is SIGKILLed leaves them behind with no trap to + # unlink them. + r"|events-down|events)" + r"|picker-proof|sp-guard|sp-snapshot|sp-strict" + r"|account-switch-(?:choices|processes|wait|rollback)" + r"|watchdog-snapshot|watchdog-repair" + r"|revalidate|generation-proof|prune-candidate|project-discovery" + r"|provider-proof|bounce-recheck|auto-close-proof)" + r"(?:\.json\.[A-Za-z0-9_]{6,16}|\.[A-Za-z0-9_]{6,16}\.json)" + # Labels written without a .json part at all. An interrupted account switch + # leaves one of these, and no trap knows about it. + r"|(?:account-switch-(?:choices|processes|wait|rollback)|watchdog-repair" + r"|login-projects-state|project-discovery)" + r"\.[A-Za-z0-9_]{6,16}" + r"|\.inventory\.json\.[A-Za-z0-9_]{6,16})$" ) try: @@ -92,32 +352,21 @@ try: except OSError: raise SystemExit(0) -# A picker may still refer to one of its private temp files while waiting for -# input. Leak rather than delete whenever any same-user picker is live or its -# process identity cannot be read safely. +# A live picker refreshes its private temps every few seconds, so the 24-hour +# age cutoff below already guarantees no in-use file is touched. The old +# whole-run abort whenever ANY same-user picker was live made expiry +# permanently dead on a box that always has a picker open, the leak this +# collector exists to prevent. Only an unreadable process table still aborts. for name in proc_names: if not name.isdigit(): continue root = os.path.join(proc, name) try: - if os.stat(root, follow_symlinks=False).st_uid != uid: - continue - with open(os.path.join(root, "cmdline"), "rb") as handle: - payload = handle.read(65537) - if len(payload) > 65536: - raise OSError("oversized process command") + os.stat(root, follow_symlinks=False) except FileNotFoundError: continue except OSError: raise SystemExit(0) - arguments = [ - os.path.basename(part.decode("utf-8", "surrogateescape")) - for part in payload.split(b"\0") - if part - ] - if "shpool_login" in arguments: - print("live picker; temp expiry skipped", file=sys.stderr) - raise SystemExit(0) flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC flags |= getattr(os, "O_NOFOLLOW", 0) @@ -137,6 +386,9 @@ try: raise SystemExit(0) cutoff_ns = time.time_ns() - 24 * 60 * 60 * 1_000_000_000 for name in os.listdir(directory): + if stale_tui_capture(directory, name, cutoff_ns): + removed += 1 + continue if not pattern.fullmatch(name): continue try: @@ -187,10 +439,89 @@ PY printf '%s expired_picker_temp_files=%s\n' "$(timestamp)" "$expired" >&2 } +prepare_darwin_proc_snapshot() { + [[ $REAPER_PLATFORM == Darwin ]] || return 0 + [[ -f $INVENTORY_CORE ]] || fail "Darwin process adapter unavailable" + DARWIN_PROC_SNAPSHOT=$(mktemp -d "${TMPDIR:-/tmp}/session-kit-proc.XXXXXX") || + fail "Darwin process snapshot creation" + chmod 700 "$DARWIN_PROC_SNAPSHOT" || fail "Darwin process snapshot permissions" + trap '[[ -z ${DARWIN_PROC_SNAPSHOT:-} ]] || rm -rf -- "$DARWIN_PROC_SNAPSHOT"' EXIT + local process_json=$DARWIN_PROC_SNAPSHOT/process-table.json + python3 "$INVENTORY_CORE" platform process-table > "$process_json" || return 1 + chmod 600 "$process_json" || return 1 + DARWIN_DAEMON_PID=$(python3 - "$DARWIN_PROC_SNAPSHOT" "$process_json" <<'PY' +import json +import os +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]) +payload = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")) +rows = payload.get("processes") if isinstance(payload, dict) else None +if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows): + raise SystemExit("session-kit: invalid Darwin process snapshot") +daemons = [] +for row in rows: + argv = row.get("cmdline") + if isinstance(argv, list) and argv and pathlib.Path(str(argv[0])).name == "shpool" and "daemon" in argv[1:]: + daemons.append(row) +if len(daemons) != 1: + raise SystemExit("session-kit: expected exactly one shpool daemon") +daemon_pid = daemons[0].get("pid") +if not isinstance(daemon_pid, int) or daemon_pid <= 0: + raise SystemExit("session-kit: invalid shpool daemon identity") +for row in rows: + if row.get("ppid") != daemon_pid: + continue + if row.get("args_available") is not True or not row.get("session_name"): + raise SystemExit("session-kit: unreadable shpool child; cleanup refused") +for row in rows: + pid = row.get("pid") + ppid = row.get("ppid") + generation = row.get("start_ticks") + if not all(isinstance(value, int) and value >= 0 for value in (pid, ppid, generation)): + raise SystemExit("session-kit: invalid Darwin process identity") + base = root / str(pid) + base.mkdir(mode=0o700) + comm = str(row.get("comm") or "process").replace(")", "")[:128] + fields = ["S", str(ppid), *(["0"] * 17), str(generation), *(["0"] * 30)] + (base / "stat").write_text(f"{pid} ({comm}) {' '.join(fields)}\n", encoding="utf-8") + (base / "comm").write_text(f"{comm}\n", encoding="utf-8") + argv = row.get("cmdline") if isinstance(row.get("cmdline"), list) else [] + (base / "cmdline").write_bytes(b"\0".join(str(value).encode() for value in argv) + (b"\0" if argv else b"")) + session = row.get("session_name") + environ = b"SHPOOL_SESSION_NAME=" + str(session).encode() + b"\0" if session else b"" + (base / "environ").write_bytes(environ) + cwd = row.get("cwd") + if isinstance(cwd, str) and cwd.startswith("/"): + try: + os.symlink(cwd, base / "cwd") + except OSError: + pass +print(daemon_pid) +PY + ) || return 1 + command rm -- "$process_json" +} + if [[ -e $SENTINEL ]]; then printf '%s reaper disabled by %s\n' "$(timestamp)" "$SENTINEL" >&2 exit 3 fi + +# The sweep-only pass runs on its own five-minute timer and stops here: it +# reads /proc and signals finished workers, and must never build the +# guard-live inventory or touch a terminal. It sits BELOW the sentinel on +# purpose -- their off switch has to reach the most destructive pass in the kit, +# and putting this above it was the regression review found (lane A F1). It +# sits above the shpool gates equally on purpose: a finished worker is +# independent of the session manager, so a down daemon must not keep it alive. +if [[ ${MODE:-report} == sweep ]]; then + sweep_status=0 + sweep_subagents || sweep_status=$? + (( sweep_status == 3 )) && exit 3 + exit 0 +fi [[ -n $SHPOOL && -x $SHPOOL ]] || fail "shpool executable unavailable" [[ $MAX_AGE_DAYS =~ ^[0-9]+$ && $MAX_AGE_DAYS -ge 1 ]] || fail "invalid prune age" [[ $AUTO_CLOSE_HOURS =~ ^[0-9]+$ && $AUTO_CLOSE_HOURS -ge 1 && @@ -198,24 +529,342 @@ fi expire_picker_temp_files -json=$("$SHPOOL" list --json 2>/dev/null) || fail "shpool list error" -daemon_pid=${SESSION_KIT_DAEMON_PID:-} -if [[ -z $daemon_pid && $PROC_ROOT == /proc ]]; then - daemon_pid=$(pgrep -f '^.*/shpool daemon$' | head -1) +# Immutable releases accumulate one per roll and only `current`, its recorded +# predecessor, and whatever a STANDING window still runs are ever used again. +# Prune the rest once they are old and provably unreferenced; keep the newest +# three regardless, and never more than ten deletions per run. +expire_stale_releases() { + [[ $MODE != verify && $DRY_RUN != 1 ]] || return 0 + [[ $REAPER_PLATFORM != Darwin ]] || return 0 + python3 - "$PROC_ROOT" <<'PY' || true +import json +import os +import re +import shutil +import sys +import time + +proc = os.path.abspath(sys.argv[1]) +home = os.path.expanduser("~") +root = os.path.join(home, ".local", "lib", "session-kit") +releases = os.path.join(root, "releases") +if not os.path.isdir(releases): + raise SystemExit(0) +uid = os.geteuid() + +keep: set[str] = set() +def decline(reason): + # Silence was how this stayed broken for 152 releases: every refusal to + # collect now says so where the journal keeps it. + print(f"release GC declined: {reason}", file=sys.stderr) + raise SystemExit(0) + + +try: + keep.add(os.path.basename(os.path.realpath(os.path.join(root, "current")))) +except OSError: + decline("the current release link could not be read") +try: + with open( + os.path.join(home, ".local", "state", "session-kit", "install.json"), + encoding="utf-8", + ) as handle: + recorded = json.load(handle) + for key in ("installed_release", "previous_release"): + value = recorded.get(key) + if isinstance(value, str): + keep.add(value) +except (OSError, ValueError): + decline("install.json could not be read") + +# Anything ANY live same-uid process references stays; an unreadable process +# table means no proof, so nothing is deleted. +# +# One /proc file per process may legitimately refuse to open: `environ` is +# readable only for a dumpable process, and `systemd --user`, `(sd-pam)` and +# every `sshd-session` are same-uid and permanently non-dumpable. Treating that +# PermissionError as "no proof" aborted the whole scan before the deletion loop +# on every systemd-user host, so this GC had never once run to completion, 152 +# release directories and 368 MB with no log line to notice it by. A process +# whose environment cannot be read still contributes its cmdline and cwd, and +# neither can be forged into a reference the scan misses: an unreadable +# `environ` cannot name a release the same process is not also running. +try: + for name in os.listdir(proc): + if not name.isdigit(): + continue + base = os.path.join(proc, name) + evidence = [] + try: + if os.stat(base, follow_symlinks=False).st_uid != uid: + continue + except FileNotFoundError: + continue + except OSError as error: + decline(f"/proc/{name} could not be inspected ({error.errno})") + for entry, limit in (("cmdline", 65536), ("environ", 131072)): + try: + with open(os.path.join(base, entry), "rb") as handle: + evidence.append(handle.read(limit)) + except (FileNotFoundError, ProcessLookupError, PermissionError): + continue + except OSError as error: + decline(f"/proc/{name}/{entry} could not be read ({error.errno})") + try: + evidence.append(os.readlink(os.path.join(base, "cwd")).encode()) + except OSError: + pass + for blob in evidence: + for match in re.finditer(rb"releases/([0-9a-f]{40})", blob): + keep.add(match.group(1).decode()) +except OSError: + decline("the process table could not be listed") + +entries = [] +for name in os.listdir(releases): + if not re.fullmatch(r"[0-9a-f]{40}", name): + continue + path = os.path.join(releases, name) + if os.path.islink(path) or not os.path.isdir(path): + continue + try: + entries.append((os.lstat(path).st_mtime, name)) + except OSError: + continue +entries.sort(reverse=True) +for _, name in entries[:3]: + keep.add(name) + +cutoff = time.time() - 14 * 86400 +removed = 0 +for mtime, name in reversed(entries): + if removed >= 10 or name in keep or mtime > cutoff: + continue + target = os.path.join(releases, name) + # Release dirs are installed read-only; restore owner-write so the + # removal itself cannot half-fail. + for directory, _, files in os.walk(target): + try: + os.chmod(directory, 0o700) + for entry in files: + os.chmod(os.path.join(directory, entry), 0o600) + except OSError: + break + try: + shutil.rmtree(target) + removed += 1 + except OSError: + continue +if removed: + print(f"release GC removed {removed} stale release(s)", file=sys.stderr) +PY +} +expire_stale_releases + +if [[ $REAPER_PLATFORM == Darwin ]]; then + prepare_darwin_proc_snapshot || fail "Darwin process snapshot unavailable" + PROC_ROOT=$DARWIN_PROC_SNAPSHOT + daemon_pid=$DARWIN_DAEMON_PID +fi + +if [[ $MODE == verify ]]; then + [[ -f $VERIFY_FILE && ! -L $VERIFY_FILE ]] || + fail "candidate proof is not a regular file" +fi + +# Every terminal-closing reaper path consumes the daemon generation selected +# by the same socket-bound guard inventory used by ordinary mutations. A +# process-name match is not provenance: another user's lower-PID daemon can +# carry the same session name. Verification may be handed the exact guard +# document already built by `sp prune`; direct callers get a fresh one. +load_bound_inventory() { + if [[ -n ${VERIFY_GUARD_FILE:-} ]]; then + [[ -f $VERIFY_GUARD_FILE && ! -L $VERIFY_GUARD_FILE ]] || + fail "bound guard document is not a regular file" + inventory=$(command cat -- "$VERIFY_GUARD_FILE") || + fail "bound guard document unavailable" + else + [[ -x $STATUS ]] || fail "guard-live status command unavailable" + inventory=$("$STATUS" --guard-json 2>/dev/null) || + fail "guard-live inventory unavailable" + fi + export SK_REAPER_INVENTORY_JSON=$inventory + export SK_REAPER_BOUND_CANDIDATE=${VERIFY_FILE:-} + read -r daemon_pid daemon_start < <(python3 <<'PY' +import json, os, pathlib + +try: + inventory = json.loads(os.environ["SK_REAPER_INVENTORY_JSON"]) +except (KeyError, json.JSONDecodeError): + raise SystemExit(2) +if ( + not isinstance(inventory, dict) + or inventory.get("source") != "live" + or inventory.get("stale") is not False + or inventory.get("warnings") +): + raise SystemExit(2) +daemon = inventory.get("daemon_generation") +pid = daemon.get("pid") if isinstance(daemon, dict) else None +start = daemon.get("process_start_ticks") if isinstance(daemon, dict) else None +if ( + not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0 + or not isinstance(start, int) or isinstance(start, bool) or start < 0 +): + raise SystemExit(2) + +candidate_path = os.environ.get("SK_REAPER_BOUND_CANDIDATE") +if candidate_path: + try: + candidate = json.loads(pathlib.Path(candidate_path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + raise SystemExit(2) + session_id = candidate.get("shpool_id") if isinstance(candidate, dict) else None + shell_pid = candidate.get("shell_pid") if isinstance(candidate, dict) else None + shell_start = candidate.get("shell_start_ticks") if isinstance(candidate, dict) else None + rows = inventory.get("sessions") + matches = [ + row for row in rows + if isinstance(row, dict) and row.get("shpool_id_raw") == session_id + ] if isinstance(rows, list) else [] + if len(matches) != 1: + raise SystemExit(2) + shell = matches[0].get("shpool_shell") + if candidate.get("class") == "shell-less-phantom": + if ( + shell is not None + or matches[0].get("mutation_allowed") is not False + or matches[0].get("mutation_rejection_reason") + != "missing-shell-generation" + ): + raise SystemExit(2) + elif ( + not isinstance(shell, dict) + or shell.get("pid") != shell_pid + or shell.get("process_start_ticks") != shell_start + or matches[0].get("mutation_allowed") is not True + ): + raise SystemExit(2) +print(pid, start) +PY + ) || fail "socket-bound daemon or candidate shell identity unavailable" + [[ $daemon_pid =~ ^[0-9]+$ && $daemon_start =~ ^[0-9]+$ ]] || + fail "socket-bound daemon identity unavailable" + # The legacy PID override exists only for isolated fixtures. It used to + # stand in for the first-name `pgrep` result; preserving a disagreement as + # a refusal makes old adversarial probes exercise the safety boundary + # without letting the override select process evidence. + if [[ ${SESSION_KIT_TESTING:-0} == 1 && + -n ${SESSION_KIT_DAEMON_PID:-} && + ${SESSION_KIT_DAEMON_PID} != "$daemon_pid" ]]; then + fail "fixture daemon evidence disagrees with socket-bound identity" + fi +} + +# Every raw client payload that can nominate, verify, or authorize cleanup is +# bracketed by observations of the listener holder. Both observations must be +# the exact daemon generation in the guard document. An unknown author is not +# evidence, and a different author is never allowed to donate its names to the +# bound daemon's process tree. +bound_shpool_list() { + local purpose=$1 before after + before=$(observe_reaper_holder) || before=unknown + BOUND_SHPOOL_JSON=$("$SHPOOL" list --json 2>/dev/null) || { + printf 'session-kit: %s refused: shpool list failed\n' "$purpose" >&2 + return 1 + } + after=$(observe_reaper_holder) || after=unknown + if [[ $before == unknown ]]; then + printf 'session-kit: %s refused: socket holder before shpool list is unknown; expected PID %s start %s\n' \ + "$purpose" "$daemon_pid" "$daemon_start" >&2 + return 1 + fi + if [[ $before != "$daemon_pid"$'\t'"$daemon_start" ]]; then + printf 'session-kit: %s refused: socket holder before shpool list is %s; expected PID %s start %s\n' \ + "$purpose" "${before//$'\t'/ start }" "$daemon_pid" "$daemon_start" >&2 + return 1 + fi + if [[ $after == unknown ]]; then + printf 'session-kit: %s refused: socket holder after shpool list is unknown; expected PID %s start %s\n' \ + "$purpose" "$daemon_pid" "$daemon_start" >&2 + return 1 + fi + if [[ $after != "$daemon_pid"$'\t'"$daemon_start" ]]; then + printf 'session-kit: %s refused: shpool list was answered across a socket-holder change to %s; expected PID %s start %s\n' \ + "$purpose" "${after//$'\t'/ start }" "$daemon_pid" "$daemon_start" >&2 + return 1 + fi + return 0 +} + +observe_reaper_holder() { + local observed + if observed=$(sk_observe_shpool_holder 2>/dev/null); then + printf '%s\n' "$observed" + return 0 + fi + # Existing synthetic reaper fixtures carry no kernel socket. Their guarded + # PID seam may stand in for the observation only behind the testing gate and + # only when it agrees with the already-loaded exact guard generation. + if [[ ${SESSION_KIT_TESTING:-0} == 1 && + ${SESSION_KIT_DAEMON_PID:-} == "$daemon_pid" ]]; then + printf '%s\t%s\n' "$daemon_pid" "$daemon_start" + return 0 + fi + return 1 +} + +require_bound_holder() { + local purpose=$1 observed + observed=$(observe_reaper_holder) || { + printf 'session-kit: %s refused: current socket holder is unknown; expected PID %s start %s\n' \ + "$purpose" "$daemon_pid" "$daemon_start" >&2 + return 1 + } + if [[ $observed != "$daemon_pid"$'\t'"$daemon_start" ]]; then + printf 'session-kit: %s refused: socket holder is %s; expected PID %s start %s\n' \ + "$purpose" "${observed//$'\t'/ start }" "$daemon_pid" "$daemon_start" >&2 + return 1 + fi + return 0 +} + +if [[ $MODE == auto ]]; then + # Worker completion is process-tree based and does not consume a shpool + # payload. It therefore remains independent of terminal inventory health. + sweep_subagents fi -[[ $daemon_pid =~ ^[0-9]+$ ]] || fail "daemon PID unavailable" +load_bound_inventory +bound_shpool_list "initial reaper snapshot" || + fail "raw shpool snapshot author does not match the bound daemon" +json=$BOUND_SHPOOL_JSON + +# A Codex App Server socket dies with its window, but the private directory +# holding it survives every reboot, so the tree grows one entry per Codex +# session forever, and a live-rename sweep that walks it pays for every +# corpse. Remove only directories whose session the daemon no longer lists, +# whose socket refuses a connection, and that nothing has touched for an hour. +# Entirely fail-open: no proof, no removal. +expire_stale_app_server_dirs() { + [[ $MODE != verify && $DRY_RUN != 1 ]] || return 0 + [[ -f $RELEASE_DIR/lib/sessionkit_inventory/reaper.py ]] || return 0 + local swept + swept=$(SK_REAPER_SHPOOL_JSON=$json \ + SK_REAPER_STATE_DIR=$STATE_DIR \ + PYTHONPATH="$RELEASE_DIR/lib" \ + python3 -m sessionkit_inventory.reaper sweep-app-servers) || return 0 + [[ $swept =~ ^[0-9]+$ && $swept -gt 0 ]] || return 0 + printf '%s expired_app_server_dirs=%s\n' "$(timestamp)" "$swept" >&2 +} +expire_stale_app_server_dirs if [[ $MODE == auto ]]; then - [[ -x $STATUS ]] || fail "guard-live status command unavailable" [[ -f $RELEASE_DIR/lib/sessionkit_inventory/reaper.py ]] || fail "auto-close planner unavailable" - inventory=$("$STATUS" --guard-json 2>/dev/null) || - fail "guard-live inventory unavailable" export SK_REAPER_SHPOOL_JSON=$json export SK_REAPER_INVENTORY_JSON=$inventory export SK_REAPER_STATE_DIR=$STATE_DIR export SK_REAPER_PROC_ROOT=$PROC_ROOT - export SK_REAPER_DAEMON_PID=$daemon_pid export SK_REAPER_AUTO_CLOSE_HOURS=$AUTO_CLOSE_HOURS plan_args=(plan) if [[ $DRY_RUN == 1 ]]; then plan_args+=(--no-write); fi @@ -242,21 +891,28 @@ print(len(items)) PY ) || fail "auto-close candidate document is invalid" if [[ $candidate_count == 0 ]]; then - printf '%s\n' "$plan" + # The sweep runs on EVERY auto pass, not only on the ones that had a + # session to close. Nothing else in the kit runs it, and a machine with no + # auto-close candidate is the ordinary case -- so leaving it below this + # early exit meant the scheduled half of "a copy comes back" almost never + # happened, and a copy kept because a worker was still writing was kept for + # good. + run_worktree_sweep + log_reaper_run auto "$plan worktrees_released=$swept worktrees_kept=$swept_kept" + printf '%s worktrees_released=%s worktrees_kept=%s\n' \ + "$plan" "$swept" "$swept_kept" exit 0 fi # The creation lock is the shared Session Kit attach/create/close barrier. - exec 9>"$STATE_DIR/create.lock" || fail "creation lock unavailable" - flock -x 9 || fail "creation lock unavailable" # Reuse the privacy-minimal capped action log already used by picker actions. - # shellcheck source=bin/session_kit_common - source "$SCRIPT_DIR/session_kit_common" sk_prepare_state || fail "private action state unavailable" + exec 9>"$STATE_DIR/create.lock" || fail "creation lock unavailable" + sk_lock_acquire 9 "$STATE_DIR/create.lock" || fail "creation lock unavailable" actions=0 proof= trap '[[ -z ${proof:-} ]] || rm -f -- "$proof"' EXIT for (( candidate_index=0; candidate_index<candidate_count; candidate_index++ )); do - proof=$(mktemp "$STATE_DIR/auto-close-proof.XXXXXX.json") || + proof=$(mktemp "$STATE_DIR/auto-close-proof.json.XXXXXX") || fail "private candidate proof creation" chmod 600 "$proof" || fail "private candidate proof permissions" python3 - "$candidate_file" "$candidate_index" "$proof" <<'PY' || @@ -281,10 +937,22 @@ print(target) PY ) || fail "private candidate target is invalid" for verification_pass in 1 2; do - json=$("$SHPOOL" list --json 2>/dev/null) || - fail "fresh shpool list error" - inventory=$("$STATUS" --guard-json 2>/dev/null) || + fresh_inventory=$("$STATUS" --guard-json 2>/dev/null) || fail "fresh guard-live inventory unavailable" + SK_REAPER_FRESH_INVENTORY=$fresh_inventory python3 - \ + "$daemon_pid" "$daemon_start" <<'PY' || +import json,os,sys +value=json.loads(os.environ["SK_REAPER_FRESH_INVENTORY"]) +daemon=value.get("daemon_generation") if isinstance(value,dict) else None +expected=(int(sys.argv[1]),int(sys.argv[2])) +actual=(daemon.get("pid"),daemon.get("process_start_ticks")) if isinstance(daemon,dict) else None +raise SystemExit(0 if actual == expected else 1) +PY + fail "fresh guard-live inventory changed daemon generation" + inventory=$fresh_inventory + bound_shpool_list "auto-close verification pass $verification_pass" || + fail "auto-close snapshot author changed" + json=$BOUND_SHPOOL_JSON export SK_REAPER_SHPOOL_JSON=$json export SK_REAPER_INVENTORY_JSON=$inventory PYTHONPATH="$RELEASE_DIR/lib" \ @@ -293,11 +961,51 @@ PY done sk_log_action auto_close requested || fail "private action log unavailable; nothing closed" - "$SHPOOL" kill "$target" >/dev/null 2>&1 || - fail "exact auto-close action failed" - after=$("$SHPOOL" list --json 2>/dev/null) || - fail "post-close shpool list error" - SK_REAPER_POST_JSON=$after python3 - "$target" <<'PY' || + read -r candidate_class candidate_started closed_shell_pid closed_shell_start < <( + python3 - "$proof" <<'PY' +import json,sys +value=json.load(open(sys.argv[1],encoding="utf-8")) +print( + value.get("class", "provider-exited"), + value.get("started_at_unix_ms", ""), + value.get("shell_pid", ""), + value.get("shell_start_ticks", ""), +) +PY + ) || fail "auto-close candidate shell identity is invalid" + [[ $candidate_started =~ ^[0-9]+$ ]] || + fail "auto-close candidate manager generation is invalid" + if [[ $candidate_class == shell-less-phantom ]]; then + [[ -z $closed_shell_pid && -z $closed_shell_start ]] || + fail "shell-less candidate unexpectedly carries a shell generation" + require_bound_holder "shell-less cleanup dispatch for $target" || + fail "auto-close socket holder changed before manager finalization" + sk_finalize_dead_shpool_entry \ + "$target" "$daemon_pid" "$daemon_start" "$candidate_started" || + fail "dead manager entry $target remains after finalization" + else + [[ $closed_shell_pid =~ ^[0-9]+$ && $closed_shell_start =~ ^[0-9]+$ ]] || + fail "auto-close candidate shell identity is invalid" + # The lock serializes kit callers. The destructive act is addressed to + # the exact verified shell generation, never to a session name. + require_bound_holder "auto-close dispatch for $target" || + fail "auto-close socket holder changed before exact termination; no signal was sent" + if ! shell_proof=$(sk_terminate_exact_shell \ + "$daemon_pid" "$daemon_start" \ + "$closed_shell_pid" "$closed_shell_start" 2>&1); then + sk_cleanup_dead_shpool_entry "$target" \ + "$daemon_pid" "$daemon_start" \ + "$closed_shell_pid" "$closed_shell_start" "$candidate_started" || true + fail "exact shell PID $closed_shell_pid start $closed_shell_start was not terminated ($shell_proof); nothing was reported closed" + fi + sk_cleanup_dead_shpool_entry "$target" \ + "$daemon_pid" "$daemon_start" \ + "$closed_shell_pid" "$closed_shell_start" "$candidate_started" || + fail "dead manager entry $target remains after exact shell termination" + fi + if bound_shpool_list "auto-close post-close cleanup snapshot"; then + after=$BOUND_SHPOOL_JSON + SK_REAPER_POST_JSON=$after python3 - "$target" <<'PY' || import json,os,sys payload=json.loads(os.environ["SK_REAPER_POST_JSON"]) rows=payload.get("sessions") if isinstance(payload,dict) else payload @@ -307,24 +1015,64 @@ raise SystemExit(1 if any( isinstance(row,dict) and row.get("name")==sys.argv[1] for row in rows ) else 0) PY - fail "closed target is still present" + fail "manager entry $target remains after cleanup postcondition" + else + printf 'session-kit: exact shell for %s is dead; post-cleanup list skipped because its author is unproved\n' \ + "$target" >&2 + fi sk_log_action auto_close closed || fail "close succeeded but private action log update failed" + # A worker the sweep closed gives its own copy of the code back as well. + # Never fatal and never forced: unmerged work keeps the directory, and the + # line that says so goes to this run's output rather than to /dev/null -- + # a copy kept for work in it is the one thing here worth reading later. + sk_release_worktree "$target" 2>/dev/null || true rm -f -- "$proof" proof= actions=$((actions + 1)) done trap - EXIT - printf 'auto_candidates=%s actions=%s\n' "$candidate_count" "$actions" + run_worktree_sweep + # A copy the sweep KEPT because there is unmerged work in it is the line + # worth finding later, and the summary counted only the ones it removed. + summary="auto_candidates=$candidate_count actions=$actions worktrees_released=$swept worktrees_kept=$swept_kept" + log_reaper_run auto "$summary" + printf '%s\n' "$summary" exit 0 fi if [[ $MODE == verify ]]; then - [[ -f $VERIFY_FILE && ! -L $VERIFY_FILE ]] || fail "candidate proof is not a regular file" + # The last gate before a kill re-proves the keep marker too: a candidate + # nominated hours ago may have been kept since, from the exit menu of the + # very terminal about to be closed. + [[ -f $RELEASE_DIR/lib/sessionkit_inventory/lifecycle.py ]] || + fail "keep-marker reader unavailable; nothing was pruned" + SK_REAPER_LIB_DIR=$RELEASE_DIR/lib \ + SK_REAPER_STATE_DIR=$STATE_DIR \ + python3 - "$VERIFY_FILE" <<'KEEP' || +import json, os, sys +from pathlib import Path + +sys.path.insert(0, os.environ["SK_REAPER_LIB_DIR"]) +from sessionkit_inventory.lifecycle import cleanup_protected + +candidate = json.load(open(sys.argv[1], encoding="utf-8")) +session_id = candidate.get("shpool_id") if isinstance(candidate, dict) else None +if not isinstance(session_id, str): + raise SystemExit(2) +raise SystemExit( + 1 + if session_id + in cleanup_protected(Path(os.environ["SK_REAPER_STATE_DIR"]), [session_id]) + else 0 +) +KEEP + fail "candidate is marked keep; automatic cleanup is disabled for it" export SK_REAPER_JSON=$json export SK_REAPER_VERIFY_FILE=$VERIFY_FILE export SK_REAPER_PROC_ROOT=$PROC_ROOT export SK_REAPER_DAEMON_PID=$daemon_pid + export SK_REAPER_DAEMON_START=$daemon_start export SK_REAPER_MAX_AGE_DAYS=$MAX_AGE_DAYS python3 <<'PY' || fail "candidate changed or is no longer empty" import json, os, pathlib, re, time @@ -334,6 +1082,7 @@ rows = payload.get("sessions") if isinstance(payload, dict) else payload candidate = json.loads(pathlib.Path(os.environ["SK_REAPER_VERIFY_FILE"]).read_text()) proc_root = pathlib.Path(os.environ["SK_REAPER_PROC_ROOT"]) daemon_pid = int(os.environ["SK_REAPER_DAEMON_PID"]) +daemon_start = int(os.environ["SK_REAPER_DAEMON_START"]) required = ( "shpool_id", "started_at_unix_ms", @@ -382,6 +1131,9 @@ def read_bytes(pid, name): return (proc_root / str(pid) / name).read_bytes() processes = {} +daemon_before = stat(daemon_pid) +if daemon_before is None or daemon_before[1] != daemon_start: + raise SystemExit(2) try: pids = sorted(int(entry.name) for entry in proc_root.iterdir() if entry.name.isdigit()) except OSError: @@ -442,8 +1194,26 @@ if direct: raise SystemExit(2) if stat(shell) != (processes[shell]["ppid"], processes[shell]["start_ticks"]): raise SystemExit(2) +if stat(daemon_pid) != daemon_before: + raise SystemExit(2) +PY + if [[ $REAPER_PLATFORM == Darwin ]]; then + read -r native_shell_pid native_shell_generation < <( + python3 - "$VERIFY_FILE" <<'PY' +import json,sys +value=json.load(open(sys.argv[1],encoding="utf-8")) +print(value.get("shell_pid", ""), value.get("shell_start_ticks", "")) PY - second=$("$SHPOOL" list --json 2>/dev/null) || fail "second shpool list error" + ) + [[ $native_shell_pid =~ ^[0-9]+$ && $native_shell_generation =~ ^[0-9]+$ ]] || + fail "candidate has no native shell generation" + python3 "$INVENTORY_CORE" platform process-generation-is \ + "$native_shell_pid" "$native_shell_generation" >/dev/null 2>&1 || + fail "candidate shell generation changed" + fi + bound_shpool_list "final candidate verification" || + fail "second shpool list author does not match the bound daemon" + second=$BOUND_SHPOOL_JSON export SK_REAPER_SECOND_JSON=$second python3 - "$VERIFY_FILE" <<'PY' || fail "candidate changed after process proof" import json,os,sys @@ -460,7 +1230,7 @@ matches=[ ] raise SystemExit(0 if len(matches)==1 else 1) PY - echo "candidate verified exact, disconnected, and empty" + printf 'session-kit: this session is idle, empty, and safe to close.\n' exit 0 fi @@ -470,15 +1240,27 @@ if [[ $DRY_RUN != 1 ]]; then chmod 700 "$STATE_DIR" 2>/dev/null || true fi +# A terminal that answered the exit menu with "keep" was promised no +# unattended cleanup, and `sp prune` closes what this report nominates. The +# marker lives in the lifecycle record, so without its reader nothing is +# nominated at all. +[[ -f $RELEASE_DIR/lib/sessionkit_inventory/lifecycle.py ]] || + fail "keep-marker reader unavailable; nothing was nominated" + export SK_REAPER_JSON=$json export SK_REAPER_STATE_DIR=$STATE_DIR export SK_REAPER_PROC_ROOT=$PROC_ROOT export SK_REAPER_DAEMON_PID=$daemon_pid +export SK_REAPER_DAEMON_START=$daemon_start export SK_REAPER_MAX_AGE_DAYS=$MAX_AGE_DAYS export SK_REAPER_DRY_RUN=$DRY_RUN +export SK_REAPER_LIB_DIR=$RELEASE_DIR/lib summary=$(python3 <<'PY' -import json, os, pathlib, tempfile, time +import json, os, pathlib, sys, tempfile, time + +sys.path.insert(0, os.environ["SK_REAPER_LIB_DIR"]) +from sessionkit_inventory.lifecycle import cleanup_protected payload = json.loads(os.environ["SK_REAPER_JSON"]) rows = payload.get("sessions") if isinstance(payload, dict) else payload @@ -487,10 +1269,21 @@ if not isinstance(rows, list): for row in rows: if not isinstance(row, dict) or not isinstance(row.get("name"), str): raise SystemExit(2) +inventory = json.loads(os.environ["SK_REAPER_INVENTORY_JSON"]) +guard_rows = inventory.get("sessions") if isinstance(inventory, dict) else None +if not isinstance(guard_rows, list): + raise SystemExit(2) +guard_by_id = {} +for item in guard_rows: + session_id = item.get("shpool_id_raw") if isinstance(item, dict) else None + if not isinstance(session_id, str) or session_id in guard_by_id: + raise SystemExit(2) + guard_by_id[session_id] = item state_dir = pathlib.Path(os.environ["SK_REAPER_STATE_DIR"]) proc_root = pathlib.Path(os.environ["SK_REAPER_PROC_ROOT"]) daemon_pid = int(os.environ["SK_REAPER_DAEMON_PID"]) +daemon_start = int(os.environ["SK_REAPER_DAEMON_START"]) age_ms = int(os.environ["SK_REAPER_MAX_AGE_DAYS"]) * 86400 * 1000 dry = os.environ["SK_REAPER_DRY_RUN"] == "1" now_ms = int(time.time() * 1000) @@ -540,12 +1333,23 @@ for pid in pids: } children.setdefault(before["ppid"], []).append(pid) +daemon_stat = stats.get(daemon_pid) +if not daemon_stat or daemon_stat["start_ticks"] != daemon_start: + raise SystemExit(2) + shells = {} +duplicate_shells = set() for pid in children.get(daemon_pid, []): for item in processes[pid]["environ"]: if item.startswith(b"SHPOOL_SESSION_NAME="): - shells[item.split(b"=", 1)[1].decode("utf-8", "replace")] = pid + name = item.split(b"=", 1)[1].decode("utf-8", "replace") + if name in shells: + duplicate_shells.add(name) + else: + shells[name] = pid break +for name in duplicate_shells: + shells.pop(name, None) def comm(pid): return processes.get(pid, {}).get("comm", "") @@ -573,6 +1377,13 @@ try: except (OSError, ValueError, AttributeError): previous = {} +# Read once, before the loop: the keep marker is per session and the +# lifecycle reader fails closed on anything it cannot validate. +protected = cleanup_protected( + state_dir, + [row["name"] for row in rows], +) + observations = {} candidates = [] for row in rows: @@ -583,6 +1394,17 @@ for row in rows: stat = stats.get(shell_pid) if shell_pid else None if status != "disconnected" or not isinstance(disconnected, int) or not shell_pid or not stat: continue + guard = guard_by_id.get(name) + guard_shell = guard.get("shpool_shell") if isinstance(guard, dict) else None + if ( + not isinstance(guard_shell, dict) + or guard.get("started_at_unix_ms") != row.get("started_at_unix_ms") + or str(guard.get("shpool_status", "")).lower() != status + or guard.get("mutation_allowed") is not True + or guard_shell.get("pid") != shell_pid + or guard_shell.get("process_start_ticks") != stat["start_ticks"] + ): + raise SystemExit(2) key = f'{row.get("started_at_unix_ms")}:{disconnected}:{shell_pid}:{stat["start_ticks"]}' empty = verified_empty(shell_pid) observations[name] = { @@ -594,6 +1416,8 @@ for row in rows: prior = previous.get(name, {}) old_enough = now_ms - disconnected >= age_ms seen_twice = prior.get("key") == key and prior.get("empty") is True + if name in protected: + continue if empty and old_enough and seen_twice: candidates.append({ "shpool_id": name, @@ -607,6 +1431,11 @@ candidate_doc = { "schema_version": 1, "generated_at_unix_ms": now_ms, "policy": "confirmed-prune-only", + # The window is configurable, so it travels with the list rather than + # being a number the reader assumes. `sp prune` inherits the caller's + # environment: a one-day nomination printed under a seven-day heading is + # a person closing sessions they were told were a week old. + "max_age_days": int(os.environ["SK_REAPER_MAX_AGE_DAYS"]), "candidates": candidates, } observation_doc = { @@ -637,17 +1466,18 @@ def atomic_json(path, value): except FileNotFoundError: pass +current_daemon = proc_stat(daemon_pid) +if current_daemon != daemon_stat: + raise SystemExit(2) + if not dry: atomic_json(previous_path, observation_doc) atomic_json(state_dir / "prune-candidates.json", candidate_doc) -print(f"sessions={len(rows)} disconnected={sum(str(r.get('status','')).lower() == 'disconnected' for r in rows)} candidates={len(candidates)} actions=0") +print(f"sessions={len(rows)} disconnected={sum(str(r.get('status','')).lower() == 'disconnected' for r in rows)} candidates={len(candidates)} actions=0 kept={len(protected)}") PY ) || fail "invalid inventory or process map" -if [[ $DRY_RUN != 1 ]]; then - printf '%s %s\n' "$(timestamp)" "$summary" >> "$STATE_DIR/reaper.log" - chmod 600 "$STATE_DIR/reaper.log" 2>/dev/null || true -fi +log_reaper_run candidates "$summary" printf '%s\n' "$summary" diff --git a/bin/shpool_status b/bin/shpool_status index d3c37ed..c35784a 100755 --- a/bin/shpool_status +++ b/bin/shpool_status @@ -5,34 +5,89 @@ set -u SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) CORE=${SESSION_KIT_INVENTORY_CORE:-"$SCRIPT_DIR/../lib/session_inventory.py"} -case "${1:-}" in - ""|--rows|--waiting-count|--json|--strict-json|--guard-json|--lookup) - # Codex has no title hook, so pending threads are titled before every - # fresh inventory build — menu, tab, and bounce all see a real name on - # the human's first look after an answer. Fail-open and cheap (a no-op - # read once every recent thread carries an index entry). Replay paths - # (--render-file/--lookup-file) never build fresh state and are skipped. - python3 "$CORE" automatic-title codex-pending >/dev/null 2>&1 || true - ;; -esac +# See bin/session_kit_common: the release directory is read-only, so writing a +# bytecode cache always fails. This entry point does not source that file, and +# it runs on every login picker and every `sp` render, so it sets it here too. +export PYTHONDONTWRITEBYTECODE=1 + +# Title reconciliation mutates private provider/name state and therefore runs +# only on refresh paths already documented as state-building. Human listing +# remains observation-only. +fill_pending_titles() { + python3 "$CORE" automatic-title claude-pending >/dev/null 2>&1 || true + python3 "$CORE" automatic-title codex-pending >/dev/null 2>&1 || true +} + +# Which modes touch state and which do not is the question a person actually +# has here. Listing is observation: it neither fills titles nor writes the +# cached snapshot. Explicit machine refresh modes retain their documented +# cache behaviour. +status_usage() { + cat <<'USAGE' +usage: shpool_status [mode] + +Read-only modes -- nothing on disk changes: + (no mode) the dashboard + --rows dashboard rows only + --strict-json live snapshot, strict identity, no write + --guard-json live snapshot for mutation guards, no write + --render-file <snapshot.json> [--rows] + render a snapshot file that already exists + --lookup-file <snapshot.json> <row|shpool-id> + resolve a selector inside a snapshot file + --recovery-pending-list list pending recovery records + +Modes that refresh the cached inventory as they run: + --waiting-count how many sessions are waiting for you + --detail <session-number> one session in full, for a person + --json the whole snapshot as JSON + --lookup <row|shpool-id> resolve one selector to its record + +Modes that change recovery state: + --recovery-pending-ack <source-generation-key> <old-shpool-id> <uuid> + acknowledge one pending recovery record + --recovery-remember-printed <recovery-list.json> + record what a recovery screen printed + beside each row, so a word typed later + can be checked against it + +--json, --strict-json, --guard-json, --lookup, and --lookup-file are the +machine channels and carry identifiers. The dashboard, --rows, and --detail are +what a person reads, and never print a shpool ID or a provider UUID. +Statuses: 0 done, 2 wrong arguments, 3 live evidence refused as stale, partial, +or ambiguous. `sp help exit-codes` documents them all. +USAGE +} case "${1:-}" in "") exec python3 "$CORE" render ;; --rows) exec python3 "$CORE" render --rows ;; --waiting-count) exec python3 "$CORE" waiting-count ;; - --json) exec python3 "$CORE" snapshot ;; + --json) + [[ -z ${2:-} ]] || { status_usage >&2; exit 2; } + fill_pending_titles + exec python3 "$CORE" snapshot + ;; --strict-json) exec python3 "$CORE" snapshot --strict-live --no-write ;; --guard-json) exec python3 "$CORE" snapshot --guard-live --no-write ;; --lookup) [[ -n ${2:-} && -z ${3:-} ]] || { - echo "usage: shpool_status --lookup <row|shpool-id>" >&2 + status_usage >&2 exit 2 } + fill_pending_titles exec python3 "$CORE" lookup "$2" ;; + --detail) + [[ -n ${2:-} && -z ${3:-} ]] || { + status_usage >&2 + exit 2 + } + exec python3 "$CORE" detail "$2" + ;; --render-file) [[ -n ${2:-} && ( -z ${3:-} || ${3:-} == --rows ) && -z ${4:-} ]] || { - echo "usage: shpool_status --render-file <snapshot.json> [--rows]" >&2 + status_usage >&2 exit 2 } if [[ ${3:-} == --rows ]]; then @@ -42,27 +97,38 @@ case "${1:-}" in ;; --lookup-file) [[ -n ${2:-} && -n ${3:-} && -z ${4:-} ]] || { - echo "usage: shpool_status --lookup-file <snapshot.json> <row|shpool-id>" >&2 + status_usage >&2 exit 2 } exec python3 "$CORE" lookup --input "$2" "$3" ;; --recovery-pending-list) [[ -z ${2:-} ]] || { - echo "usage: shpool_status --recovery-pending-list" >&2 + status_usage >&2 exit 2 } exec python3 "$CORE" recovery-pending list ;; + --recovery-remember-printed) + [[ -n ${2:-} && -z ${3:-} ]] || { + status_usage >&2 + exit 2 + } + exec python3 "$CORE" recovery-selectors remember --payload "$2" + ;; --recovery-pending-ack) [[ -n ${2:-} && -n ${3:-} && -n ${4:-} && -z ${5:-} ]] || { - echo "usage: shpool_status --recovery-pending-ack <source-generation-key> <old-shpool-id> <uuid>" >&2 + status_usage >&2 exit 2 } exec python3 "$CORE" recovery-pending ack "$2" "$3" "$4" ;; + -h | --help | help) + status_usage + exit 0 + ;; *) - echo "usage: shpool_status [--rows|--waiting-count|--json|--strict-json|--guard-json|--lookup <selector>|--render-file <file> [--rows]|--lookup-file <file> <selector>|--recovery-pending-list|--recovery-pending-ack <generation> <id> <uuid>]" >&2 + status_usage >&2 exit 2 ;; esac diff --git a/bin/sp b/bin/sp index 8b458b2..30ffb9f 100755 --- a/bin/sp +++ b/bin/sp @@ -10,1262 +10,555 @@ SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) # shellcheck source=session_kit_common source "$SCRIPT_DIR/session_kit_common" INVENTORY_CORE=${SESSION_KIT_INVENTORY_CORE:-"$SCRIPT_DIR/../lib/session_inventory.py"} +# Background creates and restores run with no client terminal. The daemon +# copies TERM from this environment into the new shell; with no TERM, or the +# TERM=dumb that systemd/cron inject, every provider inside starts colorless +# and stays that way for the life of the session (environment is fixed at +# exec). Scoped to non-tty callers only: a human on a genuinely limited +# terminal keeps the TERM they declared. +if [[ ! -t 1 ]]; then + case "${TERM:-}" in + '' | dumb) export TERM=xterm-256color ;; + esac +fi PICKER_REFUSED_STATUS=74 PICKER_ATTACH_FAILED_STATUS=75 +# The short synopsis every argument mistake prints. The full reference is one +# command away rather than forty lines of stderr on a typo. usage() { cat <<'EOF' -Usage: - sp list - sp new [claude|codex|shell] [project-alias] - sp name <terminal-number|shpool-id> <title> - sp name reset <terminal-number|shpool-id> - sp color <terminal-number|shpool-id> <red|blue|green|yellow|purple|orange|pink|cyan> - sp color reset <terminal-number|shpool-id> - sp self-name <2-5-word-title> - sp go <terminal-number|shpool-id> - sp takeover <terminal-number|shpool-id> - sp close <terminal-number|shpool-id> - sp repair <terminal-number|shpool-id> - sp prune - sp detail <terminal-number|shpool-id> - sp find <text> - sp history <terminal-number|shpool-id> - sp health - sp recover - sp verify-start <terminal-number|shpool-id> -EOF -} - -make_snapshot() { - sk_prepare_state || return 1 - [[ -z ${SNAPSHOT:-} || ! -f $SNAPSHOT ]] || command rm -- "$SNAPSHOT" - SNAPSHOT=$(mktemp "$SK_STATE_DIR/sp-snapshot.XXXXXX.json") || return 1 - sk_snapshot_file "$SNAPSHOT" -} - -make_strict_snapshot() { - sk_prepare_state || return 1 - [[ -z ${SNAPSHOT:-} || ! -f $SNAPSHOT ]] || command rm -- "$SNAPSHOT" - SNAPSHOT=$(mktemp "$SK_STATE_DIR/sp-strict.XXXXXX.json") || return 1 - sk_strict_snapshot_file "$SNAPSHOT" -} - -make_guard_snapshot() { - sk_prepare_state || return 1 - [[ -z ${SNAPSHOT:-} || ! -f $SNAPSHOT ]] || command rm -- "$SNAPSHOT" - SNAPSHOT=$(mktemp "$SK_STATE_DIR/sp-guard.XXXXXX.json") || return 1 - sk_guard_snapshot_file "$SNAPSHOT" -} - -cleanup_snapshot() { - if [[ -n ${SNAPSHOT:-} && -f $SNAPSHOT ]]; then - command rm -- "$SNAPSHOT" - fi -} -trap cleanup_snapshot EXIT HUP INT TERM - -# Fire-and-forget provider color push at attach time. The transcript is -# guaranteed to exist by now, unlike at creation proof, and the attach itself -# is never delayed. Codex is a clean no-op inside the propagator. The kit -# never alters the attached terminal itself; the provider applies its own -# native color from this record at the session's next start or resume. -sk_push_session_color() { - [[ -n ${1:-} && -n ${2:-} ]] || return 0 - ( python3 "$INVENTORY_CORE" color propagate "$1" "$2" >/dev/null 2>&1 \ - || true ) & -} - -attach_id() { - local id=$1 cwd=${2:-} force=${3:-0} - local -a args=(attach) - [[ $force == 1 ]] && args+=(--force) - args+=(--cmd /bin/false) - [[ -z $cwd ]] || args+=(--dir "$cwd") - args+=("$id") - sk_push_session_color "${SK_PROVIDER:-}" "${SK_UUID:-}" - exec "$SK_SHPOOL" "${args[@]}" -} - -picker_attach_id() { - local id=$1 cwd=${2:-} force=${3:-0} - local -a args=(attach) - [[ $force == 1 ]] && args+=(--force) - args+=(--cmd /bin/false) - [[ -z $cwd ]] || args+=(--dir "$cwd") - args+=("$id") - sk_push_session_color "${SK_PROOF_PROVIDER:-}" "${SK_PROOF_UUID:-}" - if "$SK_SHPOOL" "${args[@]}"; then - return 0 - fi - # One retry after a beat: takeovers and just-released sessions race the - # daemon's teardown of the previous client, and the action log shows those - # first attempts failing regularly. - sleep 0.7 - "$SK_SHPOOL" "${args[@]}" -} - -picker_action_event() { - sk_log_action "$1" "$2" || true -} - -picker_load_fresh() { - local proof=$1 - sk_prepare_state || return 1 - sk_load_picker_proof "$proof" || return 1 - make_guard_snapshot || { - sk_die "guard live inventory unavailable; nothing changed" - return 1 - } - sk_picker_proof_matches "$SNAPSHOT" || { - sk_die "displayed session changed; nothing changed" - return 1 - } -} - -picker_lock() { - exec 9>"$SK_STATE_DIR/create.lock" || return 1 - sk_lock_acquire 9 "$SK_STATE_DIR/create.lock" || { - sk_lock_release 9 - return 1 - } -} - -picker_revalidate_locked() { - make_guard_snapshot || return 1 - sk_picker_proof_matches "$SNAPSHOT" -} - -# One safe provider bounce: a new-mode Codex booted before its thread title -# existed, so its status bar shows the conversation ID forever. When the human -# opens such a session and a title now exists, terminate the idle provider — -# the session shell relaunches the SAME conversation immediately (marker -# handshake with the bashrc), so the window opens named and themed. Guards, -# all fail-open toward "no bounce": untitled-boot marker present (only -# new-mode Codex sessions ever write one, so parked or pre-existing lanes are -# structurally excluded), a title in Codex's thread store, no subagents, not -# mid-turn, quiet for two minutes, and an exact PID+start-time match before -# the signal. -picker_maybe_bounce_codex() { - local id=$1 - [[ ${SK_PROOF_PROVIDER:-} == codex ]] || return 0 - local uuid=${SK_PROOF_UUID:-} - [[ -n $uuid ]] || return 0 - local untitled="$SK_STATE_DIR/provider-untitled/$id" - [[ -f $untitled && ! -L $untitled ]] || return 0 - # A marker for a long-gone session must never linger into a PID-reuse era. - local marker_age - marker_age=$(( $(date +%s) - $(stat -c %Y "$untitled" 2>/dev/null || echo 0) )) - if (( marker_age > 604800 )); then - command rm -f -- "$untitled" - return 0 - fi - local pid=${SK_PROOF_PROVIDER_PID:-} start=${SK_PROOF_PROVIDER_START:-} - [[ $pid =~ ^[0-9]+$ && $start =~ ^[0-9]+$ ]] || return 0 - python3 - "$SNAPSHOT" "$id" "$uuid" "$pid" "$start" <<'PY' || return 0 -import json, sys -from pathlib import Path - -snapshot, shpool_id, uuid, pid_text, start_text = sys.argv[1:6] -try: - data = json.load(open(snapshot, encoding="utf-8")) - row = next( - s for s in data.get("sessions", []) - if s.get("shpool_id") == shpool_id - ) -except (OSError, ValueError, StopIteration): - raise SystemExit(1) -if row.get("subagents"): - raise SystemExit(1) -if str(row.get("agent_status") or "").casefold() == "working": - raise SystemExit(1) -# With journals disabled the output age is unknown for every session; the -# rollout-derived turn state above is then the idleness signal. A known -# recent age still blocks a bounce during active use. -age = row.get("recent_output_age_seconds") -if isinstance(age, int) and not isinstance(age, bool) and age < 120: - raise SystemExit(1) -try: - stat_text = Path(f"/proc/{pid_text}/stat").read_text() - if stat_text.rsplit(")", 1)[1].split()[19] != start_text: - raise SystemExit(1) - cmdline = Path(f"/proc/{pid_text}/cmdline").read_bytes() - if b"codex" not in cmdline: - raise SystemExit(1) -except (OSError, IndexError): - raise SystemExit(1) -raise SystemExit(0) -PY - # Only a real name is worth a restart: Codex seeds threads.title with the - # raw first prompt, and bouncing on that echo burns the one-shot restart - # before the true title exists. A refusal here leaves the untitled marker - # in place so a later reopen retries once the thread is really named. - local bounce_title - bounce_title=$(python3 "$INVENTORY_CORE" codex-bounce-title "$uuid" 2>/dev/null) || return 0 - [[ -n $bounce_title ]] || return 0 - ( umask 077 - mkdir -p "$SK_STATE_DIR/provider-bounce" && - printf '%s\n' "$uuid" > "$SK_STATE_DIR/provider-bounce/$id" - ) 2>/dev/null || return 0 - if kill -TERM "$pid" 2>/dev/null; then - command rm -f -- "$untitled" - picker_action_event picker_open provider_bounced - else - command rm -f -- "$SK_STATE_DIR/provider-bounce/$id" - fi - return 0 -} - -picker_open() { - local proof=$1 - picker_action_event picker_open requested - picker_load_fresh "$proof" || { - picker_action_event picker_open refused - return "$PICKER_REFUSED_STATUS" - } - [[ $SK_STATUS == disconnected || $SK_STATUS == Disconnected ]] || { - sk_die "$SK_PROOF_ID is already open in another window; nothing changed" - picker_action_event picker_open state_changed - return "$PICKER_REFUSED_STATUS" - } - picker_lock || { - picker_action_event picker_open refused - return "$PICKER_REFUSED_STATUS" - } - if ! picker_revalidate_locked || - [[ $SK_STATUS != disconnected && $SK_STATUS != Disconnected ]]; then - sk_lock_release 9 - sk_die "displayed session changed before open; nothing changed" - picker_action_event picker_open state_changed - return "$PICKER_REFUSED_STATUS" - fi - local id=$SK_ID cwd=$SK_CWD - # Keeping this descriptor across exec would block all managed creation for - # the lifetime of the attachment. Release it immediately before the guarded - # name-only request; --cmd /bin/false prevents a persistent raced replacement. - sk_lock_release 9 - picker_maybe_bounce_codex "$id" || true - if picker_attach_id "$id" "$cwd"; then - picker_action_event picker_open returned - return 0 - fi - picker_action_event picker_open attach_failed - sk_die "the session manager could not connect to the selected session; its cause was not identified and nothing was called dead" - return "$PICKER_ATTACH_FAILED_STATUS" -} +usage: sp <command> [arguments] + sp help [topic] -picker_takeover() { - local proof=$1 - picker_action_event picker_takeover requested - picker_load_fresh "$proof" || { - picker_action_event picker_takeover refused - return "$PICKER_REFUSED_STATUS" - } - [[ $SK_STATUS != disconnected && $SK_STATUS != Disconnected ]] || { - sk_die "$SK_PROOF_ID is available; use open instead" - picker_action_event picker_takeover state_changed - return "$PICKER_REFUSED_STATUS" - } - local id=$SK_ID title=$SK_TITLE provider=$SK_PROVIDER - sk_confirm_exact "Move session to this window" "$id" "$title" "$provider" || { - echo "Move cancelled." - picker_action_event picker_takeover cancelled - return "$PICKER_REFUSED_STATUS" - } - picker_lock || { - picker_action_event picker_takeover refused - return "$PICKER_REFUSED_STATUS" - } - if ! picker_revalidate_locked || - [[ $SK_STATUS == disconnected || $SK_STATUS == Disconnected ]]; then - sk_lock_release 9 - sk_die "displayed session changed while confirming; nothing changed" - picker_action_event picker_takeover state_changed - return "$PICKER_REFUSED_STATUS" - fi - local cwd=$SK_CWD - sk_lock_release 9 - if picker_attach_id "$id" "$cwd" 1; then - picker_action_event picker_takeover returned - return 0 - fi - picker_action_event picker_takeover attach_failed - sk_die "the session manager could not move the selected session; the other window was not described as dead" - return "$PICKER_ATTACH_FAILED_STATUS" -} - -picker_close() { - local proof=$1 - picker_action_event picker_close requested - picker_load_fresh "$proof" || { - picker_action_event picker_close refused - return 1 - } - local id=$SK_ID title=$SK_TITLE provider=$SK_PROVIDER - sk_confirm_exact "Close session and everything running inside it" \ - "$id" "$title" "$provider" || { - echo "Close cancelled." - picker_action_event picker_close cancelled - return 1 - } - picker_lock || return 1 - if ! picker_revalidate_locked; then - sk_lock_release 9 - sk_die "displayed session changed while confirming; nothing closed" - picker_action_event picker_close state_changed - return 1 - fi - if ! "$SK_SHPOOL" kill "$id"; then - sk_lock_release 9 - sk_die "shpool refused to close the exact displayed session" - picker_action_event picker_close failed - return 1 - fi - sk_lock_release 9 - picker_action_event picker_close closed - printf 'Closed exact session %s\n' "$id" -} - -# Shared tail after an exact disconnected session has been killed while the -# create lock was held. Reap anything it left holding the old terminal, then -# relaunch the same exact conversation. `restore_exact` takes the lock itself. -# True while an exact conversation is still visible anywhere in the live -# inventory, including as a process outside shpool. -conversation_is_active() { - local provider=$1 uuid=$2 - make_guard_snapshot || return 2 - local answer - answer=$(python3 -c ' -import json,sys -d=json.load(open(sys.argv[1])) -wanted_provider=sys.argv[2] -wanted_uuid=sys.argv[3].lower() -rows=d.get("sessions",[])+d.get("outside_agents",[]) -print("yes" if any( - isinstance(row,dict) - and row.get("provider")==wanted_provider - and isinstance((row.get("identity") or {}).get("uuid"),str) - and row["identity"]["uuid"].lower()==wanted_uuid - for row in rows -) else "no") -' "$SNAPSHOT" "$provider" "$uuid") || return 2 - [[ $answer == yes ]] +Run 'sp help' for every command with a one-line description, or +'sp help <topic>' for one area in detail. +EOF } -finish_recovery_after_kill() { - local id=$1 provider=$2 uuid=$3 cwd=$4 - local shell_pid=$5 shell_start=$6 provider_pid=$7 provider_start=$8 - sk_reap_session_leftovers \ - "$provider_pid" "$provider_start" \ - "$shell_pid" "$shell_start" - # A provider wedged on a terminal write can outlive `shpool kill` by a few - # seconds. Relaunching while its identity is still visible would be refused - # as a duplicate conversation -- which would leave the session closed and - # nothing brought back. Wait for it to disappear first. - local attempt - for attempt in {1..60}; do - conversation_is_active "$provider" "$uuid" || break - sleep 1 +# Every refusal carries the one prefix and says what was wrong before it shows +# the synopsis: "usage:" on its own names the shape of the command but never +# the mistake, and a person reading only the first line learned nothing. +sp_refuse() { + printf 'session-kit: %s\n' "$*" >&2 + usage >&2 + exit 2 +} + +# `sp account` and `sp worktree` hand their remaining words to python. Left +# unchecked, a typo reached argparse, which answered in its own voice: a sixth +# error prefix, the file name of an internal module, and the full list of +# machine subcommands `sp help` withholds on purpose. The verb is checked here +# instead, and the refusal names only the commands a person is meant to type. +# +# The machine subcommands stay callable -- the picker runs `sp account choices` +# -- they are simply never printed. +sp_require_subcommand() { + local group=$1 candidate=$2 known= human= + case "$group" in + account) + known="list adopt-default enroll verify configure-feeds choices binding + source bind launch-profile resume-profile sync-ui switch-prepare + switch-apply switch-commit switch-rollback switch-status" + human="list adopt-default enroll verify configure-feeds" + ;; + worktree) + known="list materialize teardown sweep release bind lookup" + human="list materialize teardown sweep" + ;; + esac + local word + for word in $known; do + [[ $word == "$candidate" ]] && return 0 done - if conversation_is_active "$provider" "$uuid"; then - sk_die "session $id was closed, but conversation $uuid is still held by a live process; run 'sp recover' once it exits" - return 1 - fi - restore_exact "$provider" "$uuid" "$cwd" -} - -# Picker path: the displayed session could not be opened. Bound to the private -# proof, so it can only ever act on the exact row the user selected. -picker_recover() { - local proof=$1 - picker_action_event picker_recover requested - sk_require_integration || { - picker_action_event picker_recover refused - return 1 - } - picker_load_fresh "$proof" || { - picker_action_event picker_recover refused - return 1 - } - [[ $SK_PROOF_PROVIDER == claude || $SK_PROOF_PROVIDER == codex ]] || { - sk_die "only an exact Claude or Codex conversation can be moved to a new session" - return 1 - } - local provider=$SK_PROOF_PROVIDER uuid=$SK_PROOF_UUID - [[ $uuid =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || { - sk_die "recovery requires an exact canonical conversation UUID" - return 1 - } - picker_lock || return 1 - if ! picker_revalidate_locked; then - sk_lock_release 9 - sk_die "displayed session changed before recovery; nothing changed" - picker_action_event picker_recover state_changed - return 1 - fi - if [[ $SK_STATUS != disconnected && $SK_STATUS != Disconnected ]]; then - sk_lock_release 9 - sk_die "the selected session is open in another window; recovery refused and nothing changed" - picker_action_event picker_recover attached - return 1 - fi - local id=$SK_ID cwd=$SK_CWD - local shell_pid=$SK_PROOF_SHELL_PID shell_start=$SK_PROOF_SHELL_START - local provider_pid=$SK_PROOF_PROVIDER_PID provider_start=$SK_PROOF_PROVIDER_START - [[ $cwd == /* && -d $cwd ]] || { - sk_lock_release 9 - sk_die "session directory is not an existing absolute directory" - return 1 - } - cwd=$(cd -- "$cwd" && pwd -P) || { - sk_lock_release 9 - return 1 - } - if ! "$SK_SHPOOL" kill "$id"; then - sk_lock_release 9 - sk_die "shpool refused to close the exact disconnected session; nothing changed" - picker_action_event picker_recover failed - return 1 - fi - sk_lock_release 9 - if finish_recovery_after_kill "$id" "$provider" "$uuid" "$cwd" \ - "$shell_pid" "$shell_start" "$provider_pid" "$provider_start"; then - picker_action_event picker_recover recovered - return 0 - fi - picker_action_event picker_recover failed - return 1 -} - -# Watchdog path: same repair, selected by session ID and never interactive. -repair_target() { - local selector=$1 - picker_action_event repair requested - sk_require_integration || { - picker_action_event repair refused - return 1 - } - make_guard_snapshot || { - picker_action_event repair refused - return 1 - } - sk_resolve "$SNAPSHOT" "$selector" || { - sk_die "no such session: $selector" - return 1 - } - sk_require_mutation_target || return 1 - [[ $SK_PROVIDER == claude || $SK_PROVIDER == codex ]] || { - sk_die "only a Claude or Codex session can be repaired" - return 1 - } - local id=$SK_ID provider=$SK_PROVIDER uuid=$SK_UUID cwd=$SK_CWD started=$SK_STARTED - local shell_pid=$SK_SHELL_PID shell_start=$SK_SHELL_START - local provider_pid=$SK_PROVIDER_PID provider_start=$SK_PROVIDER_START - local daemon_pid=$SK_DAEMON_PID daemon_start=$SK_DAEMON_START - [[ $uuid =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || { - sk_die "repair requires an exact canonical conversation UUID" - return 1 - } - [[ $cwd == /* && -d $cwd ]] || { - sk_die "session directory is not an existing absolute directory" - return 1 - } - cwd=$(cd -- "$cwd" && pwd -P) || return 1 - picker_lock || { - picker_action_event repair refused - return 1 - } - sk_revalidate "$id" "$started" "$provider" "$uuid" \ - "$shell_pid" "$shell_start" "$provider_pid" "$provider_start" \ - "$daemon_pid" "$daemon_start" || { - sk_lock_release 9 - sk_die "session changed while preparing the repair; nothing changed" - picker_action_event repair state_changed - return 1 - } - if [[ $SK_STATUS != disconnected && $SK_STATUS != Disconnected ]]; then - sk_lock_release 9 - sk_die "session is open in another window; repair refused and nothing changed" - picker_action_event repair attached - return 1 - fi - if ! "$SK_SHPOOL" kill "$id"; then - sk_lock_release 9 - sk_die "shpool refused to close the exact disconnected session; nothing changed" - picker_action_event repair failed - return 1 - fi - sk_lock_release 9 - if finish_recovery_after_kill "$id" "$provider" "$uuid" "$cwd" \ - "$shell_pid" "$shell_start" "$provider_pid" "$provider_start"; then - picker_action_event repair recovered - return 0 - fi - picker_action_event repair failed - return 1 -} - -# First-window color: boot a hidden throwaway TUI on a minted conversation ID, -# let it write its own native color record by typing /color into ITSELF (no -# human terminal is ever touched), then launch the real session as a resume of -# that conversation so the color is native from the first frame. Fail-open: -# any problem falls back to the plain new-session flow. Kill switches: -# SESSION_KIT_NO_PREBAKE=1 or $SK_STATE_DIR/prebake-off. -sk_prebake_claude() { - SK_PREBAKE_UUID= - [[ ${SESSION_KIT_NO_PREBAKE:-0} != 1 && ! -e $SK_STATE_DIR/prebake-off ]] || - return 1 - command -v script >/dev/null 2>&1 || return 1 - command -v claude >/dev/null 2>&1 || return 1 - local minted color transcript - minted=$(python3 -c 'import uuid; print(uuid.uuid4())' 2>/dev/null) || return 1 - color=$(python3 "$INVENTORY_CORE" color effective claude "$minted" \ - 2>/dev/null) || return 1 - ( cd "$cwd" 2>/dev/null && - { sleep 4; printf '/color %s\r' "$color"; sleep 3; } | - timeout 14 env -u CLAUDE_CODE_CHILD_SESSION -u CLAUDE_CODE_SESSION_ID \ - script -qec "timeout 11 claude --session-id $minted" /dev/null - ) >/dev/null 2>&1 || true - transcript=$(compgen -G "$HOME/.claude/projects/*/$minted.jsonl" | head -1) || - return 1 - [[ -n $transcript && -s $transcript && ! -L $transcript ]] || return 1 - command grep -q '"agent-color"' "$transcript" 2>/dev/null || return 1 - SK_PREBAKE_UUID=$minted -} - -start_new() { - sk_require_integration || return 1 - local requested_provider=${1:-} requested_project=${2:-} - local provider=shell cwd=$PWD project_row="" - - if [[ $requested_provider =~ ^(claude|codex|shell)$ ]]; then - provider=$requested_provider - elif [[ -n $requested_provider ]]; then - requested_project=$requested_provider - requested_provider="" - fi - - if [[ -n $requested_project ]]; then - project_row=$(sk_project_lookup "$requested_project") || { - sk_die "unknown or invalid project alias: $requested_project" - return 1 - } - local alias configured_provider configured_cwd - IFS=$'\t' read -r alias configured_provider configured_cwd <<<"$project_row" - cwd=$configured_cwd - if [[ -z $requested_provider ]]; then provider=$configured_provider; fi - fi - - [[ -d $cwd ]] || { - sk_die "project directory does not exist: $cwd" - return 1 - } - cwd=$(cd -- "$cwd" && pwd -P) || return 1 - - local launch_uuid= launch_mode=new - if [[ $provider == claude ]] && sk_prebake_claude; then - # The window opens as a resume of the pre-baked conversation, so the - # session color is already native in its very first frame. - launch_uuid=$SK_PREBAKE_UUID - launch_mode=resume - fi - sk_prepare_state || return 1 - exec 9>"$SK_STATE_DIR/create.lock" || return 1 - sk_lock_acquire 9 "$SK_STATE_DIR/create.lock" || { exec 9>&-; return 1; } - if ! sk_require_integration; then - sk_lock_release 9 - return 1 - fi - local id - id=$(sk_allocate_id) || { - sk_lock_release 9 - sk_die "could not allocate a unique session ID" - return 1 - } - sk_write_start_record "$id" "$provider" "$cwd" "$launch_uuid" "$launch_mode" || { - sk_lock_release 9 - return 1 - } - printf 'Starting %s session %s in %s\n' "$provider" "$id" "$cwd" - local creation_floor_ms - creation_floor_ms=$(sk_now_unix_ms) || { - sk_lock_release 9 - return 1 - } - if ! "$SK_SHPOOL" attach --background --dir "$cwd" "$id"; then - local quarantine - quarantine=$(sk_quarantine_start_record "$id" attach-failed 2>/dev/null || true) - sk_lock_release 9 - sk_die "shpool did not confirm session creation; launch record quarantined at ${quarantine:-$SK_START_DIR/failed}" - return 1 - fi - if ! sk_capture_session_generation "$id" "$creation_floor_ms"; then - local quarantine - quarantine=$(sk_quarantine_start_record "$id" generation-unproven 2>/dev/null || true) - sk_lock_release 9 - sk_die "session $id may be open, but its exact generation was not proven; unarmed launch record quarantined at ${quarantine:-$SK_START_DIR/failed}" - return 1 - fi - local created_started=$SK_CREATED_STARTED - local created_boot_id=$SK_CREATED_BOOT_ID - local created_shell_pid=$SK_CREATED_SHELL_PID created_shell_start=$SK_CREATED_SHELL_START - local created_daemon_pid=$SK_CREATED_DAEMON_PID created_daemon_start=$SK_CREATED_DAEMON_START - sk_write_generation_record "$id" "$provider" "$cwd" "$launch_uuid" \ - "$created_boot_id" "$created_started" "$created_shell_pid" "$created_shell_start" \ - "$created_daemon_pid" "$created_daemon_start" "$launch_mode" || { - local quarantine - quarantine=$(sk_quarantine_start_record "$id" arming-failed 2>/dev/null || true) - sk_lock_release 9 - sk_die "launch generation could not be armed; record quarantined at ${quarantine:-$SK_START_DIR/failed}" - return 1 - } - sk_lock_release 9 - if [[ $provider != shell ]] && ! sk_wait_for_provider "$id" "$provider" "$cwd" \ - "$launch_uuid" \ - "$created_boot_id" "$created_started" "$created_shell_pid" "$created_shell_start" \ - "$created_daemon_pid" "$created_daemon_start" "$launch_mode"; then - sk_die "session $id remains open, but exact $provider startup was not proven; launch record retained. Attach with 'sp go $id' and run 'exec bash -i' to retry" - return 1 - fi - if [[ $provider != shell && -n ${SK_PROVEN_UUID:-} ]]; then - # Every proven provider session gets its stable color pushed once at - # creation, so Claude shows it natively from the first resume onward. - python3 "$INVENTORY_CORE" color propagate "$provider" "$SK_PROVEN_UUID" \ - >/dev/null 2>&1 || true - fi - if [[ ${SESSION_KIT_BACKGROUND:-0} == 1 ]]; then - printf '%s\n' "$id" - return - fi - SK_PROVIDER=$provider - SK_UUID=${SK_PROVEN_UUID:-} - attach_id "$id" -} - -restore_exact() { - local provider=$1 uuid=$2 cwd=$3 - sk_require_integration || return 1 - [[ $provider =~ ^(claude|codex)$ ]] || { - sk_die "restore provider must be claude or codex" - return 1 - } - [[ $uuid =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]] || { - sk_die "restore requires an exact conversation UUID" - return 1 - } - uuid=${uuid,,} - [[ $cwd == /* && -d $cwd ]] || { - sk_die "restore cwd is not an existing absolute directory" - return 1 - } - cwd=$(cd -- "$cwd" && pwd -P) || return 1 - - sk_prepare_state || return 1 - exec 9>"$SK_STATE_DIR/create.lock" || return 1 - sk_lock_acquire 9 "$SK_STATE_DIR/create.lock" || { exec 9>&-; return 1; } - if ! sk_require_integration; then - sk_lock_release 9 - return 1 - fi - # Recovery uses the same guard inventory as every other mutating command. - # A half-started session that has not yet published a provider identity is - # unrelated to this restore and must not be able to block it; the exact - # guarantees that matter here are the duplicate-conversation check below and - # the exact generation proof captured after creation. - make_guard_snapshot || { - sk_lock_release 9 - return 1 - } - local duplicate_check - duplicate_check=$(python3 -c ' -import json,sys -d=json.load(open(sys.argv[1])) -wanted_provider=sys.argv[2] -wanted_uuid=sys.argv[3].lower() -rows=d.get("sessions",[])+d.get("outside_agents",[]) -print("yes" if any( - isinstance(row,dict) - and row.get("provider")==wanted_provider - and isinstance((row.get("identity") or {}).get("uuid"),str) - and row["identity"]["uuid"].lower()==wanted_uuid - for row in rows -) else "no") - ' "$SNAPSHOT" "$provider" "$uuid") || { - sk_lock_release 9 - sk_die "could not verify active conversation UUIDs; no recovery launched" - return 1 - } - if [[ $duplicate_check == yes ]]; then - sk_lock_release 9 - sk_die "exact conversation $uuid is already active" - return 1 - elif [[ $duplicate_check != no ]]; then - sk_lock_release 9 - sk_die "active conversation UUID check returned an invalid result" - return 1 - fi - - local id - id=$(sk_allocate_id) || { - sk_lock_release 9 - return 1 - } - sk_write_start_record "$id" "$provider" "$cwd" "$uuid" resume || { - sk_lock_release 9 - return 1 - } - local creation_floor_ms - creation_floor_ms=$(sk_now_unix_ms) || { - sk_lock_release 9 - return 1 - } - # Push the stable color before the provider starts so this very resume - # already reads it from the transcript. - python3 "$INVENTORY_CORE" color propagate "$provider" "$uuid" \ - >/dev/null 2>&1 || true - if ! "$SK_SHPOOL" attach --background --dir "$cwd" "$id"; then - local quarantine - quarantine=$(sk_quarantine_start_record "$id" attach-failed 2>/dev/null || true) - sk_lock_release 9 - sk_die "shpool did not confirm recovery shell creation; launch record quarantined at ${quarantine:-$SK_START_DIR/failed}" - return 1 - fi - if ! sk_capture_session_generation "$id" "$creation_floor_ms"; then - local quarantine - quarantine=$(sk_quarantine_start_record "$id" generation-unproven 2>/dev/null || true) - sk_lock_release 9 - sk_die "recovery shell $id may be open, but its exact generation was not proven; unarmed record quarantined at ${quarantine:-$SK_START_DIR/failed}" - return 1 - fi - sk_write_generation_record "$id" "$provider" "$cwd" "$uuid" \ - "$SK_CREATED_BOOT_ID" "$SK_CREATED_STARTED" "$SK_CREATED_SHELL_PID" "$SK_CREATED_SHELL_START" \ - "$SK_CREATED_DAEMON_PID" "$SK_CREATED_DAEMON_START" resume || { - local quarantine - quarantine=$(sk_quarantine_start_record "$id" arming-failed 2>/dev/null || true) - sk_lock_release 9 - sk_die "recovery generation could not be armed; record quarantined at ${quarantine:-$SK_START_DIR/failed}" - return 1 - } - local created_started=$SK_CREATED_STARTED - local created_boot_id=$SK_CREATED_BOOT_ID - local created_shell_pid=$SK_CREATED_SHELL_PID created_shell_start=$SK_CREATED_SHELL_START - local created_daemon_pid=$SK_CREATED_DAEMON_PID created_daemon_start=$SK_CREATED_DAEMON_START - sk_lock_release 9 - if ! sk_wait_for_provider "$id" "$provider" "$cwd" "$uuid" \ - "$created_boot_id" "$created_started" "$created_shell_pid" "$created_shell_start" \ - "$created_daemon_pid" "$created_daemon_start" resume; then - sk_die "session $id remains open, but exact $provider recovery was not proven; launch record retained. Attach with 'sp go $id' and run 'exec bash -i' to retry" - return 1 - fi - printf '%s\n' "$id" -} - -resolve_target() { - local selector=$1 - make_snapshot || return 1 - sk_resolve "$SNAPSHOT" "$selector" || { - sk_die "no unique session matches that selection" - return 1 - } -} - -go_target() { - local selector=$1 - resolve_target "$selector" || return 1 - sk_require_mutation_target || return 1 - local id=$SK_ID started=$SK_STARTED provider=$SK_PROVIDER uuid=$SK_UUID cwd=$SK_CWD - local shell_pid=$SK_SHELL_PID shell_start=$SK_SHELL_START - local provider_pid=$SK_PROVIDER_PID provider_start=$SK_PROVIDER_START - local daemon_pid=$SK_DAEMON_PID daemon_start=$SK_DAEMON_START - if [[ $SK_STATUS != disconnected && $SK_STATUS != Disconnected ]]; then - sk_die "$id is attached; use 'sp takeover $id' for a confirmed takeover" - return 1 - fi - sk_revalidate "$id" "$started" "$provider" "$uuid" "$shell_pid" "$shell_start" "$provider_pid" "$provider_start" "$daemon_pid" "$daemon_start" || { - sk_die "target changed before attach; no action taken" - return 1 - } - attach_id "$id" "$cwd" -} - -takeover_target() { - local selector=$1 - resolve_target "$selector" || return 1 - sk_require_mutation_target || return 1 - local id=$SK_ID started=$SK_STARTED provider=$SK_PROVIDER uuid=$SK_UUID title=$SK_TITLE cwd=$SK_CWD - local shell_pid=$SK_SHELL_PID shell_start=$SK_SHELL_START - local provider_pid=$SK_PROVIDER_PID provider_start=$SK_PROVIDER_START - local daemon_pid=$SK_DAEMON_PID daemon_start=$SK_DAEMON_START - if [[ $SK_STATUS == disconnected || $SK_STATUS == Disconnected ]]; then - sk_die "$id is ready, not attached; use 'sp go $id'" - return 1 - fi - sk_confirm_exact "Take over attached session" "$id" "$title" "$provider" || { - echo "Takeover cancelled." - return 1 - } - sk_revalidate "$id" "$started" "$provider" "$uuid" "$shell_pid" "$shell_start" "$provider_pid" "$provider_start" "$daemon_pid" "$daemon_start" || { - sk_die "target changed while confirming; no action taken" - return 1 - } - attach_id "$id" "$cwd" 1 + printf 'session-kit: no %s command named %s\n' "$group" "$candidate" >&2 + printf 'commands: %s\n' "$human" >&2 + exit 2 } -close_target() { - local selector=$1 - resolve_target "$selector" || return 1 - sk_require_mutation_target || return 1 - local id=$SK_ID started=$SK_STARTED provider=$SK_PROVIDER uuid=$SK_UUID title=$SK_TITLE - local shell_pid=$SK_SHELL_PID shell_start=$SK_SHELL_START - local provider_pid=$SK_PROVIDER_PID provider_start=$SK_PROVIDER_START - local daemon_pid=$SK_DAEMON_PID daemon_start=$SK_DAEMON_START - sk_confirm_exact "Close session" "$id" "$title" "$provider" || { - echo "Close cancelled." - return 1 - } - sk_revalidate "$id" "$started" "$provider" "$uuid" "$shell_pid" "$shell_start" "$provider_pid" "$provider_start" "$daemon_pid" "$daemon_start" || { - sk_die "target changed while confirming; no action taken" - return 1 - } - if ! "$SK_SHPOOL" kill "$id"; then - sk_die "shpool refused to close the exact session" - return 1 - fi - printf 'Closed exact session %s\n' "$id" -} - -show_prune() { - if [[ $(sk_platform) == Darwin ]]; then - sk_die "prune is unavailable in the macOS preview; nothing changed" - return 1 - fi - local candidate_file="$SK_STATE_DIR/prune-candidates.json" - "$SCRIPT_DIR/shpool_reaper" --candidates >/dev/null || return 1 - if [[ ! -s $candidate_file ]]; then - echo "No verified seven-day empty-shell prune candidates." - return 0 - fi - local count - count=$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("candidates",[])))' "$candidate_file") - if (( count == 0 )); then - echo "No verified seven-day empty-shell prune candidates." - return 0 - fi - printf 'Verified prune candidates:\n' - if ! python3 - "$candidate_file" <<'PY' -import json,re,sys -rows=json.load(open(sys.argv[1])).get("candidates",[]) -pattern=re.compile(r"(?:main(?:[1-9][0-9]*)?|s[0-9]{8}-[0-9]{6}-[1-9][0-9]*(?:-[1-9][0-9]*)?)") -for row in rows: - value=row.get("shpool_id") if isinstance(row,dict) else None - if not isinstance(value,str) or len(value.encode())>128 or pattern.fullmatch(value) is None: - raise SystemExit(2) -for row in rows: - print(f" {row['shpool_id']}") -PY - then - sk_die "candidate list contains an unmanaged session ID; nothing was displayed or pruned" - return 1 - fi - local index private_candidate - for (( index=0; index<count; index++ )); do - private_candidate=$(mktemp "$SK_STATE_DIR/prune-candidate.XXXXXX.json") || return 1 - if ! python3 - "$candidate_file" "$index" > "$private_candidate" <<'PY' -import json,re,sys -rows=json.load(open(sys.argv[1])).get("candidates",[]) -row=rows[int(sys.argv[2])] -required=("shpool_id","started_at_unix_ms","last_disconnected_at_unix_ms", - "shell_pid","shell_start_ticks") -if not isinstance(row,dict) or any(key not in row for key in required): - raise SystemExit(2) -session_id=row["shpool_id"] -if not isinstance(session_id,str) or len(session_id.encode())>128: - raise SystemExit(2) -if not re.fullmatch(r"(?:main(?:[1-9][0-9]*)?|s[0-9]{8}-[0-9]{6}-[1-9][0-9]*(?:-[1-9][0-9]*)?)",session_id): - raise SystemExit(2) -if not all(isinstance(row[key],int) for key in required[1:]): - raise SystemExit(2) -json.dump({key:row[key] for key in required},sys.stdout,indent=2,sort_keys=True) -sys.stdout.write("\n") -PY - then - command rm -- "$private_candidate" - sk_die "invalid or unmanaged prune candidate; no action taken" - return 1 - fi - chmod 600 "$private_candidate" 2>/dev/null || true - if ! prune_target "$private_candidate"; then - command rm -- "$private_candidate" - return 1 - fi - command rm -- "$private_candidate" - done +# The complete human command reference, grouped by the job being done. The +# machine verbs, picker-*, restore-exact, are deliberately absent: the +# picker and the managed shell call them with proof arguments a person does +# not hold, so listing them here would only invite someone to run one by hand. +# `sp help machine` names them for anyone reading the source. +sp_help_overview() { + cat <<'EOF' +sp manages Claude Code, Codex, and shell sessions inside shpool. + +usage: sp <command> [arguments] + sp help [topic] + +Sessions + sp list every managed session with its live state + sp new [claude|codex|shell] [project-alias] [--account ALIAS] [--model ID] [--launch-key KEY] + [--origin human|machine] a machine session stays out of the default picker view + [--worktree BRANCH] runs the session in that branch's own git worktree + [--no-worktree] a delegated session shares the checkout instead of getting its own + a machine session in a git repository gets its own working copy by + default, and gives it back when it closes + Codex also accepts [--prompt-file OWNER-PRIVATE-FILE] + sp go <session> open a ready session in this terminal + sp takeover <session> move an attached session here, after a check + sp close <session> close one session + sp change-model <session> <model> [--model-anyway] + move an idle conversation to another model; + a model this machine would answer with a + different one is refused, never substituted + sp account-auto-switch <session> [--apply] + say whether an idle conversation would be + carried off a subscription account that + has run dry; --apply actually moves it + sp detail <session> everything known about one session + sp health manager, daemon, and resource state + sp prune close sessions that are idle and empty + sp repair <session> rebuild after a proven shpool handoff failure + sp verify-start <session> re-prove a provider start left pending + +Names and colors + sp name <session> <title> set a local title + sp name reset <session> remove the local title + sp self-name <2-5-word-title> title the session you are running in + sp color <session> <color> set one session's color + Claude: red|blue|green|yellow|purple|orange|pink|cyan + Codex: lime|magenta|silver|sand|sky|sea + sp color reset <session> return to the derived color + sp color reconcile give every live session its own color + +Delegated work + sp teardown <session> [--merged-into REF] [--force] + close a worker and prune its worktree + sp worktree list worktrees materialized for delegated work + sp worktree materialize --repo DIR --branch BRANCH + sp worktree teardown --branch BRANCH [--merged-into REF] [--force] + +Accounts + sp account list [claude|codex] enrolled accounts and last verification + sp account adopt-default <claude|codex> <alias> <email> + sp account enroll <claude|codex> <alias> <email> + sp account verify <claude|codex> <alias> + sp account sync-rules [--check] give every profile the same rulebook + sp account configure-feeds <roster-json> <advice-json> + +History and recovery + sp find <text> search history for text + sp history <session> show one session's history + sp recover closed conversations you can restore + sp recover --allow-large-ledger deliberately stream a pathological ledger + sp restore number|selector bring one back, by what sp recover shows beside it + sp restore --allow-large-ledger number|selector + restore from the deliberate large stream + +Topics + sp help sessions start, open, move, and close sessions + sp help names titles and colors + sp help accounts provider profiles and account switching + sp help history history, search, and restore + sp help selectors what <session> accepts + sp help unavailable session records with no shell, and how they clear + sp help exit-codes what every status a Session Kit command returns means + sp help completion turn on tab completion + sp help machine the verbs the picker and the managed shell call + +<session> is a session number or an exact shpool ID. Both -h and --help print +this reference, with or without shpool installed. +EOF } -prune_target() { - local candidate_file=$1 id - id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["shpool_id"])' "$candidate_file") || return 1 - make_guard_snapshot || return 1 - sk_resolve "$SNAPSHOT" "$id" || { - sk_die "prune candidate no longer exists: $id" - return 1 - } - sk_require_mutation_target || return 1 - local title=$SK_TITLE provider=$SK_PROVIDER - sk_confirm_exact "Prune verified empty session" "$id" "$title" "$provider" || { - echo "Prune cancelled." - return 1 - } - - exec 9>"$SK_STATE_DIR/create.lock" || return 1 - sk_lock_acquire 9 "$SK_STATE_DIR/create.lock" || { exec 9>&-; return 1; } - # This is the final command before kill. It proves exact raw shpool - # generation, disconnected status, stable shell PID/start, and an empty - # process tree using two fresh shpool snapshots. Any ambiguity fails closed. - "$SCRIPT_DIR/shpool_reaper" --verify-candidate "$candidate_file" >/dev/null || { - sk_lock_release 9 - sk_die "prune candidate changed or is no longer empty; no action taken" - return 1 - } - if ! "$SK_SHPOOL" kill "$id"; then - sk_lock_release 9 - sk_die "shpool refused to kill exact verified candidate: $id" - return 1 - fi - sk_lock_release 9 - printf 'Pruned exact verified-empty session %s\n' "$id" -} +sp_help_topic() { + case "$1" in + sessions) + cat <<'EOF' +Sessions + + sp list every managed session with its live state + sp new [claude|codex|shell] [project-alias] [--account ALIAS] [--model ID] + [--origin human|machine] + [--worktree BRANCH] [--no-worktree] + [--model-anyway] + sp go <session> open a ready session in this terminal + sp takeover <session> move an attached session here, after a check + sp close <session> close one session + sp change-model <session> <model> [--model-anyway] + move an idle conversation to another model; + a model this machine would answer with a + different one is refused, never substituted + sp account-auto-switch <session> [--apply] + say whether an idle conversation would be + carried off a subscription account that + has run dry; --apply actually moves it + sp detail <session> everything known about one session + sp health manager, daemon, and resource state + sp prune close sessions that are idle and empty + sp repair <session> rebuild after a proven shpool handoff failure + sp verify-start <session> re-prove a provider start left pending + +`sp change-model` restarts the provider on the model named and resumes the same +conversation. It runs only on an idle session: a restart in the middle of a +turn would lose that turn. + +`sp account-auto-switch` says whether one idle conversation would be moved to +another enrolled account because its own is spent, and changes nothing until +`--apply` is given. It refuses unless the usage feed is fresh, refuses any +account already past the reserve, refuses a conversation whose live account +disagrees with its record or that is running a model asked for by name, and +moves a conversation at most once -- a session that spends a second account +stops and says so. It never switches an account back on. +`$XDG_STATE_HOME/session-kit/account-switching-off` turns it off, and the same +file turns the manual switch and enrolment off with it. + +`sp new <project-alias>` alone uses the provider stored for that alias. A +session number stays with its session for the current host boot. + +`sp go` opens a detached session. `sp takeover` moves an attached one after a +fresh identity check, and the window that had it returns to its picker; the +conversation is never duplicated. Closing ends the session only, and keeps the +conversation and its history. + +`sp repair` is for direct evidence of an unrecoverable shpool handoff failure. +Silence alone does not qualify, and if every session is unreachable at once, +repair is the wrong tool. + +Automation closes only with an explicit opt-in and the exact shpool ID: + + SESSION_KIT_NONINTERACTIVE=1 SESSION_KIT_CONFIRM_ID=<exact-shpool-id> \ + sp close <exact-shpool-id> +EOF + ;; + names | colors | color) + cat <<'EOF' +Names and colors + + sp name <session> <title> set a local title + sp name reset <session> remove the local title + sp self-name <2-5-word-title> title the session you are running in + sp color <session> <color> set one session's color + sp color reset <session> return to the derived color + sp color reconcile give every live session its own color + +Claude sessions take red, blue, green, yellow, purple, orange, pink, and cyan: +the eight names Claude Code itself accepts. Codex sessions take lime, magenta, +silver, sand, sky, and sea. The two palettes never overlap, so a Claude session +and a Codex session cannot look alike. + +Title priority: a local manual name, then an explicit provider rename, then a +retained automatic title, then a provider-and-project description, then a +shortened identity, then the shell command. `sp name reset` removes only the +local name; it does not hand the session back to automatic naming. + +A color is derived from the conversation identity, so it is the same every time +you return. `sp color reconcile` settles every live session at once, moves only +the ones that have to move, and changes nothing on a second run. +EOF + ;; + accounts | account) + cat <<'EOF' +Accounts + + sp account list [claude|codex] enrolled accounts and last verification + sp account adopt-default <claude|codex> <alias> <email> + record the login this machine already has + sp account enroll <claude|codex> <alias> <email> + create a separate provider profile + sp account verify <claude|codex> <alias> + confirm a profile is signed in + sp account sync-rules [--check] write the shared rules file into every + provider and profile rulebook, leaving each + one's own text alone; --check only reports + sp account configure-feeds <roster-json> <advice-json> + point account advice at local feeds + sp new <claude|codex> [project-alias] --account <alias> + +Claude Code profiles use separate CLAUDE_CONFIG_DIR roots and Codex profiles +separate CODEX_HOME roots. Enroll, complete the provider's own sign-in, then +verify. Session Kit never asks for, copies, or prints a provider token. + +Changing the account of an existing conversation is a picker action, not a +command: select the session and choose `Change account`. A switch proceeds only +when fresh evidence proves the exact conversation has no active turn, tool, +hook, or subagent work. Nothing is asked first; if the evidence is not there, +the switch is refused. + +Which accounts can be selected + +An account is selectable when its profile is enabled and the account roster +reports its health as `ok`. The roster's `serving` flag is a routing toggle +for scheduled proxy work: it is displayed, and it never decides an interactive +session, which signs in with that profile's own login and no proxy. + +Health comes from the two feeds `sp account configure-feeds` points at (a +roster of accounts and a rotation advice file), and each carries its own +timestamp. Past SESSION_KIT_ACCOUNT_ADVICE_MAX_AGE_SECONDS (600 by default), +or with no feed configured at all, nothing is known about any account's +health. Every enabled account then stays selectable, its row reads `ready +(feed stale)` or `ready (feed absent)`, and the picker names the feed's state +above its footer. Stale advice recommends nothing; it never blocks a choice. +A machine with no feeds configured behaves this way from the first session. + +Refusals name the account, not a guess about the login: + + Account work is not selectable: its health check expired. + There is no account 4 on this screen. Numbers shown here work. + +The same rule answers `sp new --account <alias>`, which refuses with `account +work is not selectable: its health check expired`. The other states an account +can carry are `its profile is disabled` and `it is not in the account roster`. +`sp account verify` refreshes a profile's own record; the roster health itself +comes from whatever writes the feed. +EOF + ;; + history | recovery | recover) + cat <<'EOF' +History and recovery + + sp find <text> search history for text + sp history <session> show one session's history + sp recover closed conversations you can restore + sp recover --allow-large-ledger deliberately stream a pathological ledger + sp restore number|selector bring one back, by what sp recover shows beside it + sp restore --allow-large-ledger number|selector + restore from the deliberate large stream + +A recording captures everything on the screen, so it is off unless you asked +for it. A session with no recording falls back to its conversation transcript, +for Claude and for Codex. Both histories include readable tool calls and tool +output. `sp find` groups matching lines under session names and reports counts. + +`sp recover` lists what you closed on purpose next to what a crash took, +newest first. A conversation stays on that list until it is restored. One +whose transcript this machine can no longer read stays as well, as history +only, and the row says so; a plain shell has no conversation to reopen, so its +row offers history only too. A row keeps the session number it has on every +other screen; a conversation whose number was retired goes by the name shown +beside it, or -- when two rows answer to one name, or another row already +answers to that name -- by the time of its own event, printed where its number +would be. A name and an event time belong to the list rather than to the +conversation, so each screen writes down what it printed beside each row: if +the word you type has come to mean a different conversation since you read it, +`sp restore` says so and restores nothing. + +Normal refreshes refuse a ledger above the pathological-file ceiling. The +error names its exact size and tells you to use `sp recover +--allow-large-ledger`; that deliberate form validates and streams the complete +file with bounded memory instead of hiding its rows. Its footer gives the +matching `sp restore --allow-large-ledger number` command. + +Restoring uses an exact Claude Code or Codex UUID and never falls back to +recency, directory, or "latest". An open conversation cannot be restored into a +second writable session: open the session that has it, or fork it +deliberately. +EOF + ;; + selectors | selector | session | targets) + cat <<'EOF' +Selectors + +<session> is either the session number shown in the picker and in `sp list`, or +the exact shpool ID. Every command that takes one accepts both. + +Session numbers are display conveniences. They are stable for the current host +boot and never change meaning under a repaint, but they describe what is on +screen, not which conversation you mean. Never automate a move, close, repair, +or cleanup by number: pass the exact shpool ID. + +No Session Kit screen prints a shpool ID or a provider UUID, and no flag reveals +one. Identifiers live in the 0600 state files, in the JSON modes +(`shpool_status --json`, `--lookup`), and in proofs. Commands still accept an ID +wherever they accept a number, so a script that already holds one keeps working. +EOF + ;; + unavailable | quarantined | phantom) + cat <<'EOF' +Unavailable session records + +The session manager can keep listing a session whose shell process no longer +exists: a kill that hit an already-dead process aborts its own bookkeeping, and +the entry stays in the list with nothing behind it. + +Session Kit quarantines those entries. They have no session number, they are +not drawn in the picker, and every `sp` verb refuses them: there is no shell +to attach to, take over, close, or repair, and a number that resolved to one +would be a number that cannot act. The picker's More screen counts them so the +state is never silent. + +They clear on their own: + + * the next cleanup pass removes a record whose shell and every process from + its session have been proved absent, and + * restarting the session manager clears every stale entry at once, which also + ends every live session, so it is not a routine step. + +Cleanup re-proves the exact manager generation and session start time, then +uses the manager's supported attach-and-exit path with a hard timeout and +checks that the entry is gone. A changed manager, a reused name, an unreadable +process census, or any surviving session process leaves the record quarantined +here instead of guessing. +EOF + ;; + exit-codes | exit | status | codes) + cat <<'EOF' +Exit codes + +Every Session Kit command uses the same statuses. + + 0 the command did what it was asked to do + 1 refused or failed; the reason is on stderr + 2 wrong arguments, or a selector that matched no single session + 3 live evidence was refused as stale, partial, or ambiguous + (shpool_status --strict-json, --guard-json), or shpool_reaper found its + disable sentinel + 64 the launcher was invoked under a name that is not a Session Kit command + 69 something required is unavailable: the current-release link, a helper + missing from the pinned release, or Bash 4 or newer on macOS + 74 the picker refused the action it was asked to take + 75 the picker's attach failed after the action was allowed + 76 a provider-exit reopen ran and the reopened provider exited non-zero; + the managed shell stops at the recovery menu again rather than closing + 78 the launcher found a release or manager link outside the install root + 127 codex_resume_here could not find the codex command + 143 sp was terminated (SIGTERM or SIGHUP) + +A refusal is a status, not a suggestion: nothing partial has happened. Session +Kit disables a whole action rather than performing part of it. +EOF + ;; + completion | complete | tab) + cat <<'EOF' +Tab completion -history_files() { - local id=$1 recovered - recovered=$(sk_recovery_journal "$id" 2>/dev/null || true) - if [[ -n $recovered && -r $recovered ]]; then - printf '%s\n' "$recovered" - elif [[ -r $SK_JOURNAL_DIR/$id.raw ]]; then - printf '%s\n' "$SK_JOURNAL_DIR/$id.raw" - elif [[ -d $SK_JOURNAL_DIR/$id ]]; then - find "$SK_JOURNAL_DIR/$id" -maxdepth 1 -type f -name 'segment-*.raw' -print | sort - fi -} +Session Kit ships one Bash completion file and installs a copy under each +command name that takes arguments: -show_history() { - local selector=$1 - resolve_target "$selector" || return 1 - show_history_id "$SK_ID" -} + ~/.local/share/bash-completion/completions/sp + ~/.local/share/bash-completion/completions/session-kit + ~/.local/share/bash-completion/completions/shpool_status + ~/.local/share/bash-completion/completions/shpool_reaper + ~/.local/share/bash-completion/completions/codex_resume_here -show_history_id() { - local id=$1 - local -a files=() - while IFS= read -r path; do [[ -n $path ]] && files+=("$path"); done < <(history_files "$id") - (( ${#files[@]} > 0 )) || { - sk_die "no live history for $id" - return 1 - } - if [[ ${SESSION_KIT_NONINTERACTIVE:-0} == 1 ]]; then - command cat -- "${files[@]}" - else - command cat -- "${files[@]}" | less -R - fi -} +With the bash-completion package present, a new shell loads the right file on +demand. Without it, source one copy from your own shell startup file: -picker_history() { - local proof=$1 - picker_load_fresh "$proof" || return 1 - show_history_id "$SK_PROOF_ID" -} + source ~/.local/share/bash-completion/completions/sp -color_target() { - local selector=$1 action=$2 chosen=${3:-} - resolve_target "$selector" || return 1 - sk_require_mutation_target || return 1 - [[ $SK_PROVIDER == claude || $SK_PROVIDER == codex ]] || { - sk_die "managed shell rows keep the derived look and cannot be colored" - return 1 - } - local id=$SK_ID started=$SK_STARTED provider=$SK_PROVIDER uuid=$SK_UUID - local shell_pid=$SK_SHELL_PID shell_start=$SK_SHELL_START - local provider_pid=$SK_PROVIDER_PID provider_start=$SK_PROVIDER_START - local daemon_pid=$SK_DAEMON_PID daemon_start=$SK_DAEMON_START - picker_lock || return 1 - sk_revalidate "$id" "$started" "$provider" "$uuid" \ - "$shell_pid" "$shell_start" "$provider_pid" "$provider_start" \ - "$daemon_pid" "$daemon_start" || { - sk_lock_release 9 - sk_die "target changed before color update; nothing changed" - return 1 - } - if [[ $action == delete ]]; then - python3 "$INVENTORY_CORE" color delete "$provider" "$uuid" >/dev/null || { - sk_lock_release 9 - sk_die "could not reset the exact session color; nothing changed" - return 1 - } - else - python3 "$INVENTORY_CORE" color set "$provider" "$uuid" "$chosen" \ - >/dev/null || { - sk_lock_release 9 - sk_die "could not set the exact session color; nothing changed" - return 1 - } - fi - sk_lock_release 9 - if [[ $action == delete ]]; then - printf 'Reset color for exact %s session %s (stable hash color applies)\n' \ - "$provider" "$id" - else - printf 'Colored exact %s session %s: %s\n' "$provider" "$id" "$chosen" - fi - printf 'Claude sessions show the color natively from their next start or resume.\n' -} +It completes verbs, help topics, flags, provider names, color names, and the +project aliases in your projects file. It never runs a session command and +never asks the manager for state, so a Tab press cannot change anything or +block on a snapshot, which is also why it does not complete session numbers. -update_ai_alias() { - local action=$1 provider=$2 uuid=$3 title=${4:-} - case "$action" in - set) - python3 "$INVENTORY_CORE" alias set "$provider" "$uuid" -- "$title" >/dev/null +Set SESSION_KIT_COMPLETION_DIR to install the copies somewhere else, or to +"none" to skip them. Uninstall removes only files that still carry the kit's +own marker line, so an edited copy is left alone. +EOF ;; - delete) - python3 "$INVENTORY_CORE" alias delete "$provider" "$uuid" >/dev/null + machine | internal | picker) + cat <<'EOF' +Machine verbs + +These exist for the picker and the managed shell, which call them with proof +arguments a person does not hold. They are not part of the human command set, +they are not listed in `sp help`, and running one by hand is a good way to be +refused. + + sp picker-open <session> the picker's own attach path + sp picker-takeover <session> + sp picker-close <session> + sp picker-recover <session> + sp picker-history <session> + sp picker-name <session> <title> + sp picker-name-reset <session> + sp picker-title-refresh <session> + sp picker-fork <session> + sp picker-account-switch <session> <alias> + sp picker-change-model <session> <model> + sp account-auto-switch <session> [--dry-run] + sp restore-exact <provider> <uuid> <cwd> [account-alias] + sp worktree bind --path DIR --shpool-id ID + sp worktree lookup --path DIR + +A picker verb answers with 74 when it refuses the action and 75 when the attach +itself failed after the action was allowed, so the picker can tell the two +apart. Use the human verbs (`sp go`, `sp close`, `sp name`, `sp recover`) +for anything you type yourself. +EOF ;; *) - return 2 + return 1 ;; esac } -picker_alias() { - local proof=$1 action=$2 title=${3:-} - picker_load_fresh "$proof" || return 1 - [[ $SK_PROOF_PROVIDER == claude || $SK_PROOF_PROVIDER == codex ]] || { - sk_die "managed shell labels are derived and cannot be renamed" - return 1 - } - picker_lock || return 1 - if ! picker_revalidate_locked; then - sk_lock_release 9 - sk_die "displayed session changed before name update; nothing changed" - return 1 - fi - update_ai_alias "$action" "$SK_PROOF_PROVIDER" "$SK_PROOF_UUID" "$title" || { - sk_lock_release 9 - sk_die "could not update the exact session name; nothing changed" - return 1 - } - sk_lock_release 9 - if [[ $action == delete ]]; then - printf 'Reset local name for exact %s session %s\n' "$SK_PROOF_PROVIDER" "$SK_PROOF_ID" - else - printf 'Named exact %s session %s\n' "$SK_PROOF_PROVIDER" "$SK_PROOF_ID" - fi -} - -name_target() { - local selector=$1 action=$2 title=${3:-} - resolve_target "$selector" || return 1 - sk_require_mutation_target || return 1 - [[ $SK_PROVIDER == claude || $SK_PROVIDER == codex ]] || { - sk_die "managed shell labels are derived and cannot be renamed" - return 1 - } - local id=$SK_ID started=$SK_STARTED provider=$SK_PROVIDER uuid=$SK_UUID - local shell_pid=$SK_SHELL_PID shell_start=$SK_SHELL_START - local provider_pid=$SK_PROVIDER_PID provider_start=$SK_PROVIDER_START - local daemon_pid=$SK_DAEMON_PID daemon_start=$SK_DAEMON_START - picker_lock || return 1 - sk_revalidate "$id" "$started" "$provider" "$uuid" \ - "$shell_pid" "$shell_start" "$provider_pid" "$provider_start" \ - "$daemon_pid" "$daemon_start" || { - sk_lock_release 9 - sk_die "target changed before name update; nothing changed" - return 1 - } - update_ai_alias "$action" "$provider" "$uuid" "$title" || { - sk_lock_release 9 - sk_die "could not update the exact session name; nothing changed" - return 1 - } - sk_lock_release 9 - if [[ $action == delete ]]; then - printf 'Reset local name for exact %s session %s\n' "$provider" "$id" - else - printf 'Named exact %s session %s\n' "$provider" "$id" - fi -} - -picker_fork() { - local proof=$1 - sk_require_integration || return 1 - picker_load_fresh "$proof" || return 1 - [[ $SK_PROOF_PROVIDER == claude || $SK_PROOF_PROVIDER == codex ]] || { - sk_die "only exact Claude or Codex sessions can be forked" - return 1 - } - local provider=$SK_PROOF_PROVIDER source_uuid=$SK_PROOF_UUID - [[ $source_uuid =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || { - sk_die "fork requires an exact canonical source UUID" - return 1 - } - picker_lock || return 1 - if ! sk_require_integration; then - sk_lock_release 9 - return 1 - fi - if ! picker_revalidate_locked; then - sk_lock_release 9 - sk_die "displayed session changed before fork; nothing launched" - return 1 - fi - local cwd=$SK_CWD - [[ $cwd == /* && -d $cwd ]] || { - sk_lock_release 9 - sk_die "fork source directory is not an existing absolute directory" - return 1 - } - cwd=$(cd -- "$cwd" && pwd -P) || { - sk_lock_release 9 - return 1 - } - - local id - id=$(sk_allocate_id) || { - sk_lock_release 9 - sk_die "could not allocate a unique fork session ID" - return 1 - } - sk_write_start_record "$id" "$provider" "$cwd" "$source_uuid" fork || { - sk_lock_release 9 - sk_die "could not write the exact fork launch record" - return 1 - } - local creation_floor_ms - creation_floor_ms=$(sk_now_unix_ms) || { - sk_lock_release 9 - return 1 - } - if ! "$SK_SHPOOL" attach --background --dir "$cwd" "$id"; then - local quarantine - quarantine=$(sk_quarantine_start_record "$id" attach-failed 2>/dev/null || true) - sk_lock_release 9 - sk_die "shpool did not confirm fork shell creation; launch record quarantined at ${quarantine:-$SK_START_DIR/failed}" - return 1 - fi - if ! sk_capture_session_generation "$id" "$creation_floor_ms"; then - local quarantine - quarantine=$(sk_quarantine_start_record "$id" generation-unproven 2>/dev/null || true) - sk_lock_release 9 - sk_die "fork shell $id may be open, but its exact generation was not proven; unarmed record quarantined at ${quarantine:-$SK_START_DIR/failed}" - return 1 - fi - local created_started=$SK_CREATED_STARTED - local created_boot_id=$SK_CREATED_BOOT_ID - local created_shell_pid=$SK_CREATED_SHELL_PID created_shell_start=$SK_CREATED_SHELL_START - local created_daemon_pid=$SK_CREATED_DAEMON_PID created_daemon_start=$SK_CREATED_DAEMON_START - sk_write_generation_record "$id" "$provider" "$cwd" "$source_uuid" \ - "$created_boot_id" "$created_started" "$created_shell_pid" "$created_shell_start" \ - "$created_daemon_pid" "$created_daemon_start" fork || { - local quarantine - quarantine=$(sk_quarantine_start_record "$id" arming-failed 2>/dev/null || true) - sk_lock_release 9 - sk_die "fork generation could not be armed; record quarantined at ${quarantine:-$SK_START_DIR/failed}" - return 1 - } - sk_lock_release 9 - if ! sk_wait_for_provider "$id" "$provider" "$cwd" "$source_uuid" \ - "$created_boot_id" "$created_started" "$created_shell_pid" "$created_shell_start" \ - "$created_daemon_pid" "$created_daemon_start" fork; then - sk_die "session $id remains open, but a distinct exact $provider fork was not proven; fork launch record retained" - return 1 - fi - printf 'Forked exact %s conversation %s into session %s as %s\n' \ - "$provider" "$source_uuid" "$id" "$SK_PROVEN_UUID" -} - -find_history() { - local query=$1 - [[ -n $query ]] || return 1 - local found=0 path - while IFS= read -r path; do - [[ -r $path ]] || continue - if LC_ALL=C grep -aFi -- "$query" "$path" >/dev/null 2>&1; then - printf '\n%s\n' "$path" - LC_ALL=C grep -aFi -- "$query" "$path" | head -8 - found=1 - fi - done < <( - find "$SK_JOURNAL_DIR" -type f -name '*.raw' -print 2>/dev/null - find "$SK_RECOVERY_DIR" -type f -name '*.raw' -print 2>/dev/null - ) - while IFS= read -r path; do - [[ -r $path ]] || continue - if gzip -dc "$path" 2>/dev/null | LC_ALL=C grep -aFi -- "$query" >/dev/null 2>&1; then - printf '\n%s\n' "$path" - gzip -dc "$path" 2>/dev/null | LC_ALL=C grep -aFi -- "$query" | head -8 - found=1 - fi - done < <(find "$SK_ARCHIVE_DIR" -type f -name '*.gz' -print 2>/dev/null) - (( found == 1 )) || echo "Nothing found." -} - -show_health() { - sk_require_shpool || return 1 - local daemon - daemon=$(pgrep -f '^.*/shpool daemon$' | head -1) - if [[ -z $daemon ]]; then - echo "shpool daemon: unavailable" - return 1 +# Help must answer before anything else can refuse. It is the one command that +# has to work on a machine where shpool was never installed, or was installed +# and later broke, because it is where the instructions for fixing that live. +sp_help() { + local topic=${1:-} + if (($# > 1)); then + printf 'session-kit: sp help takes one topic\n' >&2 + usage >&2 + return 2 fi - ps -o pid=,rss=,nlwp=,etime=,command= -p "$daemon" - printf 'Host memory:\n' - free -h 2>/dev/null || true -} - -show_recovery() { - local manifest="$SK_STATE_DIR/recovery-manifest.json" - [[ -r $manifest ]] || { - echo "No exact recovery manifest is available." - return 0 - } - python3 - "$manifest" <<'PY' -import json, sys -d=json.load(open(sys.argv[1])) -rows=(d.get("sessions") or {}).values() -for i,row in enumerate(rows,1): - print(f"{i:3} {row.get('provider','unknown'):6} {row.get('title','')} {row.get('uuid','')}") -PY - echo "Recovery launches only through the reviewed login chooser." -} - -verify_start_target() { - local selector=$1 - sk_require_integration || return 1 - resolve_target "$selector" || return 1 - sk_require_mutation_target || return 1 - local id=$SK_ID - sk_read_start_expectation "$id" || { - sk_die "no complete retained launch proof exists for that session" - return 1 - } - local expected_provider=$SK_EXPECT_PROVIDER expected_cwd=$SK_EXPECT_CWD - local expected_uuid=$SK_EXPECT_UUID expected_started=$SK_EXPECT_STARTED - local expected_boot_id=$SK_EXPECT_BOOT_ID - local expected_shell_pid=$SK_EXPECT_SHELL_PID expected_shell_start=$SK_EXPECT_SHELL_START - local expected_daemon_pid=$SK_EXPECT_DAEMON_PID expected_daemon_start=$SK_EXPECT_DAEMON_START - local SESSION_KIT_PROVIDER_PROOF_ATTEMPTS=1 - if sk_wait_for_provider "$id" "$expected_provider" "$expected_cwd" "$expected_uuid" \ - "$expected_boot_id" "$expected_started" "$expected_shell_pid" "$expected_shell_start" \ - "$expected_daemon_pid" "$expected_daemon_start" "$SK_EXPECT_LAUNCH_MODE"; then - printf 'Verified exact %s provider and cleared retained launch record for %s\n' "$expected_provider" "$id" + if [[ -z $topic ]]; then + sp_help_overview return 0 fi - sk_die "exact provider is not active yet; record retained. Attach with 'sp go $id' and run 'exec bash -i' to retry" -} + sp_help_topic "$topic" && return 0 + printf 'session-kit: there is no help topic named %s\n' "$topic" >&2 + printf 'topics: sessions names accounts history selectors unavailable exit-codes completion machine\n' >&2 + return 2 +} + +# sp's action implementations live beside this program under ../lib/sh, in a +# Git checkout and in an installed release alike: the launcher always execs +# this file from inside its own release, so SCRIPT_DIR resolves to that same +# tree. Every module defines functions only. They are sourced here, ahead of +# the first trap, so no handler is ever installed before the function it names +# exists, and after SCRIPT_DIR, session_kit_common, INVENTORY_CORE, and the +# picker status codes are in place. +MODULE_DIR=$SCRIPT_DIR/../lib/sh +# shellcheck source=lib/sh/sp_core.sh +source "$MODULE_DIR/sp_core.sh" +# shellcheck source=lib/sh/sp_picker.sh +source "$MODULE_DIR/sp_picker.sh" +# shellcheck source=lib/sh/sp_provider_bounce.sh +source "$MODULE_DIR/sp_provider_bounce.sh" +# shellcheck source=lib/sh/sp_sessions.sh +source "$MODULE_DIR/sp_sessions.sh" +# shellcheck source=lib/sh/sp_commands.sh +source "$MODULE_DIR/sp_commands.sh" + +# Two cleanups, both required, neither an alternative to the other. This is a +# merge of two branches that each added one to the same three traps. +# `sk_handoff_guard_abandon` puts stderr back where the caller had it, so an +# interrupted `sp` cannot leave the launcher's capture bypassed. It +# deliberately does NOT touch the terminal: a trap can fire while an attach +# client is still running, and nothing may fight a live client for the line +# discipline. The terminal repair happens only on the normal return from +# picker_attach_id, where the client has exited and been waited on. +# `sk_prebake_abort` ends what `sp` itself started: the first-frame colour step +# runs a real hidden provider, and the reap at the end of that function never +# runs when the shell is being torn down -- exactly the path that left a +# four-day-old provider pair alive on this machine. +trap 'sk_handoff_guard_abandon; sk_prebake_abort; cleanup_snapshot' EXIT +# Ctrl-C must actually stop sp. A trap that only cleaned up let bash resume +# the script mid-flow with the snapshot deleted from under it, so no sp +# operation could ever be interrupted. Held locks release with the process. +trap 'sk_handoff_guard_abandon; sk_prebake_abort; cleanup_snapshot; trap - INT; kill -INT "$$"' INT +trap 'sk_handoff_guard_abandon; sk_prebake_abort; cleanup_snapshot; exit 143' TERM HUP command=${1:-list} -if [[ $command == prune && $(sk_platform) == Darwin ]]; then - sk_die "prune is unavailable in the macOS preview; nothing changed" - exit 1 -fi +# Help is answered before shpool is required. `sp --help` on a machine where +# shpool is missing, half-installed, or broken used to print "shpool executable +# unavailable" and nothing else -- the one moment the reference is most needed +# was the one moment it could not be reached. +case "$command" in + help | -h | --help) + shift + sp_help "$@" + exit $? + ;; + list) + [[ $# -le 1 ]] || sp_refuse "sp list takes nothing else" + ;; +esac sk_require_shpool || exit 1 case "$command" in list) @@ -1273,114 +566,170 @@ case "$command" in ;; new) shift - start_new "${1:-}" "${2:-}" + start_new "$@" ;; name) if [[ ${2:-} == reset ]]; then - [[ $# -eq 3 ]] || { usage >&2; exit 2; } + [[ $# -eq 3 ]] || sp_refuse "sp name reset takes one session" name_target "$3" delete else - [[ $# -eq 3 ]] || { usage >&2; exit 2; } + [[ $# -eq 3 ]] || sp_refuse "sp name takes one session and one title" name_target "$2" set "$3" fi ;; self-name) - [[ $# -eq 2 ]] || { usage >&2; exit 2; } + [[ $# -eq 2 ]] || sp_refuse "sp self-name takes one title" exec python3 "$INVENTORY_CORE" automatic-title self-name "$2" ;; color) - if [[ ${2:-} == reset ]]; then - [[ $# -eq 3 ]] || { usage >&2; exit 2; } + if [[ ${2:-} == reconcile ]]; then + [[ $# -eq 2 ]] || sp_refuse "sp color reconcile takes nothing else" + color_reconcile + elif [[ ${2:-} == reset ]]; then + [[ $# -eq 3 ]] || sp_refuse "sp color reset takes one session" color_target "$3" delete else - [[ $# -eq 3 ]] || { usage >&2; exit 2; } + [[ $# -eq 3 ]] || sp_refuse "sp color takes one session and one color" color_target "$2" set "$3" fi ;; go|resume|attach) - [[ $# -eq 2 ]] || { usage >&2; exit 2; } + [[ $# -eq 2 ]] || sp_refuse "sp go takes one session" go_target "$2" ;; takeover) - [[ $# -eq 2 ]] || { usage >&2; exit 2; } + [[ $# -eq 2 ]] || sp_refuse "sp takeover takes one session" takeover_target "$2" ;; close|kill) - [[ $# -eq 2 ]] || { usage >&2; exit 2; } + [[ $# -eq 2 ]] || sp_refuse "sp close takes one session" close_target "$2" ;; + change-model) + [[ $# -eq 3 || ($# -eq 4 && $4 == --model-anyway) ]] || + sp_refuse "sp change-model takes one session and one model" + change_model_target "$2" "$3" "${4:-}" + ;; + teardown) + shift + [[ $# -gt 0 ]] || sp_refuse "sp teardown takes one session" + teardown_target "$@" + ;; + worktree) + shift + [[ $# -gt 0 ]] || sp_refuse "sp worktree takes a command" + sp_require_subcommand worktree "$1" + exec python3 "$INVENTORY_CORE" worktree "$@" + ;; prune) - [[ $# -eq 1 ]] || { usage >&2; exit 2; } + [[ $# -eq 1 ]] || sp_refuse "sp prune takes nothing else" show_prune ;; detail) - [[ $# -eq 2 ]] || { usage >&2; exit 2; } - exec "$SCRIPT_DIR/shpool_status" --lookup "$2" + [[ $# -eq 2 ]] || sp_refuse "sp detail takes one session" + exec "$SCRIPT_DIR/shpool_status" --detail "$2" + ;; + account) + shift + [[ $# -gt 0 ]] || sp_refuse "sp account takes a command" + sp_require_subcommand account "$1" + exec python3 "$INVENTORY_CORE" account "$@" ;; history|log) - [[ $# -eq 2 ]] || { usage >&2; exit 2; } + [[ $# -eq 2 ]] || sp_refuse "sp history takes one session" show_history "$2" ;; find|grep) shift - [[ $# -gt 0 ]] || { usage >&2; exit 2; } + [[ $# -gt 0 ]] || sp_refuse "sp find takes text to search for" find_history "$*" ;; health|resources) show_health ;; recover) - show_recovery + if [[ $# -eq 1 ]]; then + show_recovery no + elif [[ $# -eq 2 && $2 == --allow-large-ledger ]]; then + show_recovery yes + else + sp_refuse "sp recover takes only --allow-large-ledger" + fi + ;; + restore) + # A name can be several words, and quoting one is not something a person + # should have to know, so everything after the verb is the selector. + shift + allow_large=no + if [[ ${1:-} == --allow-large-ledger ]]; then + allow_large=yes + shift + fi + [[ $# -ge 1 ]] || sp_refuse "sp restore takes one number from sp recover, or the selector beside a session that has none" + restore_listed "$*" "$allow_large" ;; verify-start) - [[ $# -eq 2 ]] || { usage >&2; exit 2; } + [[ $# -eq 2 ]] || sp_refuse "sp verify-start takes one session" verify_start_target "$2" ;; picker-open) - [[ $# -eq 2 ]] || { usage >&2; exit 2; } + [[ $# -eq 2 ]] || sp_refuse "$command takes different arguments" picker_open "$2" ;; picker-takeover) - [[ $# -eq 2 ]] || { usage >&2; exit 2; } + [[ $# -eq 2 ]] || sp_refuse "$command takes different arguments" picker_takeover "$2" ;; picker-close) - [[ $# -eq 2 ]] || { usage >&2; exit 2; } + [[ $# -eq 2 ]] || sp_refuse "$command takes different arguments" picker_close "$2" ;; picker-recover) - [[ $# -eq 2 ]] || { usage >&2; exit 2; } + [[ $# -eq 2 ]] || sp_refuse "$command takes different arguments" picker_recover "$2" ;; repair) - [[ $# -eq 2 ]] || { usage >&2; exit 2; } + [[ $# -eq 2 ]] || sp_refuse "sp repair takes one session" repair_target "$2" ;; picker-history) - [[ $# -eq 2 ]] || { usage >&2; exit 2; } + [[ $# -eq 2 ]] || sp_refuse "$command takes different arguments" picker_history "$2" ;; picker-name) - [[ $# -eq 3 ]] || { usage >&2; exit 2; } + [[ $# -eq 3 ]] || sp_refuse "$command takes different arguments" picker_alias "$2" set "$3" ;; picker-name-reset) - [[ $# -eq 2 ]] || { usage >&2; exit 2; } + [[ $# -eq 2 ]] || sp_refuse "$command takes different arguments" picker_alias "$2" delete ;; + picker-title-refresh) + [[ $# -eq 2 ]] || sp_refuse "$command takes different arguments" + picker_refresh_title "$2" + ;; picker-fork) - [[ $# -eq 2 ]] || { usage >&2; exit 2; } + [[ $# -eq 2 ]] || sp_refuse "$command takes different arguments" picker_fork "$2" ;; - restore-exact) - [[ $# -eq 4 ]] || { usage >&2; exit 2; } - restore_exact "$2" "$3" "$4" + picker-account-switch) + [[ $# -eq 3 ]] || sp_refuse "$command takes different arguments" + picker_account_switch "$2" "$3" + ;; + picker-change-model) + [[ $# -eq 3 ]] || sp_refuse "$command takes different arguments" + picker_change_model "$2" "$3" ;; - help|-h|--help) - usage + account-auto-switch) + [[ $# -eq 2 || ($# -eq 3 && ($3 == --apply || $3 == --dry-run)) ]] || + sp_refuse "sp account-auto-switch takes one session and an optional --apply" + account_auto_switch_target "$2" "${3:-}" + ;; + restore-exact) + [[ $# -ge 4 && $# -le 6 ]] || sp_refuse "$command takes different arguments" + restore_exact "$2" "$3" "$4" "${5:-}" "${6:-}" ;; *) - usage >&2 - exit 2 + sp_refuse "there is no command named $command" ;; esac diff --git a/config/claude/README.md b/config/claude/README.md new file mode 100644 index 0000000..c13857a --- /dev/null +++ b/config/claude/README.md @@ -0,0 +1,191 @@ +# Claude Code integration files (canonical copies) + +These files make a Session Kit name visible inside a Claude Code window, and one +of them tells the kit when a session starts waiting for a person. +`nameintent_title.sh` and `statusline.sh` are installer-managed: install, +update, and rollback copy them from the selected release, and uninstall removes +an unchanged kit copy and its exact registrations. `attention_hook.sh` is the +exception -- it is hand-deployed, so no install, update, or rollback copies it +and no uninstall removes it. Every copy here is canonical: `session-kit doctor` +compares installed content to it, and an edit to a live file has to be mirrored +back or the next person reads a stale source. + +| repo copy | live file | +|---|---| +| `nameintent_title.sh` | `~/.claude/hooks/nameintent_title.sh` | +| `attention_hook.sh` | `~/.claude/hooks/attention_hook.sh` | +| `statusline.sh` | `~/.claude/statusline.sh` | + +`session-kit doctor` checks the naming hook as `naming-hook` and the status line +as `statusline`. Both files must be owner-only executable regular files whose +bytes match the active release. The hook must be registered for all three of its +events (see [Hook registration](#hook-registration)) and produce the expected +title JSON against a throwaway fixture `HOME`; the status line must have its +exact registration. Every enrolled Claude account profile is checked because +`CLAUDE_CONFIG_DIR` replaces the default settings. A missing piece is a warning, +not a failure. + +## How the kit learns a session is waiting for you + +`claude agents --json` is a poll: it says what was true when it ran, and it is +the most expensive part of a snapshot, so it cannot simply run more often. A +question that appears between two polls is invisible until the next one. + +`attention_hook.sh` closes that gap. Claude Code fires its Notification hook at +the moment attention is wanted, and the hook writes one small record per +session under +`${SESSION_KIT_STATE_DIR:-~/.local/state/session-kit}/attention/claude/<uuid>.json`. +The picker reads that record beside the poll and takes whichever evidence is +newer; the poll stays as reconciliation, so an estate with no hook installed +behaves exactly as it did before. Register it for three events: + +```bash +install -D -m 700 \ + ~/.local/lib/session-kit/current/config/claude/attention_hook.sh \ + ~/.claude/hooks/attention_hook.sh +``` + +Then add the three registrations to the applicable Claude settings file: + +```json +"Notification": [{"matcher": "", "hooks": [{"type": "command", "command": "~/.claude/hooks/attention_hook.sh", "timeout": 10}]}], +"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "~/.claude/hooks/attention_hook.sh", "timeout": 10}]}], +"SessionEnd": [{"hooks": [{"type": "command", "command": "~/.claude/hooks/attention_hook.sh", "timeout": 10}]}] +``` + +`session-kit doctor` reports it as `attention-hook`: the file must be an +owner-only executable regular file, registered for all three events, and it has +to produce a real record when doctor feeds it a fixture notification. Not +installed at all is a warning that names what you lose, not a failure. + +Two settings decide how far this goes. `SESSION_KIT_ATTENTION_SOURCE=poll` is +the kill switch, the hook keeps writing, nothing reads it, and the picker is +back to the poll-only reading it shipped with. `hook` is the opposite extreme +and exists for drills. The default, `auto`, merges the two. + +Which notification types mean "a person is waited on" is decided in one place +(`lib/sessionkit_inventory/attention.py`) and pinned to Claude Code's own +matcher enum by `tests/test_attention_truth.py`. A type the kit does not +recognise is deliberately left alone: the poll still sees that session, so a new +vendor event costs a refresh of latency rather than a wrong answer. + +## How a session name reaches a Claude window + +The kit pushes a name onto three surfaces: `~/.claude/sessions/<uuid>.nameintent`, +an `agent-name` record appended to the matching transcripts, and the per-PID +`~/.claude/sessions/<pid>.json` record whose `sessionId` matches the +conversation. The three feed different parts of the interface, and only the +first is read by the hook. + +Claude Code applies an external `sessionTitle` from hook output at exactly two +moments in the supported provider behavior: **SessionStart** and +**UserPromptSubmit**. `Stop` and `PostToolUse` output is ignored. A window that +is only being watched therefore keeps its old tab title and top-right chip until +the human sends the next prompt; the name is not lost, it is queued behind an +event that has not happened yet. + +The status line is the one surface that repaints live mid-turn. It renders the +nameintent name at the start of line 1, tinted with the session's kit color. +Register it with a refresh timer so idle windows update too: + +```json +"statusLine": { + "type": "command", + "command": "~/.claude/statusline.sh", + "refreshInterval": 2 +} +``` + +Three things about that script are worth knowing before deploying it. It reads +`display_color` out of `~/.local/state/session-kit/inventory.json` by a literal +path, so an installation that moves its state root with `SESSION_KIT_STATE_DIR` +or `XDG_STATE_HOME` gets the untinted fallback rather than an error. Its color +table covers exactly the eight names in the Claude palette and nothing else, +which is complete rather than partial: the row is looked up by Claude Code's own +`session_id`, so only a Claude color can ever reach it, and the six Codex-only +names cannot. And it declares itself the Linux status line: line 2 uses GNU +`stat -c`. + +### Line 2: quota refresher extension + +The quota display is an extension point. With no executable +`~/.claude/statusline-quota-refresh.sh`, line 2 reads `quota --`; the name, +model, location, context usage, and account display keep working. Session Kit +does not make a quota request itself. A contract skeleton ships as +`extras/statusline-quota-refresh.example`; it deliberately leaves the endpoint +and credential-bearing transport as a marked TODO. + +The status line invokes a refresher in the background at most once every 180 +seconds. An ambient account runs it normally. An enrolled account runs it with +`HOME` set to an isolated probe directory and `CLAUDE_CONFIG_DIR` set to that +profile; the probe home links that profile's `.claude.json` and +`.credentials.json`. The refresher must therefore use the `HOME` it receives, +not assume the operator's normal home. + +The refresher writes `$HOME/.claude/cache/quota_headers` as `key: value` lines. +The required fields are: + +- `x-probe-account`: the email from that invocation's `.claude.json`; a + mismatch is discarded rather than shown for the wrong account; +- `anthropic-ratelimit-unified-5h-utilization`: a fraction from 0 to 1; +- `anthropic-ratelimit-unified-5h-reset`: a Unix epoch time in seconds. + +The matching `anthropic-ratelimit-unified-7d-utilization` and `-7d-reset` +fields are optional, but must appear as a pair. The status line copies a valid +result into its account-and-profile-specific directory under +`~/.claude/cache/session-kit-quota/`; the refresher does not write there. + +After filling in the TODO, install a private executable copy: + +```bash +install -m 700 ~/.local/lib/session-kit/current/extras/statusline-quota-refresh.example ~/.claude/statusline-quota-refresh.sh +``` + +Windows that boot before their name exists are restarted-as-resumed once at +reopen by the provider bounce (`picker_bounce_claude` in +`lib/sh/sp_provider_bounce.sh`, sourced by `bin/sp`), which boots them through +SessionStart with the name already applied. The bounce is heavily guarded, since +restarting a provider that is mid-thought would lose work: it runs only against +a detached ready session with no subagents, whose provider is idle or awaiting a +reply, that has produced no output for at least 120 seconds, and whose PID and +process start time still match the frozen proof. It also asks the kit whether a +real prompt already followed the name intent, if one did, the live window has +the name, so the pending marker is dropped and nothing restarts. + +## Hook registration + +`nameintent_title.sh` must be registered in `~/.claude/settings.json` for +`SessionStart`, `UserPromptSubmit`, and `Stop`. The first two are the events +that can retitle a live window. `Stop` cannot, and the hook emits nothing there; +it is registered because Stop is where the kit derives an automatic title for a +resume-launched conversation that Claude never auto-titled, writing the intent +file that renames the window at the next prompt. + +The hook fails open. Any unexpected input, missing file, or failed lookup exits +0 with no output, which leaves the window titled as it was. + +## Why the vendor's off-switch is never used + +`CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1` looks like the clean way to stop Claude +Code writing its own tab titles so the kit can own the tab outright. It is not. +Compatibility checks show that it suppresses every OSC title sequence, +including a title supplied by hook output: + +| run | switch | what the window wrote | +| --- | --- | --- | +| hook returns `sessionTitle` | unset | `ESC]0;✳ <kit title>BEL` | +| hook returns `sessionTitle` | `=1` | nothing | +| hook returns `terminalSequence` | unset | `ESC]0;✳ <kit title>BEL` | +| hook returns `terminalSequence` | `=1` | nothing | + +The switch silences the window's title writes entirely, hook output included, +so setting it would leave every kit-launched Claude window with no tab name at +all. The kit therefore leaves it alone, writes the tab name itself when a +session is entered, `sp go`, `sp takeover`, a fresh `sp new`, and the picker's +own open and take-over, all through `sk_tab_title` in `bin/session_kit_common` +, and lets this hook carry renames from inside the window. A restore writes no +tab name (it ends at the picker or a prompt, not inside the session); it writes +the name and colour into the provider's store, and the tab is named when the +session is opened. `session-kit doctor` reports the variable under `tab-title` +if it finds it set. The whole behavior, both providers, turns off with +`SESSION_KIT_TAB_TITLE=off`. diff --git a/config/claude/attention_hook.sh b/config/claude/attention_hook.sh new file mode 100755 index 0000000..6954d8d --- /dev/null +++ b/config/claude/attention_hook.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# User-level hook (Notification + UserPromptSubmit + SessionEnd): record the +# moment a Claude session starts or stops needing a person, so the session kit +# learns it from Claude Code itself instead of catching it on the next poll. +# +# One small file per session under +# ${SESSION_KIT_STATE_DIR:-~/.local/state/session-kit}/attention/claude/<uuid>.json +# and nothing else: no network, no locks, no growth. The picker reads it beside +# `claude agents --json`, which stays as the reconciliation source. +# +# Which notification types mean what is decided here in one place and pinned by +# tests/test_attention_truth.py against the 2.1.229 matcher enum. A type this +# hook does not recognise is left alone deliberately: the poll still sees the +# session, so an unknown vendor event costs a refresh of latency, never a +# wrong answer. +# +# FAIL OPEN: any problem exits 0 with no output. A hook that fails must never +# be able to hold up the session it is watching. + +# The payload never becomes an argument. A notification's text is whatever a +# session was asking about, and /proc/<pid>/cmdline is readable by every user +# on the box; /proc/<pid>/environ is readable only by its owner. So the payload +# is read from stdin and handed on in the environment. +set -uo pipefail +INPUT=$(cat 2>/dev/null) || exit 0 +[ -n "$INPUT" ] || exit 0 + +SESSION_KIT_HOOK_PAYLOAD=$INPUT python3 - <<'PY' 2>/dev/null || exit 0 +import json +import os +import re +import time +from pathlib import Path + +UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +NEEDS_YOU = { + "permission_prompt", + "idle_prompt", + "elicitation_dialog", + "agent_needs_input", +} +CLEARING = { + "elicitation_complete", + "elicitation_response", + "auth_success", + "agent_completed", +} + + +def clean(value, limit): + text = "".join( + character + for character in str(value or "") + if character.isprintable() or character == " " + ) + return text.strip()[:limit] + + +try: + payload = json.loads(os.environ.get("SESSION_KIT_HOOK_PAYLOAD") or "") +except ValueError: + raise SystemExit(0) +if not isinstance(payload, dict): + raise SystemExit(0) + +session_id = str(payload.get("session_id") or "").strip().lower() +if not UUID.match(session_id): + raise SystemExit(0) +event = clean(payload.get("hook_event_name"), 40) + +needs_you = None +notification_type = "" +if event == "Notification": + notification_type = clean(payload.get("notification_type"), 40) + if notification_type in NEEDS_YOU: + needs_you = True + elif notification_type in CLEARING: + needs_you = False +elif event == "UserPromptSubmit": + # The person answered. Whatever the session was waiting for, it is not + # waiting for that any more. + needs_you = False +elif event == "SessionEnd": + needs_you = None +else: + raise SystemExit(0) + +configured = os.environ.get("SESSION_KIT_STATE_DIR") +if configured: + state_dir = Path(configured) +else: + state_home = os.environ.get("XDG_STATE_HOME") + base = Path(state_home) if state_home else Path.home() / ".local" / "state" + state_dir = base / "session-kit" +path = state_dir / "attention" / "claude" / f"{session_id}.json" + +if event == "SessionEnd": + # The session is gone; so is anything it needed. + try: + path.unlink() + except OSError: + pass + raise SystemExit(0) +if needs_you is None: + # A notification type this kit has no opinion about. Leave the record and + # let the poll speak for this session. + raise SystemExit(0) + +record = { + "schema_version": 1, + "session_id": session_id, + "hook_event": event, + "notification_type": notification_type, + "message": clean(payload.get("message"), 200), + "needs_you": bool(needs_you), + "recorded_at_ms": time.time_ns() // 1_000_000, +} +path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) +temporary = path.with_name(f"{path.name}.tmp.{os.getpid()}") +descriptor = os.open( + temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600 +) +with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(record, handle, sort_keys=True) + handle.write("\n") +os.replace(temporary, path) +PY +exit 0 diff --git a/config/claude/nameintent_title.sh b/config/claude/nameintent_title.sh new file mode 100755 index 0000000..f69caec --- /dev/null +++ b/config/claude/nameintent_title.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# User-level hook (SessionStart + UserPromptSubmit): if this session has a +# recorded name intent (~/.claude/sessions/<sessionId>.nameintent, written by +# session-kit name propagation or agent_name_session.sh), set the live TUI +# title to it. Project-agnostic so kit-assigned names rename Claude Code in +# every project, not just repos that ship their own coordination hooks. +# FAIL OPEN: any problem exits 0 with no output. + +set -uo pipefail +INPUT=$(cat 2>/dev/null) || exit 0 +SID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null) +EVENT=$(printf '%s' "$INPUT" | jq -r '.hook_event_name // ""' 2>/dev/null) +case "$EVENT" in SessionStart|UserPromptSubmit|Stop) ;; *) exit 0 ;; esac +[ -n "$SID" ] || exit 0 +INTENT="$HOME/.claude/sessions/$SID.nameintent" +if [ -f "$INTENT" ] && [ ! -L "$INTENT" ]; then + # Stop stays silent: the runtime ignores sessionTitle there (retested + # on 2.1.221, as did PostToolUse, only SessionStart and UserPromptSubmit + # apply it; probes 2026-08-04). + [ "$EVENT" = Stop ] && exit 0 + TITLE="$(tr -d '\n\r' < "$INTENT" 2>/dev/null | cut -c1-64)" + [ -n "$TITLE" ] || exit 0 + jq -nc --arg e "$EVENT" --arg t "$TITLE" \ + '{hookSpecificOutput:{hookEventName:$e,sessionTitle:$t}}' 2>/dev/null + exit 0 +fi + +# No name intent: pre-baked (resume-launched) conversations never receive +# Claude's own auto-title, so the session kit derives one from the first real +# prompt. Cheap grep pre-filter keeps ordinary sessions on a no-Python path; +# the kit verb re-checks eligibility strictly and fails open. +case "$EVENT" in UserPromptSubmit|Stop) ;; *) exit 0 ;; esac +INV="$HOME/.local/lib/session-kit/current/lib/session_inventory.py" +[ -f "$INV" ] || exit 0 +TP=$(printf '%s' "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null) +[ -n "$TP" ] && [ -f "$TP" ] || exit 0 +grep -qF '"type":"ai-title"' "$TP" 2>/dev/null && exit 0 +grep -qF '"type":"agent-name"' "$TP" 2>/dev/null && exit 0 +# Claude can invoke Stop before its latest transcript writes are visible. Only +# wait for a resume-launched, colored candidate; all ordinary sessions retain +# the immediate no-op path. The Python verb performs the strict final checks. +if ! grep -qF '"Continue from where you left off."' "$TP" 2>/dev/null; then + [ "$EVENT" = Stop ] || exit 0 + grep -qF '"type":"agent-color"' "$TP" 2>/dev/null || exit 0 + for _ in {1..20}; do + sleep 0.1 + grep -qF '"Continue from where you left off."' "$TP" 2>/dev/null && break + done + grep -qF '"Continue from where you left off."' "$TP" 2>/dev/null || exit 0 +fi +TITLE=$(printf '%s' "$INPUT" | + timeout 10 python3 "$INV" automatic-title from-hook 2>/dev/null | + jq -r '.title // ""' 2>/dev/null) +[ -n "$TITLE" ] || exit 0 +# Stop output cannot retitle the live window; the intent file written by the +# verb renames it at the next prompt, and records cover picker/chip surfaces. +[ "$EVENT" = UserPromptSubmit ] || exit 0 +jq -nc --arg e "$EVENT" --arg t "$TITLE" \ + '{hookSpecificOutput:{hookEventName:$e,sessionTitle:$t}}' 2>/dev/null +exit 0 diff --git a/config/claude/statusline.sh b/config/claude/statusline.sh new file mode 100755 index 0000000..2d37545 --- /dev/null +++ b/config/claude/statusline.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# Claude Code status line (Linux) +# Format: line 1: [MODEL] user@host:/CWD-BASENAME | PCT% context +# line 2: 5h PCT% (left) · 7d PCT% (left) · account email (~/.claude.json oauthAccount) +# Quota segment reads a cache keyed by the active Claude profile (refreshed in +# background by statusline-quota-refresh.sh, TTL 180s); time-left counts down +# to window reset. +# Session name leads line 1 (maintainer decision, 2026-08-04): the tab title +# and top-right chip only update at session start or the next prompt, so +# this is the one surface that shows a mid-turn name change while the human +# just watches. +# Source: ~/.claude/sessions/<session_id>.nameintent (kit name push). + +input=$(cat) + +# Tab is IFS whitespace, so a run of tabs around an EMPTY field collapses and +# every later field shifts left (the husk bug). Translate to \034 first. +__sl_line=$( + printf '%s' "$input" | jq -r '[ + .model.display_name, + .workspace.current_dir, + (.context_window.used_percentage | tostring), + (.session_id // "") + ] | @tsv' +) +__sl_line=${__sl_line//$'\t'/$'\034'} +IFS=$'\034' read -r model cwd used_pct sid <<<"$__sl_line" + +session_name="" +if [ -n "$sid" ]; then + intent="$HOME/.claude/sessions/$sid.nameintent" + if [ -f "$intent" ] && [ ! -L "$intent" ]; then + session_name=$(tr -d '\n\r\000-\037' < "$intent" 2>/dev/null | cut -c1-64) + fi +fi +if [ -n "$session_name" ]; then + # Tint with the session's kit color (inventory display_color), using the + # kit's calibrated palette so this matches the picker rows and the chip. + name_color=$(jq -r --arg u "$sid" '.sessions[]? | select(.identity.uuid == $u) | .display_color // ""' "$HOME/.local/state/session-kit/inventory.json" 2>/dev/null | head -1) + case "$name_color" in + red) tint=$'\033[38;2;237;93;93m' ;; + blue) tint=$'\033[38;2;97;166;240m' ;; + green) tint=$'\033[38;2;63;221;115m' ;; + yellow) tint=$'\033[38;2;249;215;108m' ;; + purple) tint=$'\033[38;2;173;115;239m' ;; + orange) tint=$'\033[38;2;242;144;81m' ;; + pink) tint=$'\033[38;2;240;113;177m' ;; + cyan) tint=$'\033[38;2;64;216;209m' ;; + *) tint=$'\033[1m' ;; + esac + printf '%s%s\033[0m · ' "$tint" "$session_name" +fi + +# show only the last path component: /srv/example/app -> /app +display_cwd="/${cwd##*/}" +[ "$cwd" = "/" ] && display_cwd="/" +host=$(hostname -s) +reset=$'\033[0m' + +if [ "$used_pct" = "null" ] || [ -z "$used_pct" ]; then + printf '[%s] %s@%s:%s | --%% context' "$model" "$USER" "$host" "$display_cwd" +else + pct_int=$(printf '%.0f' "$used_pct") + if [ "$pct_int" -lt 50 ]; then + color=$'\033[38;2;80;200;120m' + elif [ "$pct_int" -lt 80 ]; then + color=$'\033[38;2;230;180;50m' + else + color=$'\033[38;2;220;60;60m' + fi + printf '[%s] %s@%s:%s | %s%d%%%s context' \ + "$model" "$USER" "$host" "$display_cwd" "$color" "$pct_int" "$reset" +fi + +# --- Plan quota segment (5h + 7d unified windows) --- +QTTL=180 +now=$(date +%s) +# The session's own profile first: a kit-launched session sets +# CLAUDE_CONFIG_DIR to its account profile, and that profile's .claude.json +# names the account this session actually bills. The ambient file is only +# right for sessions with no profile. +if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ -f "$CLAUDE_CONFIG_DIR/.claude.json" ]; then + acct=$(jq -r '.oauthAccount.emailAddress // empty' "$CLAUDE_CONFIG_DIR/.claude.json" 2>/dev/null) + qprofile=$CLAUDE_CONFIG_DIR +else + acct=$(jq -r '.oauthAccount.emailAddress // empty' "$HOME/.claude.json" 2>/dev/null) + qprofile= +fi +qalias=${SESSION_KIT_ACCOUNT_ALIAS:-${qprofile##*/}} +qalias=$(printf '%s' "${qalias:-default}" | tr -cd 'A-Za-z0-9._-') +[ -n "$qalias" ] || qalias=default +qidentity=${qprofile:-$HOME/.claude} +qsum=$(printf '%s' "$qidentity" | sha256sum 2>/dev/null || printf '%s' "$qidentity" | shasum -a 256 2>/dev/null) +qsum=${qsum%% *} +QDIR="$HOME/.claude/cache/session-kit-quota/${qalias}-${qsum:-0}" +QCACHE="$QDIR/quota_headers" +QSTAMP="$QDIR/refresh-requested" +QREFRESH="$HOME/.claude/statusline-quota-refresh.sh" +if mkdir -p "$QDIR" 2>/dev/null; then chmod 700 "$QDIR" 2>/dev/null || true; fi +qacct=$(awk -F': ' '$1=="x-probe-account"{print $2}' "$QCACHE" 2>/dev/null) +qmtime=$(stat -c %Y "$QCACHE" 2>/dev/null || echo 0) +if [ $((now - qmtime)) -ge $QTTL ] || [ "$qacct" != "$acct" ]; then + qstamp=$(stat -c %Y "$QSTAMP" 2>/dev/null || echo 0) + if [ $((now - qstamp)) -ge $QTTL ] && [ -x "$QREFRESH" ]; then + : > "$QSTAMP" 2>/dev/null || true + ( + candidate= + if [ -n "$qprofile" ] && [ -d "$qprofile" ] && [ ! -L "$qprofile" ]; then + qhome="$QDIR/probe-home" + mkdir -p "$qhome/.claude/cache" 2>/dev/null || exit 0 + chmod 700 "$qhome" "$qhome/.claude" "$qhome/.claude/cache" 2>/dev/null || exit 0 + ln -sfn "$qprofile/.claude.json" "$qhome/.claude.json" 2>/dev/null || exit 0 + ln -sfn "$qprofile/.credentials.json" "$qhome/.claude/.credentials.json" 2>/dev/null || exit 0 + HOME="$qhome" CLAUDE_CONFIG_DIR="$qprofile" "$QREFRESH" >/dev/null 2>&1 + candidate="$qhome/.claude/cache/quota_headers" + else + "$QREFRESH" >/dev/null 2>&1 + candidate="$HOME/.claude/cache/quota_headers" + fi + candidate_acct=$(awk -F': ' '$1=="x-probe-account"{print $2}' "$candidate" 2>/dev/null) + if [ -f "$candidate" ] && [ "$candidate_acct" = "$acct" ]; then + qtmp=$(mktemp "$QDIR/quota_headers.tmp.XXXXXXXX" 2>/dev/null) || exit 0 + if cp "$candidate" "$qtmp" 2>/dev/null && + chmod 600 "$qtmp" 2>/dev/null && mv "$qtmp" "$QCACHE"; then + : + else + rm -f -- "$qtmp" + fi + fi + ) & + fi +fi + +quota_color() { + # args: pct warn crit + if [ "$1" -ge "$3" ]; then printf '\033[38;2;220;60;60m' + elif [ "$1" -ge "$2" ]; then printf '\033[38;2;230;180;50m' + else printf '\033[38;2;80;200;120m'; fi +} + +fmt_left() { + local s=$1 + [ "$s" -lt 0 ] && s=0 + if [ "$s" -ge 86400 ]; then printf '%dd%dh' $((s/86400)) $((s%86400/3600)) + else printf '%dh%02dm' $((s/3600)) $((s%3600/60)); fi +} + +if [ -f "$QCACHE" ] && [ "$qacct" = "$acct" ]; then + u5=$(awk -F': ' 'tolower($1)=="anthropic-ratelimit-unified-5h-utilization"{print $2}' "$QCACHE") + r5=$(awk -F': ' 'tolower($1)=="anthropic-ratelimit-unified-5h-reset"{print $2}' "$QCACHE") + u7=$(awk -F': ' 'tolower($1)=="anthropic-ratelimit-unified-7d-utilization"{print $2}' "$QCACHE") + r7=$(awk -F': ' 'tolower($1)=="anthropic-ratelimit-unified-7d-reset"{print $2}' "$QCACHE") + if [ -n "$u5" ] && [ -n "$r5" ]; then + p5=$(printf '%.0f' "$(awk "BEGIN{print $u5*100}")") + p7=$(printf '%.0f' "$(awk "BEGIN{print ${u7:-0}*100}")") + c5=$(quota_color "$p5" 70 90) # pause batches at 90% of 5h + c7=$(quota_color "$p7" 50 70) # weekly backstop is 70% + printf '\n5h %s%d%%%s %s · 7d %s%d%%%s %s' \ + "$c5" "$p5" "$reset" "$(fmt_left $((r5 - now)))" \ + "$c7" "$p7" "$reset" "$(fmt_left $((${r7:-$now} - now)))" + else + printf '\nquota --' + fi +else + printf '\nquota --' +fi +[ -n "$acct" ] && printf ' · %s' "$acct" diff --git a/config/codex-themes/sk-lime.tmTheme b/config/codex-themes/sk-lime.tmTheme new file mode 100644 index 0000000..238dd98 --- /dev/null +++ b/config/codex-themes/sk-lime.tmTheme @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>name</key><string>sk-lime</string> + <key>settings</key> + <array> + <dict><key>settings</key><dict> + <key>foreground</key><string>#DCDFE4</string> + </dict></dict> + <dict><key>scope</key><string>markup.heading</string> + <key>settings</key><dict><key>foreground</key><string>#AAE646</string></dict></dict> + <dict><key>scope</key><string>string</string> + <key>settings</key><dict><key>foreground</key><string>#AAE646</string></dict></dict> + <dict><key>scope</key><string>variable, support.type</string> + <key>settings</key><dict><key>foreground</key><string>#C7EE86</string></dict></dict> + <dict><key>scope</key><string>constant, support.constant</string> + <key>settings</key><dict><key>foreground</key><string>#DDF5B5</string></dict></dict> + <dict><key>scope</key><string>keyword, storage</string> + <key>settings</key><dict><key>foreground</key><string>#C678DD</string></dict></dict> + <dict><key>scope</key><string>entity.name.function, support.function</string> + <key>settings</key><dict><key>foreground</key><string>#61AFEF</string></dict></dict> + <dict><key>scope</key><string>comment</string> + <key>settings</key><dict><key>foreground</key><string>#7F848E</string></dict></dict> + <dict><key>scope</key><string>entity.name.type, entity.name.class</string> + <key>settings</key><dict><key>foreground</key><string>#C7EE86</string></dict></dict> + </array> +</dict> +</plist> diff --git a/config/codex-themes/sk-magenta.tmTheme b/config/codex-themes/sk-magenta.tmTheme new file mode 100644 index 0000000..bbdf58c --- /dev/null +++ b/config/codex-themes/sk-magenta.tmTheme @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>name</key><string>sk-magenta</string> + <key>settings</key> + <array> + <dict><key>settings</key><dict> + <key>foreground</key><string>#DCDFE4</string> + </dict></dict> + <dict><key>scope</key><string>markup.heading</string> + <key>settings</key><dict><key>foreground</key><string>#FF5FFF</string></dict></dict> + <dict><key>scope</key><string>string</string> + <key>settings</key><dict><key>foreground</key><string>#FF5FFF</string></dict></dict> + <dict><key>scope</key><string>variable, support.type</string> + <key>settings</key><dict><key>foreground</key><string>#FF97FF</string></dict></dict> + <dict><key>scope</key><string>constant, support.constant</string> + <key>settings</key><dict><key>foreground</key><string>#FFBFFF</string></dict></dict> + <dict><key>scope</key><string>keyword, storage</string> + <key>settings</key><dict><key>foreground</key><string>#C678DD</string></dict></dict> + <dict><key>scope</key><string>entity.name.function, support.function</string> + <key>settings</key><dict><key>foreground</key><string>#61AFEF</string></dict></dict> + <dict><key>scope</key><string>comment</string> + <key>settings</key><dict><key>foreground</key><string>#7F848E</string></dict></dict> + <dict><key>scope</key><string>entity.name.type, entity.name.class</string> + <key>settings</key><dict><key>foreground</key><string>#FF97FF</string></dict></dict> + </array> +</dict> +</plist> diff --git a/config/codex-themes/sk-sand.tmTheme b/config/codex-themes/sk-sand.tmTheme new file mode 100644 index 0000000..0e2fc3e --- /dev/null +++ b/config/codex-themes/sk-sand.tmTheme @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>name</key><string>sk-sand</string> + <key>settings</key> + <array> + <dict><key>settings</key><dict> + <key>foreground</key><string>#DCDFE4</string> + </dict></dict> + <dict><key>scope</key><string>markup.heading</string> + <key>settings</key><dict><key>foreground</key><string>#D6B282</string></dict></dict> + <dict><key>scope</key><string>string</string> + <key>settings</key><dict><key>foreground</key><string>#D6B282</string></dict></dict> + <dict><key>scope</key><string>variable, support.type</string> + <key>settings</key><dict><key>foreground</key><string>#E4CCAD</string></dict></dict> + <dict><key>scope</key><string>constant, support.constant</string> + <key>settings</key><dict><key>foreground</key><string>#EEE0CD</string></dict></dict> + <dict><key>scope</key><string>keyword, storage</string> + <key>settings</key><dict><key>foreground</key><string>#C678DD</string></dict></dict> + <dict><key>scope</key><string>entity.name.function, support.function</string> + <key>settings</key><dict><key>foreground</key><string>#61AFEF</string></dict></dict> + <dict><key>scope</key><string>comment</string> + <key>settings</key><dict><key>foreground</key><string>#7F848E</string></dict></dict> + <dict><key>scope</key><string>entity.name.type, entity.name.class</string> + <key>settings</key><dict><key>foreground</key><string>#E4CCAD</string></dict></dict> + </array> +</dict> +</plist> diff --git a/config/codex-themes/sk-sea.tmTheme b/config/codex-themes/sk-sea.tmTheme new file mode 100644 index 0000000..f4529f0 --- /dev/null +++ b/config/codex-themes/sk-sea.tmTheme @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>name</key><string>sk-sea</string> + <key>settings</key> + <array> + <dict><key>settings</key><dict> + <key>foreground</key><string>#DCDFE4</string> + </dict></dict> + <dict><key>scope</key><string>markup.heading</string> + <key>settings</key><dict><key>foreground</key><string>#5FEBAA</string></dict></dict> + <dict><key>scope</key><string>string</string> + <key>settings</key><dict><key>foreground</key><string>#5FEBAA</string></dict></dict> + <dict><key>scope</key><string>variable, support.type</string> + <key>settings</key><dict><key>foreground</key><string>#97F2C7</string></dict></dict> + <dict><key>scope</key><string>constant, support.constant</string> + <key>settings</key><dict><key>foreground</key><string>#BFF7DD</string></dict></dict> + <dict><key>scope</key><string>keyword, storage</string> + <key>settings</key><dict><key>foreground</key><string>#C678DD</string></dict></dict> + <dict><key>scope</key><string>entity.name.function, support.function</string> + <key>settings</key><dict><key>foreground</key><string>#61AFEF</string></dict></dict> + <dict><key>scope</key><string>comment</string> + <key>settings</key><dict><key>foreground</key><string>#7F848E</string></dict></dict> + <dict><key>scope</key><string>entity.name.type, entity.name.class</string> + <key>settings</key><dict><key>foreground</key><string>#97F2C7</string></dict></dict> + </array> +</dict> +</plist> diff --git a/config/codex-themes/sk-silver.tmTheme b/config/codex-themes/sk-silver.tmTheme new file mode 100644 index 0000000..9426dac --- /dev/null +++ b/config/codex-themes/sk-silver.tmTheme @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>name</key><string>sk-silver</string> + <key>settings</key> + <array> + <dict><key>settings</key><dict> + <key>foreground</key><string>#DCDFE4</string> + </dict></dict> + <dict><key>scope</key><string>markup.heading</string> + <key>settings</key><dict><key>foreground</key><string>#CDD2DC</string></dict></dict> + <dict><key>scope</key><string>string</string> + <key>settings</key><dict><key>foreground</key><string>#CDD2DC</string></dict></dict> + <dict><key>scope</key><string>variable, support.type</string> + <key>settings</key><dict><key>foreground</key><string>#DEE1E8</string></dict></dict> + <dict><key>scope</key><string>constant, support.constant</string> + <key>settings</key><dict><key>foreground</key><string>#EBEDF1</string></dict></dict> + <dict><key>scope</key><string>keyword, storage</string> + <key>settings</key><dict><key>foreground</key><string>#C678DD</string></dict></dict> + <dict><key>scope</key><string>entity.name.function, support.function</string> + <key>settings</key><dict><key>foreground</key><string>#61AFEF</string></dict></dict> + <dict><key>scope</key><string>comment</string> + <key>settings</key><dict><key>foreground</key><string>#7F848E</string></dict></dict> + <dict><key>scope</key><string>entity.name.type, entity.name.class</string> + <key>settings</key><dict><key>foreground</key><string>#DEE1E8</string></dict></dict> + </array> +</dict> +</plist> diff --git a/config/codex-themes/sk-sky.tmTheme b/config/codex-themes/sk-sky.tmTheme new file mode 100644 index 0000000..0b474cb --- /dev/null +++ b/config/codex-themes/sk-sky.tmTheme @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>name</key><string>sk-sky</string> + <key>settings</key> + <array> + <dict><key>settings</key><dict> + <key>foreground</key><string>#DCDFE4</string> + </dict></dict> + <dict><key>scope</key><string>markup.heading</string> + <key>settings</key><dict><key>foreground</key><string>#96CDFF</string></dict></dict> + <dict><key>scope</key><string>string</string> + <key>settings</key><dict><key>foreground</key><string>#96CDFF</string></dict></dict> + <dict><key>scope</key><string>variable, support.type</string> + <key>settings</key><dict><key>foreground</key><string>#BADEFF</string></dict></dict> + <dict><key>scope</key><string>constant, support.constant</string> + <key>settings</key><dict><key>foreground</key><string>#D5EBFF</string></dict></dict> + <dict><key>scope</key><string>keyword, storage</string> + <key>settings</key><dict><key>foreground</key><string>#C678DD</string></dict></dict> + <dict><key>scope</key><string>entity.name.function, support.function</string> + <key>settings</key><dict><key>foreground</key><string>#61AFEF</string></dict></dict> + <dict><key>scope</key><string>comment</string> + <key>settings</key><dict><key>foreground</key><string>#7F848E</string></dict></dict> + <dict><key>scope</key><string>entity.name.type, entity.name.class</string> + <key>settings</key><dict><key>foreground</key><string>#BADEFF</string></dict></dict> + </array> +</dict> +</plist> diff --git a/config/codex/terminal-title.toml b/config/codex/terminal-title.toml new file mode 100644 index 0000000..2d8b2c7 --- /dev/null +++ b/config/codex/terminal-title.toml @@ -0,0 +1,26 @@ +# The kit-owned Codex tab title (K3). Deployed by the installer to +# $CODEX_HOME/session-kit/terminal-title.toml; the live ~/.codex/config.toml is +# never edited, and every kit-launched Codex session passes this value on its +# own command line (`codex -c tui.terminal_title=...`), which overrides whatever +# the personal config says for that one process. Nothing outside a kit session +# changes. +# +# Why these two items: `activity` is the state glyph Codex paints while it +# works, and `thread` is the thread name -- the exact string the kit writes when +# a session is named, so the tab carries the kit's name and nothing else. The +# default Codex title adds project, model, and git branch, which pushes the name +# off the end of a narrow tab and gives the same window two different identities +# depending on which vendor drew it. +# +# Verified live against codex-cli 0.145.0 on 2026-08-13: `codex doctor` reports +# `title terminal title configuration contains unknown item identifiers` for an +# item it does not know, accepts both items below, and a `-c` override on the +# command line wins over the file. `spinner` is an accepted alias of `activity`. +# +# This copy is owned by the release, like the kit's Codex themes: every install +# and every update replaces the deployed file, so an edit made there is lost at +# the next one. To change what kit sessions put on the tab, change this file in +# the kit and install. To turn the whole feature off on both providers, set +# SESSION_KIT_TAB_TITLE=off. +[tui] +terminal_title = ["activity", "thread"] diff --git a/config/projects.example.tsv b/config/projects.example.tsv index d6c9321..7535a7b 100644 --- a/config/projects.example.tsv +++ b/config/projects.example.tsv @@ -1,5 +1,5 @@ # alias<TAB>provider<TAB>absolute cwd # Alias: lowercase letters, digits, `_`, or dash. Provider: shell, # claude, or codex. Cwd must be an existing absolute directory when used. -web claude /home/example/projects/web -api codex /home/example/projects/api +# Run `session-kit projects import` to discover existing Claude Code and Codex +# folders, or `session-kit projects add ALIAS PROVIDER /absolute/path`. diff --git a/config/provider-commands/claude-kit.md b/config/provider-commands/claude-kit.md new file mode 100644 index 0000000..496457a --- /dev/null +++ b/config/provider-commands/claude-kit.md @@ -0,0 +1,16 @@ +--- +description: Leave this conversation running and go back to the session picker +disable-model-invocation: true +allowed-tools: Bash(shpool detach:*) +--- + +!`shpool detach` + +The session you were in keeps running exactly where it was. Nothing was +stopped, summarized, or closed: the terminal simply went back to the session +picker, and typing this session's number there re-attaches to this same +conversation. Ctrl-Q is the key that does this, in any session and any +provider. + +Use /exit when you are finished with it instead. That closes the session and +frees its number, and Ctrl-D is its key. diff --git a/config/shpool.example.toml b/config/shpool.example.toml index f4a1fe6..2a19d83 100644 --- a/config/shpool.example.toml +++ b/config/shpool.example.toml @@ -1,7 +1,22 @@ # Restore useful recent context without streaming the unbounded raw journal. -session_restore_mode = { lines = 500 } +session_restore_mode = "simple" output_spool_lines = 1000 vt100_output_spool_width = 200 # Session-kit provides its own compact shpool-aware prompt marker. prompt_prefix = "" + +# Ctrl-q leaves a conversation running and hands the window back. The docs +# tell people to press it, so the install has to make it true: stock shpool +# 0.11 binds detach to the two-key chord Ctrl-Space Ctrl-q, and a lone Ctrl-q +# does nothing at all. Shipping that instruction without shipping this binding +# was the worst shape of documentation bug -- it passed for the one person +# whose private config already had the binding, and was false for everybody +# starting fresh (found in review, 2026-08-15). +# +# It REPLACES the default chord rather than adding to it, and shpool compiles +# keybindings per attachment, so it applies to windows opened or reopened +# after this file lands -- no daemon restart. Inner programs never see the key. +[[keybinding]] +binding = "Ctrl-q" +action = "detach" diff --git a/deploy/session-kit-launcher b/deploy/session-kit-launcher index 94f10ca..c62205c 100755 --- a/deploy/session-kit-launcher +++ b/deploy/session-kit-launcher @@ -1,4 +1,16 @@ #!/usr/bin/env bash + +if [[ $(uname -s 2>/dev/null) == Darwin && ${BASH_VERSINFO[0]} -lt 4 ]]; then + for modern_bash in /opt/homebrew/bin/bash /usr/local/bin/bash; do + if [[ -x $modern_bash ]]; then + export PATH="${modern_bash%/*}:$HOME/.cargo/bin:$HOME/.local/bin:$PATH" + exec "$modern_bash" "$0" "$@" + fi + done + echo "session-kit: macOS needs Homebrew Bash 4 or newer. Install it with: brew install bash python rust" >&2 + exit 69 +fi + set -euo pipefail # Stable top-level entry point. Resolve `current` once, then execute from that @@ -6,29 +18,128 @@ set -euo pipefail # two releases. helper=${0##*/} case "$helper" in - sp|shpool_login|shpool_status|shpool_reaper|codex_resume_here) ;; - *) echo "session-kit launcher: unsupported helper: $helper" >&2; exit 64 ;; + session-kit|kit|sp|shpool_login|shpool_login_tui|shpool_login_launcher|shpool_status|shpool_reaper|codex_resume_here) ;; + *) echo "session-kit: unsupported helper: $helper" >&2; exit 64 ;; esac root=${SESSION_KIT_ROOT:-"$HOME/.local/lib/session-kit"} current=$root/current if [[ ! -L $current ]]; then - echo "session-kit launcher: missing release link: $current" >&2 + echo "session-kit: missing release link: $current" >&2 exit 69 fi release_dir=$(cd -P -- "$current" 2>/dev/null && pwd) || { - echo "session-kit launcher: invalid release link: $current" >&2 + echo "session-kit: invalid release link: $current" >&2 exit 69 } + +# Runtime helpers follow `current`; lifecycle management follows the newest +# verified manager. Rollback can select an old session payload without also +# reviving an updater that predates today's transaction and hook contracts. +# A launcher with this contract fails closed when that anchor is absent or +# unsafe. Legacy launchers keep their own pre-anchor recovery behavior until a +# reviewed source install replaces them and creates the manager link. +if [[ $helper == session-kit ]]; then + manager=$root/manager + if [[ -L $manager ]]; then + manager_dir=$(cd -P -- "$manager" 2>/dev/null && pwd) || { + echo "session-kit: invalid manager link: $manager" >&2 + exit 69 + } + manager_name=${manager_dir##*/} + manager_parent=$(cd -P -- "$root/releases" 2>/dev/null && pwd) || { + echo "session-kit: release root is unavailable" >&2 + exit 69 + } + [[ $manager_dir == "$manager_parent/$manager_name" && + $manager_name =~ ^[0-9a-f]{40}$ ]] || { + echo "session-kit: manager escapes $root/releases" >&2 + exit 78 + } + [[ -x $manager_dir/bin/session-kit ]] || { + echo "session-kit: manager command is unavailable" >&2 + exit 69 + } + release_dir=$manager_dir + unset manager_dir manager_name manager_parent + else + echo "session-kit: unsafe manager link: $manager" >&2 + exit 78 + fi + unset manager +fi + +# The management entry remains a release-independent launcher. During an +# interrupted lifecycle transaction it executes the exact pre-flip manager +# recorded in the durable journal, even when `current` already points at an +# older release that cannot understand the newer journal schema. +if [[ $helper == session-kit && ! -L $root/manager ]]; then + transaction=${SESSION_KIT_STATE_DIR:-"${XDG_STATE_HOME:-$HOME/.local/state}/session-kit"}/lifecycle-transaction.json + if [[ -f $transaction && ! -L $transaction ]]; then + recovery_release=$(python3 - "$transaction" "$root" <<'PY' +import json +import os +from pathlib import Path +import re +import stat +import sys + +journal, root = map(Path, sys.argv[1:]) +info = journal.lstat() +if ( + not stat.S_ISREG(info.st_mode) + or info.st_uid != os.geteuid() + or stat.S_IMODE(info.st_mode) != 0o600 +): + raise SystemExit(78) +payload = json.loads(journal.read_text(encoding="utf-8")) +entries = payload.get("entries") +if ( + payload.get("schema_version") != 1 + or payload.get("state") != "pending" + or not isinstance(entries, list) + or not entries + or entries[0].get("path") != os.fspath(root / "current") + or entries[0].get("kind") != "symlink" +): + raise SystemExit(78) +target = Path(entries[0].get("target", "")) +releases = root / "releases" +try: + relative = target.relative_to(releases) +except ValueError: + raise SystemExit(78) +if len(relative.parts) != 1 or re.fullmatch(r"[0-9a-f]{40}", relative.name) is None: + raise SystemExit(78) +resolved = target.resolve(strict=True) +if resolved.parent != releases.resolve(strict=True): + raise SystemExit(78) +print(resolved) +PY + ) || { + echo "session-kit: pending recovery release is unsafe" >&2 + exit 78 + } + [[ -z $recovery_release ]] || release_dir=$recovery_release + unset recovery_release + fi + unset transaction +fi case "$release_dir" in "$root"/releases/*) ;; - *) echo "session-kit launcher: release escapes $root/releases" >&2; exit 78 ;; + *) echo "session-kit: release escapes $root/releases" >&2; exit 78 ;; esac -target=$release_dir/bin/$helper +if [[ $helper == session-kit ]]; then + target=$release_dir/bin/session-kit +elif [[ $helper == kit ]]; then + target=$release_dir/bin/shpool_login +else + target=$release_dir/bin/$helper +fi if [[ ! -x $target ]]; then - echo "session-kit launcher: helper missing from pinned release: $helper" >&2 + echo "session-kit: helper missing from pinned release: $helper" >&2 exit 69 fi diff --git a/deploy/session-kit-release b/deploy/session-kit-release index 0970e7b..32ae5b4 100755 --- a/deploy/session-kit-release +++ b/deploy/session-kit-release @@ -19,6 +19,7 @@ import json import os from pathlib import Path, PurePosixPath import re +import shlex import shutil import stat import subprocess @@ -28,8 +29,16 @@ import tempfile import uuid SCHEMA_VERSION = 1 -CURRENT_RELEASE_SCHEMA_VERSION = 3 -HELPERS = ("sp", "shpool_login", "shpool_status", "shpool_reaper", "codex_resume_here") +CURRENT_RELEASE_SCHEMA_VERSION = 5 +HELPERS = ( + "sp", + "shpool_login", + "shpool_login_tui", + "shpool_login_launcher", + "shpool_status", + "shpool_reaper", + "codex_resume_here", +) LEGACY_REQUIRED_RELEASE_PAYLOAD = ( "bin/session_kit_common", "lib/session_inventory.py", @@ -53,6 +62,31 @@ REQUIRED_RELEASE_PAYLOAD_BY_SCHEMA = { "lib/sessionkit_inventory/providers.py", "lib/sessionkit_inventory/state_io.py", ), + 4: ( + *LEGACY_REQUIRED_RELEASE_PAYLOAD, + "lib/sessionkit_inventory/__init__.py", + "lib/sessionkit_inventory/common.py", + "lib/sessionkit_inventory/lifecycle.py", + "lib/sessionkit_inventory/projects.py", + "lib/sessionkit_inventory/providers.py", + "lib/sessionkit_inventory/state_io.py", + ), + # Schema 5 adds `colors`. The palettes moved into that module and split in + # two there, so it is now the single place the in-force colour set for each + # provider is declared; a payload without it cannot resolve any session + # colour. Themes are deliberately NOT required here: a release that ships + # no config/codex-themes directory at all is a supported configuration, and + # the installer removes the kit-owned themes it previously placed. + 5: ( + *LEGACY_REQUIRED_RELEASE_PAYLOAD, + "lib/sessionkit_inventory/__init__.py", + "lib/sessionkit_inventory/colors.py", + "lib/sessionkit_inventory/common.py", + "lib/sessionkit_inventory/lifecycle.py", + "lib/sessionkit_inventory/projects.py", + "lib/sessionkit_inventory/providers.py", + "lib/sessionkit_inventory/state_io.py", + ), } REQUIRED_BACKUP_PAYLOAD = { "bundle.sha256", @@ -67,6 +101,8 @@ REQUIRED_BACKUP_PAYLOAD = { } INSTALLED_TARGET_SUFFIXES = { ".local/bin/shpool_login", + ".local/bin/shpool_login_tui", + ".local/bin/shpool_login_launcher", ".local/bin/shpool_reaper", ".local/bin/shpool_status", ".local/bin/sp", @@ -84,6 +120,20 @@ SHPOOL_ID_RE = re.compile( r"^(?:main(?:[1-9][0-9]*)?|s[0-9]{8}-[0-9]{6}-[1-9][0-9]*(?:-[1-9][0-9]*)?)$" ) LAUNCH_RECORD_MAX_BYTES = 8192 +COLLECTION_DOCUMENT_NAMES = ( + "inventory.json", + "terminal-numbers.json", + "terminal-numbers-retired.json", + "terminal-numbers.initialized.json", + "recovery-manifest.json", + "recovery-pending.json", +) +COLLECTION_SEQUENCE_NAME = "collection-sequence.json" +COLLECTION_FLOOR_NAME = "collection-sequence-floor.json" +COLLECTION_MARKERS_NAME = "collection-markers" +LEGACY_INVENTORY_LOCK_NAME = "inventory.lock" +VERSIONED_INVENTORY_LOCK_NAME = "inventory-v2.lock" +LEGACY_INVENTORY_FENCE = b"session-kit publishing lock generation 2\n" class ReleaseError(RuntimeError): @@ -152,6 +202,266 @@ def atomic_json(path: Path, payload: dict, mode: int = 0o600) -> None: tmp.unlink() +def create_json_once(path: Path, payload: dict, mode: int = 0o600) -> bool: + """Durably publish JSON only if no entry already occupies its pathname.""" + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + tmp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp") + content = (json.dumps(payload, sort_keys=True, indent=2) + "\n").encode() + try: + with tmp.open("xb") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(tmp, mode) + try: + os.link(tmp, path, follow_symlinks=False) + except FileExistsError: + return False + fsync_dir(path.parent) + return True + finally: + with contextlib.suppress(FileNotFoundError): + tmp.unlink() + if path.exists() or path.is_symlink(): + fsync_dir(path.parent) + + +def collection_floor_remedy(state_root: Path, remedy_tool: Path) -> str: + command = shlex.join( + [ + "env", + f"SESSION_KIT_STATE_DIR={state_root}", + "python3", + os.fspath(remedy_tool), + ] + ) + return ( + "collection allocation floor cannot be derived from existing state; " + "restart the machine and, before starting a picker, sp, shpool_status, " + "or any Session Kit user service, run exactly:\n " + f"{command}" + ) + + +def _private_sequence(path: Path) -> int | None: + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except FileNotFoundError: + return None + except OSError as exc: + raise ReleaseError(f"unsafe collection allocation counter: {path}: {exc}") from exc + try: + metadata = os.fstat(descriptor) + try: + pathname = path.lstat() + except OSError as exc: + raise ReleaseError( + f"cannot inspect collection allocation counter: {path}" + ) from exc + if ( + not stat.S_ISREG(metadata.st_mode) + or stat.S_IMODE(metadata.st_mode) != 0o600 + or metadata.st_uid != os.geteuid() + or metadata.st_nlink != 1 + or metadata.st_size > 8192 + or (metadata.st_dev, metadata.st_ino) + != (pathname.st_dev, pathname.st_ino) + ): + raise ReleaseError(f"unsafe collection allocation counter: {path}") + payload = os.read(descriptor, 8193) + if len(payload) > 8192 or os.read(descriptor, 1): + return None + finally: + os.close(descriptor) + try: + value = json.loads(payload.decode("utf-8", "strict")) + except (UnicodeError, ValueError): + return None + sequence = value.get("last_collection_start") if isinstance(value, dict) else None + if isinstance(sequence, bool) or not isinstance(sequence, int) or sequence <= 0: + return None + return sequence + + +def _collection_estate_is_empty(state_root: Path) -> bool: + counter = state_root / COLLECTION_SEQUENCE_NAME + if os.path.lexists(counter): + return False + if any(os.path.lexists(state_root / name) for name in COLLECTION_DOCUMENT_NAMES): + return False + markers = state_root / COLLECTION_MARKERS_NAME + try: + metadata = markers.lstat() + except FileNotFoundError: + return True + except OSError: + return False + if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode): + return False + try: + next(markers.iterdir()) + except StopIteration: + return True + except OSError: + return False + return False + + +def _locked_regular(path: Path, flags: int, mode: int) -> int | None: + keep_open = False + try: + descriptor = os.open(path, flags, mode) + except OSError: + return None + try: + opened = os.fstat(descriptor) + pathname = path.lstat() + if ( + not stat.S_ISREG(opened.st_mode) + or stat.S_IMODE(opened.st_mode) != mode + or opened.st_uid != os.geteuid() + or opened.st_nlink != 1 + or (opened.st_dev, opened.st_ino) != (pathname.st_dev, pathname.st_ino) + ): + return None + fcntl.flock(descriptor, fcntl.LOCK_EX) + pathname = path.lstat() + if (opened.st_dev, opened.st_ino) != (pathname.st_dev, pathname.st_ino): + return None + keep_open = True + return descriptor + except OSError: + return None + finally: + if not keep_open: + with contextlib.suppress(OSError): + fcntl.flock(descriptor, fcntl.LOCK_UN) + os.close(descriptor) + + +def _exact_inventory_fence(path: Path) -> bool: + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError: + return False + try: + opened = os.fstat(descriptor) + pathname = path.lstat() + content = os.read(descriptor, len(LEGACY_INVENTORY_FENCE) + 1) + return ( + stat.S_ISREG(opened.st_mode) + and stat.S_IMODE(opened.st_mode) == 0o400 + and opened.st_uid == os.geteuid() + and opened.st_nlink == 1 + and (opened.st_dev, opened.st_ino) == (pathname.st_dev, pathname.st_ino) + and content == LEGACY_INVENTORY_FENCE + ) + except OSError: + return False + finally: + os.close(descriptor) + + +@contextlib.contextmanager +def collection_seed_lock(state_root: Path): + """Serialize with either publisher generation without changing its fence.""" + legacy = state_root / LEGACY_INVENTORY_LOCK_NAME + versioned = state_root / VERSIONED_INVENTORY_LOCK_NAME + open_flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0) + if hasattr(os, "O_NOFOLLOW"): + open_flags |= os.O_NOFOLLOW + while True: + if _exact_inventory_fence(legacy): + descriptor = _locked_regular(versioned, open_flags, 0o600) + if descriptor is None: + raise ReleaseError(f"unsafe collection publication lock: {versioned}") + if not _exact_inventory_fence(legacy): + os.close(descriptor) + continue + break + if os.path.lexists(legacy): + try: + info = legacy.lstat() + except OSError as exc: + raise ReleaseError( + f"cannot inspect collection publication lock: {legacy}" + ) from exc + if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600: + raise ReleaseError(f"unsafe collection publication lock: {legacy}") + descriptor = _locked_regular(legacy, open_flags, 0o600) + if descriptor is not None: + break + if _exact_inventory_fence(legacy): + continue + raise ReleaseError(f"unsafe collection publication lock: {legacy}") + try: + yield + finally: + with contextlib.suppress(OSError): + fcntl.flock(descriptor, fcntl.LOCK_UN) + os.close(descriptor) + + +def seed_collection_floor( + state_root: Path, remedy_tool: Path, *, dry_run: bool = False +) -> dict: + """Seed an absent allocation floor without consuming an allocation.""" + floor = state_root / COLLECTION_FLOOR_NAME + if os.path.lexists(floor): + return {"status": "preserved", "path": str(floor)} + if dry_run and not state_root.exists(): + return {"status": "would-seed", "path": str(floor), "value": 1} + if not dry_run: + state_root.mkdir(parents=True, mode=0o700, exist_ok=True) + try: + root_info = state_root.lstat() + except OSError as exc: + raise ReleaseError(f"cannot inspect collection state directory: {state_root}") from exc + if ( + not stat.S_ISDIR(root_info.st_mode) + or stat.S_ISLNK(root_info.st_mode) + or stat.S_IMODE(root_info.st_mode) != 0o700 + or root_info.st_uid != os.geteuid() + ): + raise ReleaseError( + f"collection state directory must be current-owner mode-0700: {state_root}" + ) + + @contextlib.contextmanager + def maybe_lock(): + if dry_run: + yield + else: + with collection_seed_lock(state_root): + yield + + with maybe_lock(): + if os.path.lexists(floor): + return {"status": "preserved", "path": str(floor)} + sequence = _private_sequence(state_root / COLLECTION_SEQUENCE_NAME) + if sequence is None: + if not _collection_estate_is_empty(state_root): + raise ReleaseError(collection_floor_remedy(state_root, remedy_tool)) + sequence = 1 + if dry_run: + status = "would-seed" + else: + status = ( + "seeded" + if create_json_once( + floor, + {"schema_version": 1, "last_collection_start": sequence}, + ) + else "preserved" + ) + result: dict[str, object] = {"status": status, "path": str(floor)} + if status != "preserved": + result["value"] = sequence + return result + + def fsync_dir(path: Path) -> None: flags = os.O_RDONLY if hasattr(os, "O_DIRECTORY"): @@ -1245,8 +1555,72 @@ def _read_private_launch_record(path: Path, expected_uid: int) -> bytes: return payload -def validate_rollback_launch_records(home: Path) -> int: - """Require rollback-compatible, exact legacy launch pairs only.""" +def _launch_record_metadata_safe(path: Path, expected_uid: int) -> None: + """The fstat half of the launch-record safety proof, without a grammar. + + Current-family records include a free-text prompt handoff, so a payload + grammar cannot apply to every file the way it does to the legacy pair. + What always applies: a private regular file, owned by us, mode 0600, one + link, non-empty, bounded, reached without following a symlink. + """ + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(path, flags) + except OSError as exc: + raise ReleaseError(f"unsafe retained launch record: {path}: {exc}") from exc + try: + info = os.fstat(descriptor) + finally: + os.close(descriptor) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_uid != expected_uid + or stat.S_IMODE(info.st_mode) != 0o600 + or info.st_nlink != 1 + or info.st_size <= 0 + or info.st_size > LAUNCH_RECORD_MAX_BYTES + ): + raise ReleaseError( + f"unsafe retained launch record type, owner, mode, link count, or size: {path}" + ) + + +def _target_reads_current_launch_records(target_release: Path | None) -> bool: + """Whether the rollback target understands the current record family. + + The `.account`/`.launch`/`.prompt` records and the widened start pair + entered with the daemon-identity release; every release that reads them + names the `.launch` suffix in its `bin/session_kit_common`. A missing or + unreadable target answers False, which keeps the refusal conservative. + """ + if target_release is None: + return False + common = target_release / "bin" / "session_kit_common" + try: + return b".launch" in common.read_bytes() + except OSError: + return False + + +CURRENT_LAUNCH_SUFFIXES = (".account", ".launch", ".prompt") + + +def validate_rollback_launch_records( + home: Path, target_release: Path | None = None +) -> int: + """Refuse rollback only when retained records would poison the target. + + The 15 August version required exact LEGACY pairs and nothing else, so + the first live estate whose kit wrote a `.launch` record could never roll + back at all -- the safety net was dead precisely when it was needed, and + on 16 August it had to be bypassed by hand-quarantining records. The rule + now: every record must still prove the same private-file safety, but + current-family records block a rollback only when the TARGET release + cannot read them. With no target supplied the old conservative refusal + stands. + """ root = home / ".local/state/shpool-start" if root.is_symlink(): raise ReleaseError(f"unsafe retained launch directory symlink: {root}") @@ -1278,8 +1652,13 @@ def validate_rollback_launch_records(home: Path) -> int: if path.is_symlink(): raise ReleaseError(f"unsafe retained launch record symlink: {path}") name = path.name - kind = "expected" if name.endswith(".expected") else "start" - shpool_id = name.removesuffix(".expected") if kind == "expected" else name + kind = "start" + shpool_id = name + for suffix in (".expected", *CURRENT_LAUNCH_SUFFIXES): + if name.endswith(suffix): + kind = suffix[1:] + shpool_id = name.removesuffix(suffix) + break if not SHPOOL_ID_RE.fullmatch(shpool_id): raise ReleaseError(f"unsafe retained launch record name: {path}") if not path.is_file(): @@ -1289,8 +1668,32 @@ def validate_rollback_launch_records(home: Path) -> int: raise ReleaseError(f"duplicate retained launch record for {shpool_id}") pair[kind] = path - legacy_pairs = 0 + target_compatible = _target_reads_current_launch_records(target_release) + validated = 0 for shpool_id, pair in sorted(records.items()): + current_family = any( + suffix[1:] in pair for suffix in CURRENT_LAUNCH_SUFFIXES + ) + if current_family: + # The current release owns the shape of its own record set (a + # `.launch` request legitimately exists alone before creation), + # so no pair mandate applies -- but every file must prove the + # private-file safety, and the prompt handoff is free text so it + # gets the metadata proof without a payload grammar. + for kind, path in sorted(pair.items()): + if kind == "prompt": + _launch_record_metadata_safe(path, expected_uid) + else: + _read_private_launch_record(path, expected_uid) + if not target_compatible: + raise ReleaseError( + "new-format retained launch pair blocks older-release " + f"rollback: {shpool_id}: the target release cannot read " + "current-family launch records; close that session or " + "move its records into the failed/ quarantine first" + ) + validated += 1 + continue if set(pair) != {"start", "expected"}: raise ReleaseError( f"mismatched retained launch pair blocks rollback: {shpool_id}" @@ -1319,9 +1722,25 @@ def validate_rollback_launch_records(home: Path) -> int: f"mismatched retained launch fields block rollback: {shpool_id}" ) if len(start) == 4: - raise ReleaseError( - f"new-format retained launch pair blocks older-release rollback: {shpool_id}" - ) + # The widened pair is the current family too: same target gate, + # same control-byte proof, no legacy 3-field grammar to apply. + if not target_compatible: + raise ReleaseError( + "new-format retained launch pair blocks older-release " + f"rollback: {shpool_id}: the target release cannot read " + "current-family launch records; close that session or " + "move its records into the failed/ quarantine first" + ) + if any( + byte < 0x20 or byte == 0x7F + for field in start + expected + for byte in field + ): + raise ReleaseError( + f"unsafe legacy retained launch fields block rollback: {shpool_id}" + ) + validated += 1 + continue uuid_pattern = re.compile( rb"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-" rb"[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$" @@ -1351,13 +1770,13 @@ def validate_rollback_launch_records(home: Path) -> int: raise ReleaseError( f"unsafe legacy retained launch fields block rollback: {shpool_id}" ) - legacy_pairs += 1 + validated += 1 after = root.lstat() stable = ("st_dev", "st_ino", "st_mode", "st_uid", "st_mtime_ns", "st_ctime_ns") if any(getattr(metadata, field) != getattr(after, field) for field in stable): raise ReleaseError(f"retained launch directory changed while reading: {root}") - return legacy_pairs + return validated def validate_rollback_creation_state(home: Path) -> tuple[Path, os.stat_result | None]: @@ -1475,6 +1894,19 @@ def integration_marker_path(args: argparse.Namespace) -> Path: return configured or args.state_dir.parent / "integration-ready-v1" +def collection_state_root(args: argparse.Namespace) -> Path: + configured = getattr(args, "collection_state_dir", None) + return configured or args.state_dir.parent + + +def target_floor_seed(args: argparse.Namespace, release: Path) -> dict: + return seed_collection_floor( + collection_state_root(args), + release / "tools/reset-collection-order.py", + dry_run=args.dry_run, + ) + + def activation_transaction_paths(args: argparse.Namespace) -> list[Path]: return [ args.root / "current", @@ -1759,9 +2191,10 @@ def activate(args: argparse.Namespace, *, action: str = "activate") -> dict: dry_run=True, ) validated_integration_proof(args, evidence["release"]["commit"]) + floor_seed = target_floor_seed(args, release) if action == "activate" else None if action == "rollback": validate_rollback_creation_state(home) - validate_rollback_launch_records(home) + validate_rollback_launch_records(home, target_release=release) previous = release_commit_from_current(root) plan = { "action": action, @@ -1775,6 +2208,8 @@ def activate(args: argparse.Namespace, *, action: str = "activate") -> dict: "dry_run": True, "service_restart": False, } + if floor_seed is not None: + plan["collection_floor"] = floor_seed return plan with deploy_lock(args.state_dir), contextlib.ExitStack() as rollback_stack: ensure_no_pending_bootstrap(args.state_dir) @@ -1782,11 +2217,11 @@ def activate(args: argparse.Namespace, *, action: str = "activate") -> dict: # Inspect before creating/acquiring the serialization lock so an # unsafe rollback refusal remains read-only. validate_rollback_creation_state(home) - validate_rollback_launch_records(home) + validate_rollback_launch_records(home, target_release=release) rollback_stack.enter_context(rollback_launch_creation_lock(home)) # Recheck under the same create lock used by sp launch writers and # hold it until the pointer transaction is complete. - validate_rollback_launch_records(home) + validate_rollback_launch_records(home, target_release=release) recovered = recover_activation_transaction(args) # Revalidate under the lock to close the check/switch race. evidence, missing = activation_preconditions( @@ -1801,8 +2236,9 @@ def activate(args: argparse.Namespace, *, action: str = "activate") -> dict: marker_content = validated_integration_proof( args, evidence["release"]["commit"] ) + floor_seed = target_floor_seed(args, release) if action == "activate" else None if action == "rollback": - validate_rollback_launch_records(home) + validate_rollback_launch_records(home, target_release=release) marker_path = integration_marker_path(args) previous = release_commit_from_current(root) receipt = { @@ -1892,6 +2328,8 @@ def activate(args: argparse.Namespace, *, action: str = "activate") -> dict: "service_restart": False, "recovered_transaction": recovered, } + if floor_seed is not None: + plan["collection_floor"] = floor_seed plan["previous_commit"] = previous plan["result"] = "current-switched" return plan @@ -1915,6 +2353,7 @@ def bootstrap(args: argparse.Namespace) -> dict: source_launcher = release / "deploy" / "session-kit-launcher" if not source_launcher.is_file(): raise ReleaseError(f"verified release is missing launcher source: {source_launcher}") + floor_seed = target_floor_seed(args, release) plan = { "action": "bootstrap", "commit": evidence["release"]["commit"], @@ -1923,6 +2362,7 @@ def bootstrap(args: argparse.Namespace) -> dict: "missing_sentinels": missing, "dry_run": True, "service_restart": False, + "collection_floor": floor_seed, } return plan with deploy_lock(args.state_dir): @@ -1940,6 +2380,7 @@ def bootstrap(args: argparse.Namespace) -> dict: source_launcher = release / "deploy" / "session-kit-launcher" if not source_launcher.is_file(): raise ReleaseError(f"verified release is missing launcher source: {source_launcher}") + floor_seed = target_floor_seed(args, release) plan = { "action": "bootstrap", "commit": evidence["release"]["commit"], @@ -1948,6 +2389,7 @@ def bootstrap(args: argparse.Namespace) -> dict: "missing_sentinels": missing, "dry_run": False, "service_restart": False, + "collection_floor": floor_seed, } args.root.mkdir(parents=True, exist_ok=True, mode=0o755) args.bin_dir.mkdir(parents=True, exist_ok=True, mode=0o755) @@ -2085,6 +2527,11 @@ def common_activation(parser: argparse.ArgumentParser) -> None: os.environ.get("SESSION_KIT_DEPLOY_STATE", "~/.local/state/session-kit/deploy") ), ) + parser.add_argument( + "--collection-state-dir", + type=path_arg, + help="inventory state root (defaults to the deploy state directory's parent)", + ) parser.add_argument("--dry-run", action="store_true") @@ -2105,6 +2552,11 @@ def parser() -> argparse.ArgumentParser: backup = commands.add_parser("verify-backup", help="verify the required pre-deploy backup") backup.add_argument("bundle", type=path_arg) + floor = commands.add_parser("seed-floor", help=argparse.SUPPRESS) + floor.add_argument("--state-root", required=True, type=path_arg) + floor.add_argument("--remedy-tool", required=True, type=path_arg) + floor.add_argument("--dry-run", action="store_true") + boot = commands.add_parser("bootstrap", help="install stable launchers once and activate") common_activation(boot) @@ -2142,6 +2594,10 @@ def main() -> int: "canonical_head": marker["canonical_head"], "ok": True, } + elif args.command == "seed-floor": + result = seed_collection_floor( + args.state_root, args.remedy_tool, dry_run=args.dry_run + ) elif args.command == "bootstrap": result = bootstrap(args) elif args.command == "activate": diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..9ed7f25 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,59 @@ +# Session Kit documentation + +Everything here is written for one of four moments: getting it installed, using +it day to day, understanding what it trusts, or fixing something. Find the +moment you are in. + +Back to the [project README](../README.md). + +## Getting it running + +| Document | What it covers | +|---|---| +| [Install Session Kit](install.md) | Every install route, the supported shpool paths, provider setup, project import, and activation. | +| [Use Session Kit](usage.md) | Opening the picker, starting sessions, and what each command does once you are running. | +| [Projects](projects.md) | A project is a directory: the shortcut you launch it by, the settings it launches with, and the sessions that belong to it. | + +## Using it day to day + +| Document | What it covers | +|---|---| +| [Picker navigation](picker-navigation.md) | The home screen, the cursor-driven picker, mouse behaviour, action panels, machine sessions, and closed-session restore. | +| [Configure Session Kit](configuration.md) | Every setting, including account enrolment, colour, terminal titles, and where host configuration lives. | +| [Claude Code and Codex integration](provider-integration.md) | The exact contract with each provider, and what stays under the provider's control. | + +## When something is wrong + +| Document | What it covers | +|---|---| +| [Troubleshooting](troubleshooting.md) | Read-only checks first, then the named problems and their remedies. | +| [Update and roll back](update-and-rollback.md) | How releases are installed and selected, and how to go back to one already on the machine. | +| [Uninstall](uninstall.md) | What `session-kit uninstall` removes, and what it deliberately leaves alone. | +| [Migrate an older installation](migrations/legacy-install.md) | A procedure, not a command, for moving a hand-managed shpool setup onto Session Kit. | + +## What it trusts, and why + +| Document | What it covers | +|---|---| +| [Architecture](architecture.md) | How shpool terminals are joined to provider processes without treating display text as identity. | +| [Security and local data](security-and-data.md) | No hosted service, no telemetry: what is read, what is written, and what stays owner-only. | +| [Vendor formats this kit depends on](pinned-internal-formats.md) | The provider files the kit reads and writes, which both vendors call internal and free to change. | + +## For maintainers + +These are kept in the open because the project's claims should be checkable. + +| Document | What it covers | +|---|---| +| [Voice](voice.md) | The contract for every string the kit can show a person, so every surface speaks one way. | +| [Release process](maintainers/release-process.md) | The public release checklist: one reviewed commit carried unchanged through export, artifact, tag, and publication. | +| [Inventory modularization contract](maintainers/modularization-roadmap.md) | How the inventory facade and its implementation modules are allowed to move. | + +## Elsewhere in the repository + +| Document | What it covers | +|---|---| +| [CHANGELOG](../CHANGELOG.md) | Release history. | +| [Contributing](../CONTRIBUTING.md) | What is welcome, and what may be declined. | +| [Security policy](../SECURITY.md) | Supported versions, and how to report a vulnerability privately. | +| [shpool patches](../shpool-patch/) | The optional patches, their scope, and their checks. | diff --git a/docs/architecture.md b/docs/architecture.md index 76a9c09..139d45f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,11 +5,11 @@ without treating display text as identity. ## Core rules -1. A provider conversation UUID is durable identity. -2. A terminal number, title, directory, and timestamp are display context. -3. Every mutation must recheck live identity immediately before it runs. -4. A Session Kit update must not disturb an existing shpool session. -5. Missing or conflicting evidence fails closed. +1. Provider UUID plus exact process generation is identity. +2. Session number, title, directory, and timestamps are display context. +3. Every mutation rechecks live identity immediately before it runs. +4. Updates preserve managed sessions even while kit-owned workers refresh. +5. Missing, stale, or conflicting evidence fails closed. ## Data flow @@ -17,100 +17,139 @@ without treating display text as identity. shpool list --json ─┐ Claude Code state ─┼─> inventory ─> frozen snapshot ─> picker / sp Codex local state ─┤ │ -Linux /proc ────────┘ ├─> terminal-number state - ├─> recovery state - └─> private action proof +native processes ──┘ ├─> stable session numbers + ├─> closed and recovery state + └─> short-lived action proofs stable launcher -> selected immutable release -> helper -managed Bash -> optional journal -> provider -> persistent managed Bash +managed Bash -> optional journal -> provider -> managed Bash ``` -`lib/session_inventory.py` is the compatibility entry point. Focused -implementation is moving into `lib/sessionkit_inventory/` in small, -behavior-preserving steps. +The collector takes bounded snapshots, joins provider conversations to exact +managed shells, publishes one private document, and refuses a strict action +view if any required identity is partial. -The inventory: +## Inventory package -- takes one bounded shpool snapshot; -- scans a bounded Linux process tree; -- joins provider roots to exact shpool shells; -- classifies structured reply and provider-exit state; -- assigns boot-scoped terminal numbers; -- selects one display title; -- writes private recovery and terminal-number state; -- renders control-safe terminal output. +`lib/session_inventory.py` is the installed compatibility entry point. It +retains CLI parsing, public imports, and compatibility wrappers while focused +implementation lives under `lib/sessionkit_inventory/`. -Strict and guard snapshots refuse partial provider identity. +The package currently contains these focused groups; every shipped module is +accounted for here: -## Commands and action proofs +| Area | Modules | +| --- | --- | +| Core I/O and proof | `common`, `private_store`, `state_io`, `validation`, `processes` | +| Provider readers | `providers`, `providers_claude`, `providers_codex`, `attention`, `pulse`, `transcripts`, `transcript_text`, `session_model`, `worker_model` | +| Collection and display | `collector`, `model`, `snapshot`, `labels`, `idle_state`, `render` | +| Names and selectors | `terminal`, `names`, `names_push`, `self_name`, `colors`, `origins`, `printed_selectors` | +| Accounts and projects | `accounts`, `account_guard`, `projects`, `worktrees` | +| Lifecycle and history | `lifecycle`, `recovery`, `recovery_list`, `closed_sessions`, `reaper`, `subagent_sweep`, `history_search`, `journal_render`, `migration` | -`bin/sp` implements explicit operations. `bin/shpool_login` provides the SSH -picker. `bin/shpool_status` renders and queries inventory. -`bin/codex_resume_here` prepares an exact Codex resume. +Package modules do not import the facade. Imports perform no process scans, +locks, configuration reads, or state writes. Compatibility wrappers inject +dependencies at call time where tests and older callers rely on facade patch +points; package-local callers are patched on the module that resolves them. +The [modularization contract](maintainers/modularization-roadmap.md) records +that boundary. -Before an action, the shared command layer binds and rechecks: +## Two picker layouts, one meaning -- shpool daemon process and start time; -- shpool terminal ID and generation; +The key-driven picker and `sp list` use +`lib/sessionkit_inventory/render.py`. The cursor-driven picker uses +`lib/sessionkit_tui/frame.py`. They are separate layout engines because one +emits complete terminal frames from Bash and the other paints a curses screen. + +They share the same snapshot, labels, canonical session-order key, and guarded +`sp` actions. A wording or order change therefore belongs in the shared label +and model layer first, followed by both renderers and their comparison tests. + +The installed `kit` executable resolves the key-driven picker directly. In an +interactive managed Bash that sourced `bashrc/shpool.bashrc`, the `kit` shell +function goes through `bin/shpool_login_launcher`; that launcher can select the +cursor-driven screen and fall back to the key-driven one. SSH itself opens an +ordinary shell rather than a picker. + +## Action proofs + +Before an action, the command layer binds and rechecks: + +- session-manager process and start time; +- terminal ID and generation; - managed shell process and start time; - provider process, ancestry, and start time; - provider and exact conversation UUID; -- the frozen dashboard generation. +- frozen snapshot generation. -The proof is private, short-lived, owner-only state. If any field changes, the -action stops and the dashboard refreshes. +The proof is owner-only and short-lived. Any change stops the action and sends +the picker back for a fresh snapshot. Neither picker kills, moves, or restores +a session directly; it invokes the matching proof-bound `sp` verb. -## Managed terminal lifecycle +## Session lifecycle -`bashrc/shpool.bashrc` consumes a paired one-shot launch record and starts the -selected provider only after exact startup proof. +`bashrc/shpool.bashrc` consumes paired one-shot launch records and starts the +chosen provider only after exact startup proof. The provider remains a child +of the managed shell. -The provider does not replace the managed shell. When Claude Code or Codex -exits, the terminal remains alive and records an exited-provider state. The -terminal menu can reopen the exact conversation, mark the terminal to keep, -open an ordinary shell, or close the terminal. +A clean provider exit closes the session and records the conversation in +Closed sessions. A crash reopens the exact conversation once. A second crash +within a minute stops the loop; the session closes only when recovery is +proved, otherwise it remains open and the picker states why. -This boundary prevents a normal provider quit from silently deleting a -recoverable shpool session. +Journals are optional and off by default. When enabled, each new managed +session writes an append-only local recording. Provider transcripts remain in +provider-owned storage. Session Kit uploads neither. -## Journals +## Cleanup -Journals are optional and off by default. When enabled, each new managed -session writes an append-only local segment. Reattachment uses shpool's bounded -rendered buffer rather than replaying the full journal. +The scheduled cleanup observer considers only detached, provider-exited +terminals. A target must keep the same exact safe state for 72 continuous +hours after the timer is enabled. Live provider work, child work, attachment, +recovery conflict, identity churn, or unreadable evidence blocks the close. -Provider-native transcripts remain in provider-owned storage. Session Kit does -not copy them into logs or upload them. +Sub-agent cleanup is a separate pass. It watches the worker transcript's size +and nanosecond modification time, not CPU use. An unreadable idle window turns +that pass off rather than selecting a shorter timeout. -## Cleanup +## Human and machine output -The scheduled observer may track a disconnected provider-exited terminal. It -cannot close the terminal until the cleanup timer is enabled and the same exact -safe state has been observed continuously for 72 hours. +Normal rows and human detail output use session numbers and titles. Internal +terminal IDs, provider UUIDs, PIDs, and generation fields remain in owner-only +state, action proofs, and explicit machine-readable lookup output. A command +may accept an exact ID without printing it back. -Automatic close requires the same exact terminal generation, the same exited -provider identity, no attachment, no live provider or child work, no pending -reply, no recovery conflict, and unchanged evidence. Any uncertainty resets or -blocks eligibility. Manual `sp prune` uses fresh checks and confirmation. +The snapshot schema is additive. Existing fields are not renamed, retyped, or +removed while a picker from an earlier release may still be reading them. See +[The session snapshot](../lib/sessionkit_inventory/SNAPSHOT.md). -## Display model +## Installed releases and services -The main dashboard favors names and state. Internal shpool IDs and provider -UUIDs are available through detail, JSON, and explicit search views, not normal -rows. +Each selected commit has an immutable release directory. Stable launchers +resolve `current` once and pin that physical directory for the command's +lifetime. The separately pinned management release keeps update and rollback +recovery available when an older runtime is selected. -Semantic color categories are provider, availability, attention, danger, and -secondary text. Text labels always carry the meaning, so color is optional. +Install, update, and rollback write platform service definitions and then +refresh the kit watchdog: -## Updates +- Linux reloads the user systemd manager, starts a newly introduced timer only + when systemd has no prior enablement decision, and try-restarts the running + watchdog; +- macOS rewrites private templates and kickstarts an already loaded kit + watchdog; +- neither path restarts the shpool session manager. -Every installed Git commit has an immutable release directory. A stable -launcher resolves one `current` link and dispatches only approved helper names. -Selecting another Session Kit release changes the pointer and receipt but does -not restart shpool. +Explicit `session-kit services enable` and `disable` control the full service +set under their live-session safety checks. ## Platform boundary -The beta requires Linux `/proc`, Bash, and systemd user services. macOS and -other process or service models stop before installation or mutation. Files -kept for future platform work do not establish support. +Linux uses `/proc`, Bash, Python 3.10 or newer, and systemd user services. +macOS 14 or newer supports Apple Silicon and Intel with Python 3.11 or newer, +Homebrew Bash 4 or newer, native process APIs, and per-user LaunchAgents in the +logged-in GUI domain. The macOS watchdog is report-only; repair mode depends on +Linux daemon-thread evidence. + +Both platforms use shpool 0.11.0. Optional source patches and their exact scope +are documented in the [shpool patch guide](../shpool-patch/README.md). diff --git a/docs/assets/mark.svg b/docs/assets/mark.svg new file mode 100644 index 0000000..e32cdeb --- /dev/null +++ b/docs/assets/mark.svg @@ -0,0 +1,13 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" + role="img" aria-label="Session Kit"> + <title>Session Kit + + + + + diff --git a/docs/assets/readme/how-it-works.png b/docs/assets/readme/how-it-works.png new file mode 100644 index 0000000..678c245 Binary files /dev/null and b/docs/assets/readme/how-it-works.png differ diff --git a/docs/assets/readme/needs-you.png b/docs/assets/readme/needs-you.png new file mode 100644 index 0000000..08c17fe Binary files /dev/null and b/docs/assets/readme/needs-you.png differ diff --git a/docs/assets/readme/picker.png b/docs/assets/readme/picker.png new file mode 100644 index 0000000..9325c1f Binary files /dev/null and b/docs/assets/readme/picker.png differ diff --git a/docs/assets/readme/safety-model.png b/docs/assets/readme/safety-model.png new file mode 100644 index 0000000..5f9b8fe Binary files /dev/null and b/docs/assets/readme/safety-model.png differ diff --git a/docs/assets/readme/social-preview.png b/docs/assets/readme/social-preview.png new file mode 100644 index 0000000..8241fe3 Binary files /dev/null and b/docs/assets/readme/social-preview.png differ diff --git a/docs/configuration.md b/docs/configuration.md index b7dbb65..c2973eb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,15 +10,204 @@ uses XDG locations where available. | Inventory configuration | `${XDG_CONFIG_HOME:-$HOME/.config}/session-kit/inventory.json` | | Project aliases | `${XDG_CONFIG_HOME:-$HOME/.config}/session-kit/projects.tsv` | | Session Kit state | `${XDG_STATE_HOME:-$HOME/.local/state}/session-kit` | -| Optional terminal journals | `${XDG_STATE_HOME:-$HOME/.local/state}/shpool-journal` | +| Optional session history | `${XDG_STATE_HOME:-$HOME/.local/state}/shpool-journal` | | Recovery journal archive | `${XDG_STATE_HOME:-$HOME/.local/state}/shpool-journal-recovery` | +| Journal archive read by `sp find` | `${XDG_STATE_HOME:-$HOME/.local/state}/shpool-archive` | | Paired launch records | `${XDG_STATE_HOME:-$HOME/.local/state}/shpool-start` | +| shpool configuration | `${XDG_CONFIG_HOME:-$HOME/.config}/shpool/config.toml` | | Immutable releases | `$HOME/.local/lib/session-kit/releases` | | Stable commands | `$HOME/.local/bin` | +| macOS launchd templates | `${XDG_CONFIG_HOME:-$HOME/.config}/session-kit/launchd` | +| Active macOS LaunchAgents | `$HOME/Library/LaunchAgents` | Private directories should be mode `0700`; private files should be mode `0600`. Sensitive proof files also require the expected owner, regular-file type, and -link count. +link count. Provider transcripts are the one exception, because the provider, not Session Kit, creates them under your umask: a Codex rollout is accepted +when this uid owns it and no group or other write bit is set. See +[Security and data](security-and-data.md). + +## Account profiles + +An account profile is a local alias for one Claude Code or Codex subscription +login. It is not a Session Kit cloud account. Claude profiles launch with a +profile-specific `CLAUDE_CONFIG_DIR`; Codex profiles launch with a +profile-specific `CODEX_HOME`. These roots keep each provider's native login and +local conversation data separate. + +```text +sp account list +sp account enroll +sp account verify +sp account sync-rules [--check] +sp account configure-feeds +``` + +`enroll` creates the owner-private profile location and registers the alias and +email description. Sign in through the provider's normal interactive login. +Session Kit does not copy, link, import, print, or log credentials from another +profile. `verify` then checks that the provider's reported signed-in identity +matches the registered description. An unverified or logged-out profile is not +eligible for a recommendation or account change. + +Aliases are short labels intended for the home screen. `sp account list` keeps +the registered identity and last verification time. Session detail shows the +bound alias, email, and provider-supplied plan. The account-choice screen shows +current roster health, usage, and any provider-qualified recommendation. +Provider data that is missing or cannot be verified is shown as unknown rather +than inferred. + +### One rulebook across every profile + +Each provider reads its own instruction file — `CLAUDE.md` for Claude Code, +`AGENTS.md` for Codex — and each profile keeps a private copy inside its own +config root. Nothing reconciles those copies on its own, so a working rule added +to one of them applies only there, and profiles enrolled at different times start +from different text. + +`sp account sync-rules` fixes that. It reads one file you own and writes it into +every provider default home and every enrolled profile, between these markers: + +```text + + +``` + +Only the region between the markers is replaced. Anything outside it is that +provider's or that profile's own text and is left exactly as it was, so a +rulebook can hold provider-specific notes alongside the shared rules. A rulebook +that has never carried the block keeps all of its existing text: the block is +added above it rather than replacing it. Each rewritten file gets a `.bak-sync` +copy beside it. + +The shared file lives at `$XDG_CONFIG_HOME/agent-rules/universal-rules.md` +(`~/.config/agent-rules/universal-rules.md` by default), or wherever +`SESSION_KIT_RULES_FILE` points. It stays outside the kit and outside any +repository: it holds your own working rules, which are yours and not +distributable. With no such file present, nothing is written and every rulebook +stands on its own. + +`--check` reports which rulebooks no longer match and exits non-zero without +changing anything, which is what `session-kit doctor` runs as its `rules-parity` +check. + +Memory is a separate store with the same shape: each profile has its own +`projects//memory` directory, so memories written under one account are +not visible under another. If you want one shared store, point each profile's +memory directory at a common location with a symbolic link — the providers read +and write through it normally. Keep the shared `MEMORY.md` index under 25,000 +characters; that is a hard limit in Claude Code, and a larger index is refused +rather than truncated. + +The default Claude and Codex homes remain usable as legacy profiles, but Session +Kit does not invent an email identity for them. Enroll and verify a named profile +before relying on account-aware recommendations or switching. + +One account change happens without being asked: a conversation whose own +account has run dry is carried to another enrolled account, once, and the +change is reported afterwards. Every condition below has to hold, and each is +a number from the usage feed rather than an estimate: + +* the roster and the rotation advice are both fresh. A stale or unreadable + feed disables automatic switching outright, because stale advice is how a + machine drains an account it believed was full; +* the conversation's own account is spent, its published weekly usage has + reached `SESSION_KIT_ACCOUNT_EXHAUSTED_PERCENT` (default `100`). A spent + five-hour window alone does not qualify: it refills on its own within five + hours, and moving for it would spend the single move for nothing; +* the account it moves to still holds a reserve, +`SESSION_KIT_ACCOUNT_RESERVE_PERCENT` (default `25`) of its weekly window + must be unused. An account past that line is refused even when it is the + only candidate left. An account whose published numbers are missing, or are + present but not readable as window fractions, is never a candidate: unknown + is never treated as "no objection" in either window; +* the conversation has not already been moved automatically. A session that + spends a second account stops, reports, and waits. It never walks to a third. + A move is counted from the moment it is reserved, before anything + irreversible happens, so a failure part-way through reads as "already moved" + rather than as no move at all; only a proven return to the original account + gives it back; +* the live session agrees with the record. The account a conversation is + actually signed in to is published per row, and a row that disagrees with + the account on file, or that carries no readable account, is left alone, +the account found dry has to be the account the process is really using; +* an explicitly requested model is still generation-bound to the same managed + shell. The handoff rebuilds that model record and resumes with the same + `--model`; a missing or mismatched record refuses the relaunch rather than + silently selecting the provider default. + +Anything else is a hold with a stated reason, and a hold changes nothing. When +no account clears the reserve, automatic switching stands down and the work +waits for the owner rather than draining what is left. Usage is published per +account, never per conversation, so no rule here depends on knowing what a +single conversation spent. + +Nothing automatic ever enables an account. Re-verifying a target profile +re-reads it under the state lock and refuses if it was switched off while the +provider was being asked to identify itself, so an account the owner disables +stays disabled even when that change happens in the middle of a handoff. + +A failed handoff leaves the conversation where it was. The unattended path +rolls back without the picker's destructive last resort, that path closes the +session and re-creates it, which is not "still working", and where even the +rollback cannot be proven it changes nothing further and says so. + +A number the feed publishes that cannot be a window fraction condemns the whole +feed, not just its own row. A single value cannot reveal a units change, if +percents replaced fractions, `55` is obviously wrong but `0`, `1` and `2` still +parse, so one impossible number means none of that feed's numbers are judged. + +### Before you turn automatic switching on + +Read this first, because the consequence lands on your account rather than on +this tool. + +Holding several subscriptions and doing your own work on each one is ordinary +use. Nothing here shares an account between people, resells access, or puts a +subscription token into a harness of its own: every session launches the +provider's own binary against its own configuration directory, and the kit +never reads, copies, prints, or logs a token. That is the side of the line +providers have drawn. + +Moving a conversation to another account *because the first account hit its +limit* is a different shape. However many subscriptions you paid for, an +automated move triggered by exhaustion is the pattern that limit-evasion +enforcement is built to find, and enforcement acts on accounts. This is a +judgement about your own risk, and it is why the mechanism ships off, why the +installer never enables it, and why nothing turns it on for you. + +If you want the information without the action, leave the watchdog in `report` +mode and read what it would have done. That costs nothing and carries no risk. + +`sp account-auto-switch ` prints the decision for one session and +changes nothing; `--apply` is what actually moves it. Judging is the default +because this verb moves a live conversation between paid subscriptions and is +reachable from any shell in the checkout. The periodic check lives in the +watchdog and runs every +`SESSION_KIT_ACCOUNT_GUARD_SECONDS` (default `300`); the move itself is +performed only when the watchdog is in its acting mode, +`SESSION_KIT_WATCHDOG_MODE=repair`. In the default `report` mode the watchdog +says a session is on a spent account and changes nothing. + +Being told is a debt, not an event. A completed move records what the owner is +owed before anything tries to deliver it, and the record is cleared only by a +delivery that actually succeeded, a notifier that is down costs a retry rather +than the message. `session_inventory.py account auto-pending-notices` lists +every move whose notice is still owed, oldest first, which is the hand-off +point for the configured delivery route. Automatic switching deliberately does +not choose a severity, device, or alert policy of its own. + +The owner-only state sentinel +`$XDG_STATE_HOME/session-kit/account-switching-off`, defaulting to +`~/.local/state/session-kit/account-switching-off`, disables enrollment, new +account selection, manual switching, and automatic switching. It does not +delete profiles, change provider-owned login state, or stop running providers. +Session Kit never creates it: taking the manual switch away is an operator +decision, not a background-process side effect. + +`configure-feeds` stores two owner-controlled absolute paths in the private +Session Kit state directory. Guided New uses a fresh roster for availability +and provider-qualified advice for recommendations. Missing or stale feeds leave +the choice unselected; they never guess an account. ## Inventory settings @@ -39,33 +228,112 @@ Session Kit manages local aliases and automatic titles. Set names through the command interface so identity checks and locking remain active: ```text -sp name -sp name reset <terminal-number|shpool-id> +sp name <session-number|shpool-id> <title> +sp name reset <session-number|shpool-id> ``` Manual names take priority over provider titles, retained automatic names, and fallback descriptions. +Renaming a session by hand also settles who owns its name, for good. Session Kit +records the rename in `name_ownership` alongside the aliases, and every +automatic naming path, the Claude title hook, the Codex auto-titler, `sp +self-name`, and any queued title retry that outlives a restart all read that +record and leaves the session alone. `sp name reset` drops the name you gave and +keeps the ownership: automatic naming does not come back and quietly rename a +session you already named. + +Automatic naming claims a name the same way, once, at a conversation's first +prompt. The claim is what makes the Claude hook a no-op on its second firing and +stops a later pass from re-titling a conversation an earlier pass named. +Pruning orphaned automatic titles clears spent automatic claims; it never +clears a rename you made. + ## Project aliases +The initial interactive installer offers to import existing folders from both +providers. Review the same bounded discovery at any time: + +```text +session-kit projects discover +session-kit projects candidates +session-kit projects import +session-kit projects import --select 1,3-4 +session-kit projects list +session-kit projects add /absolute/path/to/web +session-kit projects add api /absolute/path/to/api +session-kit projects default api codex +session-kit projects normalize +session-kit projects here +session-kit projects ignore /absolute/path/to/scratch +session-kit projects unignore /absolute/path/to/scratch +``` + +`candidates` answers "what would import add, and under what name" without +writing anything. `import --select` takes only the entries you name, so a +first run is never all or nothing. `add` takes a directory and, if you want one +of your own, a short name; the provider is not asked, because which one opens a +directory is answered when a session starts. `default` records the provider a +project usually opens with, and `any` clears it again. `normalize` folds a +directory that is listed twice, the shape the old three-answer add produced, into one entry, keeping the first short name. `here` adds the current directory +under a derived short name. `ignore` keeps a directory out of the picker, and +`unignore` takes that decision back so the directory is offered again. + +The picker offers the same choices without any of these commands: press `m` +for More, then `p` for Projects, to add a directory, review the ones your +providers use that are not listed yet, drop one, or undo a drop with `u`. + +Discovery reads only provider-owned local records. For Claude Code, those are +the project map in `~/.claude.json` and the project field in local history. For +Codex, they are project entries in `~/.codex/config.toml` and distinct working +directories in the newest local `state_*.sqlite` conversation database. Missing +directories and provider-configured temporary roots are ignored. Session Kit +does not scan unrelated folders. + +Every directory gets at most one entry, whichever providers recorded it. A +directory is one project; the provider column on its row is a **default**, not +a lock, so `sp new codex web` opens the same directory with the other provider +without a second row. Adding a directory that is already listed is refused by +name rather than given a second entry. Alias collisions are resolved +with parent-folder or provider suffixes. Import is repeatable: directories +that already have a row, including an `ignore` row, and all hand-written +lines are left unchanged. Before appending any new rows, Session Kit saves an +owner-only backup under its state `backups` directory. + +A row whose provider is `ignore` is a decision, not a shortcut. It is never +listed in the picker, never resolves as a launch target, and because import +gives each directory at most one row, discovery can never offer that +directory again. Adding the directory back withdraws the ignore. + The projects file is tab-separated: ```text -# alias<TAB>provider<TAB>absolute cwd +# alias<TAB>default provider<TAB>absolute cwd web claude /absolute/path/to/web api codex /absolute/path/to/api tools shell /absolute/path/to/tools +docs any /absolute/path/to/docs ``` -Aliases use lowercase letters, numbers, `_`, or `-`. The provider is `claude`, -`codex`, or `shell`. The directory must be absolute and exist when used. +Aliases use lowercase letters, numbers, `_`, or `-`. The middle column is the +project's default provider, `claude`, `codex`, `shell`, or `any` for a project +that keeps no default. The directory must be absolute and exist when used. ```text sp new web -sp new codex api +sp new codex web sp new shell tools +sp new claude docs ``` +`sp new web` opens the default; naming a provider opens the same directory with +that one instead. A project whose default is `any` is never opened by guess: +`sp new docs` says which words start it rather than falling through to a shell. + +A row is the host's own shortcut, and adding one is also what lets a project's +committed `session-kit.toml` decide the provider, account, model, and startup +command a launch uses. See [Projects](projects.md). + ## shpool settings Review [the shpool example](../config/shpool.example.toml). Session Kit expects @@ -78,6 +346,11 @@ vt100_output_spool_width = 200 prompt_prefix = "" ``` +On macOS, installation also records the resolved Homebrew Bash executable in +this file. Session Kit refuses a different existing `shell` value. The launchd +shpool job receives this exact configuration path explicitly because shpool's +native macOS default is under `~/Library/Application Support`. + ## Journals Terminal journals are off by default. Opting in affects new managed sessions; @@ -86,10 +359,21 @@ it does not add or remove a writer inside a session already running. The `$HOME/.no_shpool_journal` sentinel forces journals off. Removing it does not delete existing files and does not change an active terminal. +When a session with a journal is opened or reattached, the tail of its +rendered journal (the settled text `sp history` pages, never raw bytes) is +replayed into the terminal first, so the window starts with scrollback +instead of only the live frame. `SESSION_KIT_ATTACH_HISTORY` bounds the +replay; `0` disables it. + +Closing or pruning a session does not remove its recording. Nothing ages +recordings out, so a busy machine accumulates them: they are the largest thing +the kit writes. `session-kit doctor` reports the total and warns when part of it is older than +`SESSION_KIT_JOURNAL_RETENTION_DAYS` (30 by default). That setting is a +reporting bound only; no command deletes a recording on its own. + ## Optional feature sentinels ```text -$HOME/.no_shpool automatic SSH picker $HOME/.no_shpool_reaper scheduled cleanup observer $HOME/.no_shpool_watchdog health reports $HOME/.no_shpool_journal journals for new managed sessions @@ -98,49 +382,416 @@ $HOME/.no_shpool_journal journals for new managed sessions Removing a sentinel re-enables that component only after its other configuration checks pass. +The Bash integration itself is managed by `session-kit enable-login` and +`session-kit disable-login`. These compatibility commands add or remove the +guarded `kit` function and SSH hint; they do not enable automatic picker +startup. + ## Advanced watchdog repair mode -The installed watchdog defaults to `report` mode. The source also accepts -`SESSION_KIT_WATCHDOG_MODE=repair` as an explicit advanced opt-in. The installer -does not enable it, and it is not needed for ordinary health reporting. +The installed watchdog defaults to `report` mode. On Linux, the source also +accepts `SESSION_KIT_WATCHDOG_MODE=repair` as an explicit advanced opt-in. The +installer does not enable repair mode. macOS supports report mode only because +watchdog repair requires Linux daemon-thread evidence. Repair mode can close a terminal that has direct evidence of an unrecoverable shpool handoff failure and relaunch the exact provider conversation in a new -managed terminal. That can discard terminal-only state, interrupt child +session. That can discard terminal-only state, interrupt child processes if the proof is wrong, and depend on provider-native recovery being available. Quiet output alone never qualifies. Review the owner-only watchdog log and test manual `sp repair` before enabling this mode. +The watchdog accepts a handoff marker only when its journal PID and monotonic +timestamp belong to the current daemon generation, the marker is old enough, +and no later successful attach for that session cancels it. A marker with +unknown recent-output age is reported but never repaired automatically. A +process-wide mismatch between listed sessions and serving-thread count is also +reported once at manager level; it cannot identify a specific session and is +never used to repair one. + +## Watchdog alerts + +`SESSION_KIT_WATCHDOG_NOTIFY` is **unset by default, so the watchdog raises no +alert anywhere.** It detects and logs conditions either way, but without a +notifier the only record is the owner-only watchdog log and the picker banner, +nobody is told. Set it to an executable that accepts: + +```text +--type=<id> --severity=warning --title=<subject> --body=<text> +``` + +`SESSION_KIT_WATCHDOG_ALERT_TYPE` sets the `--type` value and defaults to +`session-kit.watchdog`. Severity is always `warning`; an automatic repair must +never fire a critical. + +Wire this up if you rely on the watchdog at all. Detection without a notifier +leaves the finding only in local state, so the first visible symptom may be a +failed attempt to open a session. Prefer a systemd drop-in over editing the +unit, so a reinstall cannot drop it: + +```ini +# ~/.config/systemd/user/session-kit-watchdog.service.d/notify.conf +[Service] +Environment=SESSION_KIT_WATCHDOG_NOTIFY=/path/to/your/notifier +``` + +## Notice routing + +The picker lists sessions waiting for you. Everything else that used to appear +there, a repair that failed, a session reported quiet with nothing changed, a +stalled session (a question another window is asking) is not a session waiting +for you, and it is routed instead of listed. The installed +`session_kit_notice` helper is that route, and the watchdog calls it. + +**Where a notice goes.** To the terminal session you are already sitting in when +you are at the machine; to the away transport when you are not. + +**The terminal leg only applies inside a managed session.** If you are working +in a terminal Session Kit does not manage, an ordinary SSH window, a local +shell, an editor's terminal, nothing is attached, so presence reads as away and +every notice takes the away transport. That is not a fault to fix: there is no +managed conversation to deliver into. It does mean that on a machine whose +operator usually works outside the kit, **the away transport is the only route +that will ever fire**, and leaving `SESSION_KIT_NOTICE_AWAY_CMD` unset means no +notice reaches anyone at all. + +**How "at the machine" is decided.** Two conditions, both required: **exactly +one** session is attached, and **a person has typed in it** in the last few +minutes (`SESSION_KIT_NOTICE_PRESENT_WITHIN`, default 300s). + +Nothing else counts, and in particular *provider output does not*. Output is +autonomous, a session prints for as long as its turn takes, with nobody in the +room, so a window you walked away from mid-turn would look more present than +one you were reading. The evidence used is INPUT: Claude Code's +`UserPromptSubmit` hook, which the kit already records per session, and which +only fires when a person submits a prompt. A session with no hook installed, a +Codex session, or one whose newest record is a notification rather than a +prompt, all read as no evidence, which is away. + +`attached` on its own means only "open in another window", no focus, no proof +anyone is there, and a session can stay wired to an SSH window that no longer +exists. Two attached sessions mean the machine cannot say which one you are in, +and a notice into a Claude session PROMPTS that conversation, so ambiguity takes +the away route rather than guessing. No snapshot, a stale one, nothing attached, +several attached, or no sign of typing all mean away, a duplicate notice costs +a glance, a missed one costs an evening. +`session_kit_notice presence` prints the verdict and sends nothing. + +**The terminal route never writes to a terminal.** It uses the session's own +authenticated message socket, so it cannot corrupt a screen a provider is +drawing. Only Claude sessions are addressable that way today; an attached Codex +session, and a session with an open question of its own, both fall through to +the away transport rather than being interrupted. + +The terminal proof uses one receipt of record on every path: a valid JSONL user +record with the exact message UUID that the kit's transcript renderer accepts. +A UUID substring in malformed, truncated, unrelated, or non-renderable bytes is +not a receipt. Confirmation parses only the bounded 256 KiB transcript tail; +when that window cuts its first line, only the partial line is discarded and +complete later records remain eligible. A socket `delivered` answer says only +that the frame reached the session's queue; it justifies waiting the same +bounded confirmation window but never promotes the attempt by itself. The +proof starts with the current same-target presence check immediately before +`send()`, brackets the +returned socket call with another current check, establishes the transcript +receipt, and only then starts a separate current same-target read. That final +read's attachment sample therefore cannot predate the receipt. A detach before, +inside or after `send()`, while transcript confirmation is pending, or after +confirmation all demote the attempt. Every current check refuses the recently +published inventory; that cache cannot describe this interval. A slow, failed +or uncertain manager answer also demotes the attempt. + +The socket result is first written to `notice-latch.json` as **provisional**, +with `landed=false`; it suppresses no replay. Reconciliation promotes it to a +terminal `landed=true` only when the transcript receipt exists and the same +attachment is proved immediately before send and again by a collection begun +after that receipt. Otherwise +the away leg fires and replaces the provisional result with the reconciled +away result. This can deliver the same notice twice when the conversation +accepted a frame just as its terminal detached. That duplicate is deliberate: +a receipt proves conversation acceptance, not that a person remained at the +terminal, and uncertainty defaults to away rather than false success. + +**One incident, one notice, and the watchdog decides what an incident is.** +Every notice is latched by its key and by the evidence behind it, in +`notice-latch.json`, and the key is the SESSION rather than the record, one +wedged session can write many records. Within an incident, a stronger statement +(ordinary silence, then the daemon's own handoff failure) is a new one and is +made; a weaker one is not. Across incidents the rule is the watchdog's own: it +treats output after its last record as recovery and writes a fresh record, so a +record written **after** the last thing said about that session is a new +incident and is said. There is deliberately no second definition of "new +incident" here, two rules over one event stream is how a real second failure +goes unmentioned. An attempt that did not land is never latched as delivered, +and only an entry explicitly marked `reconciled` with `landed=true` suppresses +a replay. A provisional entry is retried. + +**One message, not one per item.** A sweep says everything new in a single +notice naming up to five sessions and counting the rest. A large backlog +therefore produces one bounded notice instead of one message per record. + +**Age decides what gets named, never what gets said.** A record older than +`SESSION_KIT_NOTICE_MAX_AGE` (default 21600s, the watchdog's own quiet-report +window) is counted in the message rather than listed by name. It is not +swallowed: age is not acknowledgement, and converting an unresolved warning into +"said" on the strength of a clock is the silence-looks-like-success failure this +route exists to end. + +**Unconfirmed is not delivered.** The terminal transport cannot prove a message +landed. Even its `delivered` result confirms only the session queue. Every +notice is therefore confirmed by finding it in the target's own transcript, +which is the sole receipt of record. Until that and the ordered attachment +checks reconcile, the record remains provisional. If either proof is absent, +the notice takes the away route instead of being recorded as delivered. + +**An unwired away transport is loud.** With `SESSION_KIT_NOTICE_AWAY_CMD` unset +there is no away route at all: the log says `UNWIRED`, and a sweep that had +something to say and reached nobody **exits non-zero** so it cannot be mistaken +for a quiet night. + +| Variable | Meaning | +| --- | --- | +| `SESSION_KIT_NOTICE=off` | route nothing at all (kill switch) | +| `SESSION_KIT_NOTICE_AWAY_CMD` | the away transport. Same `--type/--severity/--title/--body` contract as the watchdog notifier, so any conforming notifier fits, but it is never inherited from that notifier, because that one is usually a board and a board is not a person. Unset means there is no away route, and every attempt says so | +| `SESSION_KIT_NOTICE_TERMINAL=off` | never use the terminal route | +| `SESSION_KIT_NOTICE_QUESTION_AWAY=hold` | stop pushing another window's open question to the away transport. The default is `send`: a class with no route is the thing this exists to prevent. A held question is still recorded, logged and counted | +| `SESSION_KIT_NOTICE_PRESENT_WITHIN` | seconds since the last valid UserPromptSubmit after which the one attached session reads as a window left open (default 300) | +| `SESSION_KIT_NOTICE_CONFIRM_SECONDS` | how long to wait for a terminal notice to appear in the target's own transcript before treating it as unconfirmed and taking the away route (default 6) | +| `SESSION_KIT_NOTICE_MAX_AGE` | seconds a record may be old and still be named; older unresolved records are counted in the same notice (default 21600) | +| `SESSION_KIT_NOTICE_SNAPSHOT_MAX_AGE` | seconds a snapshot may be old and still answer the presence question (default 90) | +| `SESSION_KIT_NOTICE_LATCH`, `SESSION_KIT_NOTICE_LOG` | the router's own state and log | + +`extras/notify-chat-card` is a worked example of an away transport that reaches +a phone through a chat bot rather than a desktop popup. Wiring it, on a machine +whose watchdog runs under systemd, is a drop-in and a restart: + +```bash +mkdir -p ~/.config/systemd/user/session-kit-watchdog.service.d +cat > ~/.config/systemd/user/session-kit-watchdog.service.d/notice.conf <<'CONF' +[Service] +Environment=SESSION_KIT_NOTICE_AWAY_CMD=%h/.local/lib/session-kit/current/extras/notify-chat-card +Environment="SESSION_KIT_NOTICE_CARD_CMD=/path/to/your/chat-bot post-card" +CONF + +systemctl --user daemon-reload +systemctl --user restart session-kit-watchdog.service +``` + +Check it took, rather than assuming: +`~/.local/lib/session-kit/current/bin/session_kit_notice presence` prints the +verdict and sends nothing, and the next sweep writes either `ROUTED … via away` +or `UNWIRED:` to `notice.log` in the state directory. + ## Color -The picker uses color only when output is an interactive, supported terminal. -Provider, availability, attention, danger, and secondary text each have one -consistent category. Labels and symbols preserve the meaning without color. +Session Kit uses color in two unrelated ways, and only one of them is +configurable here. + +**Semantic color** marks provider, availability, danger, and +secondary text, each with one consistent category. The picker applies it only +when output is an interactive, supported terminal, and labels and symbols carry +the same meaning without it. + +**Session color** gives each Claude Code and Codex session its own identity +color, so two rows on screen do not look alike. It is derived from the +conversation's identity, and the two providers draw from separate palettes. +The palettes themselves are fixed, because Claude Code accepts only its own +eight color names, but the choice within a palette is not: `sp color <target> +<color>` records a per-session override and `sp color <target> reset` removes +it. See [Session colours](usage.md#session-colours) for the behavior and +`sp color reconcile` for settling sessions that already share one. -Disable color with either variable: +Disable color entirely with either variable: ```bash export NO_COLOR=1 export SESSION_KIT_NO_COLOR=1 ``` +## The terminal tab name + +The kit puts the session's own name on the terminal tab, on both providers, so +the window you are looking at and the list you opened it from agree about what +it is. + +It gets there three ways, because no single one covers every moment. + +**When you enter a session**, `sp go`, `sp takeover`, a fresh `sp new`, and the +picker's own open and take-over, the kit writes the tab name itself, from one +function, so the kill switch and the scrub below apply to every one of them. +Leaving a session through the picker puts `session kit` back on the tab. + +**Inside a Claude window** the name arrives through the kit's `sessionTitle` +hook, which Claude applies at `SessionStart` and at each prompt. That is what +carries a rename into a window that is already open. + +**Inside a Codex window** Codex writes the title itself from +`tui.terminal_title`, and every kit-launched session passes the kit's item list +on its own command line, so your `~/.codex/config.toml` is never edited and +Codex windows you start yourself keep whatever it says. The deployed template +lives at `$CODEX_HOME/session-kit/terminal-title.toml`. It is owned by the +release and replaced on every install and update, like the kit's Codex themes, +to change what kit sessions put on the tab, change it in the kit's own +`config/codex/terminal-title.toml` and install, or turn the feature off below. + +Restoring a conversation does not write a tab name, because a restore leaves +you at the picker or at a prompt rather than inside the session: naming the tab +there would label a terminal that is not in that session. The restored +session's name and colour are written into the provider's own store before it +launches, and its tab is named when you open it. + +Do not set `CLAUDE_CODE_DISABLE_TERMINAL_TITLE`. It reads like a way to stop +Claude writing titles so the kit can own them, and it is not: it silences every +title write from that window, the kit's hook included. With it set, Claude +windows carry no tab name at all. `session-kit doctor` reports it under +`tab-title` when it finds it set. + +To turn the whole thing off, on both providers: + +```bash +export SESSION_KIT_TAB_TITLE=off +``` + ## Supported environment overrides +### Locations and commands + ```text SESSION_KIT_CONFIG SESSION_KIT_STATE_DIR SESSION_KIT_JOURNAL_DIR SESSION_KIT_ARCHIVE_DIR +SESSION_KIT_JOURNAL_RECOVERY_DIR +SESSION_KIT_JOURNAL_RETENTION_DAYS SESSION_KIT_PROJECTS_FILE SESSION_KIT_SHPOOL_CMD SESSION_KIT_CLAUDE_CMD +CLAUDE_CONFIG_DIR SESSION_KIT_CODEX_HOME +CODEX_HOME SESSION_KIT_CODEX_DB SESSION_KIT_BOOT_ID_FILE SESSION_KIT_AUTO_NAME +SESSION_KIT_PICKER_EVENTS +SESSION_KIT_PICKER_PULSE +SESSION_KIT_CLAUDE_SOCKET SESSION_KIT_NO_COLOR SESSION_KIT_NONINTERACTIVE +SESSION_KIT_WATCHDOG_NOTIFY +SESSION_KIT_WATCHDOG_ALERT_TYPE +SESSION_KIT_WATCHDOG_MODE +SESSION_KIT_WATCHDOG_LOG +SESSION_KIT_WATCHDOG_REPAIRS +SESSION_KIT_WATCHDOG_BINARY_FINGERPRINT ``` -Other environment names in the source are internal test or release hooks and -are not a stable public interface. +`SESSION_KIT_ARCHIVE_DIR` and `SESSION_KIT_JOURNAL_RECOVERY_DIR` are two +different places, and both appear in the paths table above: +`shpool-archive` is what `sp find` searches, `shpool-journal-recovery` is what +exact recovery reads. + +### Operator tunables + +These change timing and thresholds. Every one has a working default; set them +only when the default is wrong for your machine. Values are what the code +reads today. + +| Name | Default | What it changes | +| --- | --- | --- | +| `SESSION_KIT_PICKER_REFRESH_SECONDS` | `5` | picker live-refresh interval | +| `SESSION_KIT_PICKER_GROUP` | `state` | key-driven picker only: grouping it starts in, `state`, `provider`, or `project` | +| `SESSION_KIT_PICKER_COMPACT` | `0` | key-driven picker only: `1` starts it with compact rows | +| `SESSION_KIT_PICKER_FILTER_LIVE` | `1` | key-driven picker only: `0` disables filter-as-you-type; `/text` still searches on Enter | +| `SESSION_KIT_PICKER_PULSE_SECONDS` | `1` | how often the attention-pulse child scans while the picker is open; an unreadable value falls back to the default | +| `SESSION_KIT_PICKER_EVENT_POLL_SECONDS` | `30` | the timed poll interval while the live event stream is carrying attention; never narrower than the base refresh | +| `SESSION_KIT_ATTENTION_SOURCE` | `auto` | Claude attention evidence: `auto` merges the poll with the newer hook record, `poll` ignores hook records, and `hook` is for drills. Any other value falls back to `auto` | +| `SESSION_KIT_DOCTOR_FORMAT_SECONDS` | `10` | wall-clock budget for `session-kit doctor`'s internal-format scan; `0` reports the scan as timed out | +| `SESSION_KIT_TUI` | off | managed interactive Bash only: `on` makes its `kit` shell function open the interactive screen; a `tui-on` file in the state directory does the same. Direct executables and other shells remain key-driven | +| `SESSION_KIT_TUI_CMD` | the installed interactive picker | the interactive screen used by that managed Bash launcher | +| `SESSION_KIT_PICKER_FALLBACK_CMD` | the installed key-driven picker | where the managed Bash launcher goes when the interactive screen ends abnormally | +| `SESSION_KIT_TUI_FALLBACK_LOG` | `<state>/tui-fallback.log` | where a fall back is recorded, one line each | +| `SESSION_KIT_MODELS_FILE` | `~/.config/session-kit/models.tsv` | the models the picker's Change model action offers: one `provider<TAB>model` line each | +| `SESSION_KIT_STALL_SECONDS` | `2700` | silence after which provider activity no longer supports describing a session as `working` (clamped 60–86400) | +| `<state>/session-idle-minutes` | `30` | minutes a session that would otherwise say `needs you` must keep the same transcript path, size, and nanosecond modification time before it becomes `idle`. The file contains one positive number. `0`, an invalid value, a symlink, or any unreadable file disables idling; the kit never falls back to a shorter window after a failed read. Removing the file restores the 30-minute default | +| `SESSION_KIT_AUTO_CLOSE_HOURS` | `72` | continuous quiet hours before the cleanup observer may close a provider-exited terminal. A BACKSTOP: a session whose provider exits closes itself at once, so a terminal that reaches this window is one the shell could not close | +| `SESSION_KIT_PRUNE_DAYS` | `7` | age at which the reaper prunes its own records | +| `SESSION_KIT_REAPER_DRY_RUN` | `0` | `1` makes the reaper report what it would close and close nothing | +| `SESSION_KIT_SUBAGENT_IDLE_MINUTES` | `15` | quiet minutes before the sweep closes a sub-agent worker or background shell (TERM, then KILL one pass later). Workers are judged by movement of their own output transcripts; a long call that writes nothing can therefore look finished. The independent background-shell pass watches fd/1 and the shell's own CPU counters. Any live descendant refuses closure, including a sleeping zero-CPU child. Before signaling, the pass pins and stops a childless shell, verifies the stopped state, rechecks identity, zero descendants, own CPU, terminal foreground ownership, and fd/1, then sends the signal and resumes it. CPU is never evidence for closing a worker. The sweep runs every five minutes on its own `session-kit-subagent-sweep.timer`, so closure lands within about twenty. `0` turns the sweep off. **An unreadable value turns the sweep off and says so**, it never guesses a window. **Raise this if a real worker is ever closed while it is still working.** Exported variables do NOT reach the timer (the systemd user manager inherits none of your shell environment), put the number in `<state>/subagent-sweep-minutes` instead, which the pass reads itself | +| `SESSION_KIT_SUBAGENT_IDLE_HOURS` | none | the older name, still honoured when the minutes name is unset, including `0` to disable; an unreadable value turns the sweep off. Linux only, the settled exact-PID delivery proof requires live procfs, so the sweep never runs on macOS. A reaper dry run previews sweep actions only after real passes have armed the idle clock, and touches nothing on disk | +| `SESSION_KIT_SUBAGENT_SWEEP` | on | `0`, `off`, `no`, or `false` turns the sub-agent sweep off; a `subagent-sweep-off` file in the state directory does the same | +| `SESSION_KIT_WATCHDOG_POLL_SECONDS` | `60` | watchdog pass interval | +| `SESSION_KIT_WATCHDOG_DEAD_SECONDS` | `2700` | quiet time before a terminal is treated as dead | +| `SESSION_KIT_WATCHDOG_FLAGGED_QUIET_SECONDS` | `120` | quiet time before an already-flagged terminal is acted on | +| `SESSION_KIT_WATCHDOG_QUIET_REPORT_COOLDOWN` | `21600` | minimum gap between repeat manager-level alerts (an unresponsive session manager, a replaced manager binary). It no longer governs per-session reports: a session gets one record per incident, and a second only when the session has produced output since the first or the watchdog has learned something stronger about it | +| `SESSION_KIT_WATCHDOG_MANAGER_TIMEOUT` | `20` | seconds the watchdog waits on a service-manager probe | +| `SESSION_KIT_WATCHDOG_RETIRE_DAYS` | `2` | age at which a watchdog record whose session no longer exists retires itself. A record is kept at any age while the session it names is live, while the REPLACEMENT it names is live (a repaired record points at the session still carrying the conversation), or while the published inventory still lists either, and nothing is retired at all unless the snapshot proved itself live. `0` turns retirement off | +| `SESSION_KIT_WATCHDOG_RETIRE` | on | `off` turns record retirement off | +| `SESSION_KIT_WATCHDOG_RETIRE_DRY_RUN` | `0` | `1` reports what retirement would remove in the watchdog log and writes nothing | +| `SESSION_KIT_ACCOUNT_ADVICE_MAX_AGE_SECONDS` | `600` | age past which account rotation advice is treated as stale | +| `SESSION_KIT_WORKTREE_ROOT` | `<state>/worktrees` | absolute directory holding materialized worktrees and their registry. Refused if it is under a system root, a working copy is never built where the machine keeps the software it runs | +| `SESSION_KIT_SHARED_REPOS` | unset | colon-separated absolute paths that are never copied: a delegated session runs in the checkout itself, and nothing is written into that repository. A `.session-kit-shared` file at a repository's root says the same thing, and travels with the checkout | +| `SESSION_KIT_COPYABLE_REPOS` | unset | colon-separated absolute paths that **are** yours to copy, overruling every reason the kit would otherwise refuse, the system-root rule included. Use it when a project genuinely lives under `/srv`, `/opt` or `/var` and is not the thing the machine is serving | +| `SESSION_KIT_CODEX_AUTOTITLE_BUDGET_SECONDS` | `2` | how long one pass of the Codex auto-titler may spend before the next inventory build; a backlog continues on the following pass (clamped above 0 and at 60) | +| `SESSION_KIT_ATTACH_HISTORY` | `500` | lines of the session's settled rendered journal replayed into the terminal's scrollback when a session is opened or reattached, so earlier output stays reachable by scrolling; `0` turns the refill off; a session without a journal replays nothing; an unreadable value falls back to the default | + +### Off switches + +Three behaviors can be turned off by an environment variable or by a file, so +they can be disabled for one command or for the machine. The file lives under +the Session Kit state directory. + +| Behavior | Variable | File | +| --- | --- | --- | +| Session prebake | `SESSION_KIT_NO_PREBAKE=1` | `<state>/prebake-off` | +| Self-heal on start | `SESSION_KIT_NO_SELF_HEAL=1` | `<state>/self-heal-off` | +| Sub-agent sweep | `SESSION_KIT_SUBAGENT_SWEEP=off` | `<state>/subagent-sweep-off` | +| Sub-agent sweep, and every other closing pass | none | `~/.no_shpool_reaper` | +| Sub-agent sweep window | `SESSION_KIT_SUBAGENT_IDLE_MINUTES` | `<state>/subagent-sweep-minutes` | + +`session-kit doctor` reports the sub-agent sweep as a state, on its own +`subagent-sweep` line: which window is in force and where it came from, which +switch is holding it off if one is, and, because a pass that never runs closes +nothing and says nothing, how long ago a pass last ran to completion. A sweep +that has gone quiet for more than twenty minutes is named there, whether the +cause is a held state-directory lock, a crashed pass, or a timer that is not +firing. + +Shortening the window restarts every clock. The sweep records the window it +judged by alongside its evidence, and an idle clock gathered under a wider rule +is not evidence under a narrower one, so the first pass after any narrowing, +including the upgrade that introduced the fifteen-minute default, closes +nothing, and every worker is watched afresh for a full window. Widening keeps +the existing clocks, because a wider window can only ever close less. + +The kit-owned tab name has its own switch, `SESSION_KIT_TAB_TITLE=off`, which +covers both providers; see [The terminal tab name](#the-terminal-tab-name). + +The picker's three live channels are on by default and each is switched off by +`0`, `off`, `no`, or `false` (any case, surrounding spaces ignored). When one is +off, `session-kit doctor` names it on the `kill-switches` line instead of +reporting an all-clear. + +| Channel | Variable | +| --- | --- | +| Push events into the picker | `SESSION_KIT_PICKER_EVENTS` | +| Attention-pulse watcher | `SESSION_KIT_PICKER_PULSE` | +| Claude in-session message delivery | `SESSION_KIT_CLAUDE_SOCKET` | + +The `$HOME` kill switches `.no_shpool_reaper`, `.no_shpool_watchdog`, and +`.no_shpool_journal` are listed under +[Optional feature sentinels](#optional-feature-sentinels) and audited by +`session-kit doctor`. + +A clean provider exit closes the managed session outright, on every machine, +there is no switch, and the retired `~/.sk_autoclose_on_clean_exit` marker is +no longer read anywhere. A non-zero exit is a crash and still stops at the +recovery menu with the terminal open. + +### Everything else + +`SESSION_KIT_TESTING` and the `SESSION_KIT_TEST_*` names are test seams, and +each one is read only when `SESSION_KIT_TESTING=1`; the same gate covers +`SESSION_KIT_PROVIDER_PRESENCE_OVERRIDE`. Setting any of them on a normal +installation changes nothing. The remaining `SESSION_KIT_*` names in the source are +internal: the installer and release engine pass them between their own +processes. They are not a stable public interface and are not listed here. diff --git a/docs/install.md b/docs/install.md index 95c1e30..521c65b 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,41 +1,283 @@ # Install Session Kit -Session Kit is a Linux-only `v0.1.0` beta. Install from a reviewed Git commit -under a single-user account where provider conversations are recoverable. +Session Kit `v0.4.3` is a public beta for Linux with systemd and macOS 14 or +newer. Install the accepted release artifact under a single-user account where +provider conversations are recoverable. + +The commands below name no version. `releases/latest` resolves to the current +beta, so they keep working across releases. Release assets are named by source +commit rather than by version, which is why the download needs a pattern and +every later step uses a glob. It is also why there is no fixed download URL: +`releases/latest/download/<name>` cannot work when the name changes with every +release. + +Two routes fetch the same four files. Use either. + +### Without the GitHub CLI + +Python 3 is already required, so this needs nothing that the install does not +need anyway. It asks the release API which files belong to the current release +and downloads each one. + +```bash +mkdir session-kit-download +cd session-kit-download + +python3 - <<'PY' +import json, urllib.request +url = "https://api.github.com/repos/dob323/session-kit/releases/latest" +with urllib.request.urlopen(url) as response: + release = json.load(response) +for asset in release["assets"]: + urllib.request.urlretrieve(asset["browser_download_url"], asset["name"]) + print("downloaded", asset["name"]) +PY +``` + +The GitHub API allows sixty unauthenticated requests an hour from one address, +which this uses one of. + +### With the GitHub CLI ```bash -git clone https://github.com/dob323/session-kit.git -cd session-kit +mkdir session-kit-download +cd session-kit-download +gh release download --repo dob323/session-kit --pattern 'session-kit-*' +``` + +### Then, either way + +```bash +if command -v sha256sum >/dev/null; then + sha256sum --check session-kit-*.sha256 +else + shasum -a 256 --check session-kit-*.sha256 +fi +tar -xzf session-kit-*.tar.gz +cd session-kit-*/ ./install.sh --check -tests/run ./install.sh session-kit doctor ``` -`./install.sh --check` is read-only. The installer copies files and systemd -user-unit definitions but does not start, stop, restart, or enable a service. +Verify the checksum before unpacking. The `.provenance.json` file beside the +archive records the source commit, the public tree hash, and that the artifact +rebuilds byte for byte; the `.chain.json` file records the release commit, the +tag it points at, and every check that passed before publication. + +The installer puts its commands in `$HOME/.local/bin`. On Debian and Ubuntu, +`~/.profile` adds that directory to `PATH` only if it already existed when you +logged in, so if you have never had one, `session-kit` is `command not found` +until you start a new login shell. + +That first `session-kit doctor` run then exits **1**, on a perfectly good +install, with these two lines: + +```text +FAIL watchdog installed but not enabled (disabled) … enable it with: session-kit services enable +FAIL units shpool.socket disabled/inactive; shpool-reaper.timer disabled/inactive … +``` + +That is the expected result at this point, not a broken install: the installer +writes the unit files and refreshes the user service manager, but it does not +enable shpool or the watchdog on a fresh install. It may enable only a newly +introduced timer that has no prior enabled/disabled policy. Doctor turns green after +[Activate safely](#activate-safely) below. Everything else doctor checks (files, permissions, provider readers, kill switches) is meaningful right now. + +On the first interactive install, Session Kit reads the project paths already +recorded by Claude Code and Codex, shows the existing directories it found, +and offers to import all of them. Discovery reads Claude's local project map +and history plus Codex's configured projects and stored conversation +directories. It does not walk the home directory or search the rest of the +filesystem. + +For unattended installation, project import stays off unless it is requested: + +```bash +./install.sh --non-interactive --import-projects +``` + +Use `--no-import-projects` to skip the interactive offer. The same discovery +can be reviewed or rerun later with `session-kit projects discover` and +`session-kit projects import`. An existing `projects.tsv` is never replaced by +an install or update. + +Without the GitHub CLI, download the `.tar.gz`, `.sha256`, and +`.provenance.json` assets from the +[latest release](https://github.com/dob323/session-kit/releases/latest), and +put them in one empty directory. The checksum file covers the archive. Use +`sha256sum --check` on Linux or +`shasum -a 256 --check` on macOS. The provenance file records the exact source +commit and public-tree digest. + +Cloning `main` is the development path, not the normal installation path. If +you use it, review and test the exact commit before installation. + +Coming from a hand-managed shpool setup, or from an unversioned private layout, +read [Migrate an older installation](migrations/legacy-install.md) first. There +is no migration tool, and the order of the steps is what keeps live sessions +and recovery state intact. + +`./install.sh --check` is read-only. The installer copies files and user-service +definitions. The real install performs the bounded service refresh described +under [What installation changes](#what-installation-changes); it never starts, +stops, or restarts shpool. ## Requirements -- Linux with `/proc` -- systemd user services -- Bash -- Python 3.10 or newer -- shpool 0.11.0 -- Claude Code, Codex, or both +- shpool 0.11.0, see [shpool](#shpool) for the two supported ways to get it, + including a route that needs no compiler on the target machine; +- Claude Code, Codex, or both, see [Provider CLIs](#provider-clis); +- a single trusted Unix account; +- enough local access to run per-user services. + +Linux additionally requires: + +- Linux with readable `/proc`; +- a systemd user manager; +- Bash 4 or newer; +- Python 3.10 or newer. + +macOS additionally requires: + +- macOS 14 or newer on Apple Silicon or Intel; +- an active desktop login so the per-user launchd GUI domain exists; +- Homebrew Bash 4 or newer; +- Python 3.11 or newer. + +The preflight checks the platform-specific tools it uses. Linux includes +`flock`, `journalctl`, and `systemctl`. macOS includes `launchctl`, `plutil`, +and `sw_vers`. A Git checkout also requires Git; a release artifact carries its +own source record. -The preflight also checks required command-line tools such as `flock`, `git`, -`journalctl`, `mktemp`, `pgrep`, `ps`, `script`, `sha256sum`, `stat`, and -`timeout`. +Other init systems, macOS before 14, and a missing process view stop before +installation. There is no preview switch that bypasses these checks. + +### Provider CLIs + +Session Kit manages provider conversations; it does not install or update the +providers. The preflight **fails** with `install Claude Code, Codex, or both` +until at least one provider command is on `PATH`, so install one first, from its +vendor's own instructions. Either provider alone is enough. The commands below are the vendors' documented ones, check the +linked page if a command fails, since these change. + +**Claude Code** ([install guide](https://code.claude.com/docs/en/setup)): + +```bash +curl -fsSL https://claude.ai/install.sh | bash # macOS, Linux, WSL +claude --version # confirm it runs +``` + +Anthropic also publishes `brew install --cask claude-code`, signed apt/dnf/apk +repositories, and `npm install -g @anthropic-ai/claude-code`. The native +installer keeps itself updated; the package-manager routes do not. Claude Code +requires a Pro, Max, Team, Enterprise, or Console account. + +**Codex** ([install guide](https://learn.chatgpt.com/docs/codex/cli)): + +```bash +curl -fsSL https://chatgpt.com/codex/install.sh | sh # macOS, Linux +codex --version # confirm it runs +``` -macOS, another init system, or a missing process view is unsupported and must -stop before installation. No preview switch changes that beta support policy. +OpenAI also publishes `brew install --cask codex` and +`npm install -g @openai/codex`. Sign in on the first `codex` run in a project. + +Sign in to each provider once before installing Session Kit. The picker lists +sessions it can prove; a provider that has never authenticated has none. + +### Linux prerequisites + +A minimal server image usually lacks several tools the preflight requires. On +Debian or Ubuntu: + +```bash +sudo apt-get update +sudo apt-get install -y python3 procps diffutils findutils util-linux \ + bsdutils coreutils curl git +``` + +On Fedora, RHEL, or a RHEL rebuild: + +```bash +sudo dnf install -y python3 procps-ng diffutils findutils util-linux \ + coreutils curl git +``` + +`procps` (`procps-ng` on the RPM side) supplies `ps` and `pgrep`, `diffutils` +supplies `cmp`, `bsdutils` supplies `script`, and `flock` comes from +`util-linux`. `git` is only needed for a checkout install, and is absent from +every minimal image tested. The preflight names anything still missing rather +than failing part-way through an install. + +shpool is not packaged by any distribution, so it has to be built. Building it +needs a Rust toolchain **and** a C compiler and linker, which the prerequisites +above do not include: + +```bash +sudo apt-get install -y build-essential # Debian or Ubuntu +sudo dnf install -y gcc # Fedora or RHEL +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH" +cargo install shpool --version 0.11.0 --locked +``` + +Without one the build stops at ``error: linker `cc` not found``. + +**No compiler on the target machine?** You do not need one. +[Getting a static shpool binary](../extras/build-static-binary.md) builds a +fully static musl binary for the build host's architecture inside a throwaway +container and copies it out; nothing is installed on the host, and the result +does not depend on the target's glibc version. + +Read the [shpool](#shpool) section below before running either route on a +machine you intend to rely on. + +#### Keep user services running after you log out + +Session Kit's services belong to your user's systemd manager, which by default +stops when your last session ends. On a headless machine reached only over SSH, +that means the watchdog and the cleanup timer stop the moment you disconnect, +and managed sessions lose their supervision: + +```bash +loginctl enable-linger "$USER" +loginctl show-user "$USER" --property=Linger +``` + +The second command must report `Linger=yes`. Enable this before +`session-kit services enable`, not after. + +### macOS prerequisites + +Install the supported toolchain if the commands are not already available: + +```bash +brew install bash python rust +export PATH="$HOME/.cargo/bin:$HOME/.local/bin:/opt/homebrew/bin:$PATH" +cargo install shpool --version 0.11.0 --locked +``` + +Session Kit records the resolved modern Bash path in the owner-controlled +shpool configuration. A conflicting `shell` setting is refused instead of +silently replaced. The system Apple Bash is not used for managed sessions. ## shpool -Use official shpool 0.11.0 by default. Session Kit includes an optional source -patch for one heartbeat acknowledgement timeout. It is not installed -automatically. Review [the patch guide](../shpool-patch/README.md). +Use official shpool 0.11.0 by default. Session Kit ships six optional source +patches against that release and installs none of them automatically. Review +[the patch guide](../shpool-patch/README.md) before deciding. + +Read about `0004` first. Stock 0.11.0 can deadlock on detach and make every +managed session unreachable at once, the daemon stays alive and accepting +connections while every list, attach, and detach blocks forever, and it does not +recover on its own. One stalled SSH window is enough to trigger it. The other +five patches address narrower problems. + +If you apply a patch, record the resulting binary's fingerprint as the last step +of the rebuild. The patch guide gives the exact command. Skipping it leaves the +watchdog reporting a changed binary on every pass for a change you made +deliberately, and `session-kit doctor` will tell you so. With a current Rust toolchain: @@ -43,8 +285,12 @@ With a current Rust toolchain: cargo install shpool --version 0.11.0 --locked ``` +Without one, use the container build in +[Getting a static shpool binary](../extras/build-static-binary.md). Both routes +produce the same 0.11.0 binary; the container route leaves no toolchain behind. + Replacing a running shpool binary normally requires a daemon restart, which -ends its managed terminal processes. The Session Kit installer never replaces +ends the session processes it holds. The Session Kit installer never replaces shpool or restarts its daemon. ## What installation changes @@ -56,28 +302,43 @@ The installer: 2. selects it through the `current` link; 3. installs stable commands under `$HOME/.local/bin`; 4. creates missing private configuration and state; -5. copies systemd user units without enabling them; -6. asks before adding the guarded Bash login block; -7. backs up a Bash startup file before changing it; -8. asks whether to enable terminal journals, defaulting to off; +5. writes systemd units or launchd templates and refreshes the service manager + without restarting shpool; +6. adds guarded shell integration, off with `--disable-login`; +7. backs up each startup file before changing it; +8. leaves session history recording off, on with `--journal on`; 9. leaves health notifications off. -It does not change provider storage, start a service, restart shpool, attach to -a session, or close a session. +On Linux, login integration changes `.bashrc`. On macOS, it changes `.bashrc` +and `.bash_profile` for managed Bash sessions and adds only the Session Kit +command path to `.zshrc`. A normal zsh SSH login remains a normal shell and the +picker opens only when you type `kit`. + +The installer does not change provider conversation storage, restart shpool, +attach to a session, or close a session. On Linux it reloads unit definitions, +may enable a newly introduced timer that has no recorded policy, and +`try-restart`s an already running watchdog. On macOS it kickstarts an already +loaded watchdog. A fresh watchdog and the shpool service remain off until +explicit activation. -Noninteractive installation leaves login integration and journals off unless -their explicit flags are supplied. +Apart from the documented interactive project-import offer, the installer +states what it chose and the command that changes it. `--enable-login`, +`--disable-login`, and `--journal on|off` decide those choices up front, and an +unattended install adds nothing to a shell it was not asked to touch. ## Optional components -- **SSH picker:** opens for an interactive Bash login after explicit enablement. +- **Bash integration:** adds the `kit` command and a short SSH login hint after + explicit enablement. SSH still opens a normal shell; the picker opens only + when you type `kit`. - **Terminal journals:** record terminal bytes for new managed sessions after explicit opt-in. - **Cleanup observer:** tracks exact eligible provider-exited terminals. It can close only after the timer is enabled and every safety predicate has remained unchanged for 72 continuous hours. - **Watchdog:** runs in report-only mode by default. The advanced repair mode is - an explicit opt-in that can close and relaunch a proved-broken terminal. + an explicit Linux-only opt-in that can close and relaunch a proved-broken + terminal. macOS supports report mode, not watchdog repair mode. - **Notification command:** remains disabled unless explicitly configured. ## Activate safely @@ -87,22 +348,48 @@ After installation: ```bash session-kit doctor session-kit enable-login +session-kit services enable +session-kit services status ``` -Review copied user units before enabling any timer or service. Activating a -Session Kit helper does not require a shpool restart. +`enable-login` is the compatibility name for enabling the Bash integration. It +does not make the picker open automatically. -Use a new test session first. Confirm that disconnecting SSH leaves it running, -a provider quit leaves the managed terminal alive, exact reopen works, and -`k <number>` requires confirmation. +Review the generated definitions before enabling them. On Linux, +`services enable` activates the shpool socket, the cleanup timer, and the +session watchdog; `session-kit doctor` fails while any of the three is +installed but not enabled. On macOS, it +loads the shpool, cleanup, and report-only watchdog LaunchAgents. macOS refuses +activation if another shpool daemon is reachable. Disabling macOS services +holds the creation lock and requires two empty-session proofs before unloading +shpool. -## Evaluate without installing +Activation does not require a reboot. Update and rollback do not restart a +loaded shpool daemon. -The repository test suite uses temporary fixtures and does not contact a live -shpool daemon: +## Display setup -```bash -tests/run -``` +Session Kit installs Claude's two-line status display, Codex's status-bar and +terminal-title integration, and the session color environment. See +[Display setup](usage.md#display-setup) for what each segment shows, the quota +extension point, title behavior, and terminal colors. Ghostty needs no special +display setting: its stock configuration supports the titles and colors the +kit emits. + +Use a new test session first. Confirm that disconnecting SSH leaves it +running, a provider quit leaves the conversation restorable, exact reopen +works, and marking numbers in the picker resolves each one to one exact +session under a frozen proof. + +## Evaluate without installing + +The full repository test suite is the Linux development test path and uses +temporary fixtures. From a reviewed source checkout, run its `tests/run` +script before installation. Do not run active-session commands merely to inspect the source. + +macOS CI runs its native adapter checks plus focused installer, export, release, +privacy, and documentation tests on Apple Silicon and Intel. Real acceptance +also requires a disposable shpool session because GitHub-hosted CI does not +load a user LaunchAgent or start provider TUIs. diff --git a/docs/maintainers/modularization-roadmap.md b/docs/maintainers/modularization-roadmap.md index 56e1c09..56e3f38 100644 --- a/docs/maintainers/modularization-roadmap.md +++ b/docs/maintainers/modularization-roadmap.md @@ -1,77 +1,78 @@ -# Inventory modularization roadmap - -`lib/session_inventory.py` remains the executable compatibility entry point -while implementation moves into `lib/sessionkit_inventory/`. - -The current facade still combines configuration, Linux process inspection, -provider discovery, inventory assembly, state, naming, recovery, rendering, and -CLI parsing. Moving one bounded responsibility at a time makes review and -testing easier without changing installed command paths. - -## Compatibility contract - -Through at least one public minor release: - -- `lib/session_inventory.py` stays executable and importable by path. -- Existing tested symbols, signatures, exit codes, JSON fields, and rendered - behavior remain available. -- Extracted modules never import the facade. -- Imports perform no process scans, locks, configuration reads, or state writes. -- Existing test patch points continue to work. -- A refactor never moves the installed release link or restarts a service. - -## Target package - -| Module | Responsibility | -| --- | --- | -| `common.py` | constants, paths, normalization, configuration, command runner | -| `state_io.py` | private reads, locks, atomic writes, JSON and checksums | -| `processes.py` | Linux process table, ancestry, generations, boot identity | -| `providers.py` | shpool, Claude Code, and Codex readers | -| `model.py` | session records, titles, reply and provider-exit state | -| `collector.py` | bounded read-only joins | -| `names.py` | manual and automatic names | -| `terminal.py` | terminal-number and generation state | -| `validation.py` | strict snapshot and input validation | -| `recovery.py` | exact recovery transactions | -| `reaper.py` | 72-hour eligibility state and final safety proof | -| `snapshot.py` | collection and private-state orchestration | -| `render.py` | width, semantic color, dashboard, detail, JSON, and lookup | -| `self_name.py` | caller proof and automatic naming | - -The dependency graph must stay acyclic. Recovery and cleanup accept explicit -callbacks rather than importing the facade. - -## Sequence - -1. Freeze facade behavior with symbol, signature, CLI, and patch-point tests. -2. Move pure common and configuration helpers. -3. Move private state I/O. -4. Move Linux process discovery. -5. Move provider readers. -6. Move the data model and collector. -7. Move validation and rendering separately. -8. Move naming and terminal-number state. -9. Move recovery and cleanup transactions. -10. Move snapshot and self-name orchestration. -11. Certify install, public export, rollback, and both private and public tests - twice. - -Do not combine file movement with a behavior change. - -## Checks for each step - -- compile the facade and package; -- run focused and full tests; -- import through normal import and direct file execution; -- verify the symbol and signature manifest; -- scan the public tree and reachable history; -- build the public export and prove package completeness; -- verify installer and doctor rejection of a partial package; -- compare frozen Linux fixtures; -- test unsafe modes, symlinks, interrupted writes, idempotency, and locks for - state-moving work. - -The split is complete when the facade contains compatibility wrappers, CLI -parsing, and `main`; no focused module imports it; export and install tools -enforce package completeness; and rollback passes from an immutable candidate. +# Inventory modularization contract + +`lib/session_inventory.py` is the executable and import-compatible facade for +the inventory. Focused implementation lives in `lib/sessionkit_inventory/`, +but the split is intentionally incomplete: the facade still contains CLI +parsing, compatibility wrappers, and implementation retained by older tests +and callers. + +This file records the boundary that future moves must preserve. The current +module inventory is in [Architecture](../architecture.md#inventory-package). + +## Compatibility rules + +- The facade remains executable and importable by path. +- Tested symbols, signatures, exit codes, JSON fields, and rendered behaviour + remain available until a release explicitly changes their contract. +- Package modules never import the facade. +- Imports perform no process scan, lock, configuration read, or state write. +- A move preserves every test patch point or updates the test to patch the + module that really resolves the name. +- Refactoring does not select a release, refresh a service, or mutate live + session state. + +## Patch-point ownership + +A facade wrapper can inject collaborators at call time. In that case patching +the facade name still reaches the implementation. A package function can also +import a sibling directly; then the resolving package module owns the patch +point, and changing the similarly named facade attribute does nothing. + +Use one of these proofs when writing a test: + +1. patch the attribute on the module whose function resolves it; or +2. patch the facade and demonstrate interception with `wraps=` or an observable + behaviour change. + +Never assume that a re-exported name is an interception point. That mistake is +silent: the test runs against the real dependency and can pass without testing +the intended case. + +Shared state helpers deserve particular care. Some modules accept locks, +paths, and publishers as injected arguments; others import the same helpers +locally. Inspect the call path before patching. Prefer call-time injection for +new state-moving code when it keeps recovery ordering visible and testable. + +Constants need the same proof. A differential test using the real constant +cannot show that a patch stopped reaching its consumer. Change the constant in +the test and observe the result. + +## Moving code safely + +A coherent extraction follows this sequence: + +1. freeze the symbol, signature, CLI, and patch-point behaviour; +2. move one focused responsibility without changing its output; +3. keep a facade wrapper where installed callers or tests import it; +4. prove normal import and direct file execution; +5. run focused tests for unsafe files, symlinks, interrupted writes, locks, and + idempotency where state is involved; +6. run the full suite and public-export checks; +7. verify install and rollback with the package present and with a deliberately + incomplete package refused. + +Do not combine a file move with a user-visible behaviour change. A mechanical +move should have one answer to the question “what changed?”: only where the +implementation lives. + +## Completion criterion + +The long-term split is complete only when the facade contains compatibility +wrappers, CLI parsing, and `main`, no focused implementation remains there, no +package module imports it, and install, export, doctor, and rollback all enforce +package completeness. + +That criterion is not met merely because a module with the right name exists. +The facade still has substantive implementation today, so future work should +describe each extracted responsibility rather than declaring the migration +finished as a whole. diff --git a/docs/maintainers/release-process.md b/docs/maintainers/release-process.md index 5fd8d38..f0323b2 100644 --- a/docs/maintainers/release-process.md +++ b/docs/maintainers/release-process.md @@ -1,124 +1,121 @@ # Maintainer release process -This is the public release checklist, not an installation guide. +This is the public release checklist, not an installation guide. A release is +one reviewed commit carried unchanged through export, artifact creation, tag, +and release assets. ## Version and support policy -The first release is `v0.1.0` with beta maturity. Follow Semantic Versioning -and never move or reuse a tag. +The release documented by this tree is `v0.4.3` and remains beta software. +Follow Semantic Versioning and never move or reuse a tag. -Before `1.0.0`, document incompatible command, configuration, or state changes -in the changelog. Until the first tag, only current `main` receives fixes. Each -beta minor line is supported for 90 days after its first release or for 30 days -after the next beta minor release, whichever ends later. Security fixes may -require upgrading to the latest patch. +Before `1.0.0`, put every incompatible command, configuration, or private-state +change in the changelog. Each beta minor line is supported for 90 days after +its first release or for 30 days after the next beta minor release, whichever +ends later. A security fix may require upgrading to the latest patch. ## Release gates -1. The public tree and reachable Git history pass secret and private-data scans. -2. No private account names, paths, hostnames, UUIDs, transcripts, incident - timestamps, credentials, or internal services remain. -3. License, notices, security policy, changelog, support policy, and behavior - documentation agree. -4. Ubuntu 22.04 and 24.04 pass on Python 3.10 through 3.13. -5. Bash syntax, ShellCheck, Ruff, Python syntax, type baseline, and branch - coverage pass. -6. Local documentation links pass. -7. The optional shpool patch applies to its pinned upstream tag and builds. -8. A clean disposable Linux account passes preflight, install, doctor, picker, - provider exit, exact reopen, kill confirmation, update, rollback, and - uninstall checks. -9. macOS and other unsupported platforms fail closed. -10. Journals and notifications default off. -11. Normal logs contain no prompt, response, terminal, credential, or journal - content. -12. Automatic cleanup begins only after its timer is enabled and then requires - 72 continuous hours with every exact predicate unchanged. -13. The public export includes every runtime, test, license, and documentation - file declared by the reviewed manifest. -14. The release archive is reproducible and its checksum and provenance record - match the candidate commit. -15. The public repository has no earlier or conflicting tag that changes the - declared first-release version. Private-source tags are not copied into the - public history. +1. Scan the public tree and its reachable history for secrets and private data. + No private account names, hostnames, UUIDs, transcripts, incident details, + credentials, or internal services may remain. +2. Make the README, changelog, notices, security policy, voice contract, and + behavior documentation agree with the shipped code. +3. Run Bash syntax and ShellCheck, Python syntax and type checks, Ruff, branch + coverage, documentation links, manifest coverage, and public-export tests. +4. Test Ubuntu 22.04 and 24.04 with supported Python versions. Run native macOS + CI on Apple Silicon and Intel, and keep CI evidence distinct from real-device + acceptance. +5. Apply all selected shpool patches to the pinned upstream commit, run its + workspace tests, build it, and record the patch set, toolchain, target, host, + command, and checksum. +6. On clean disposable accounts, test preflight, install, doctor, both picker + screens, provider exit and exact reopen, close safety, display integration, + update, rollback, and uninstall. +7. Confirm unsupported platforms and unsafe evidence fail closed. Journals, + notifications, automatic repair, and automatic close must remain off by + default. +8. Confirm ordinary logs contain no prompt, response, terminal, credential, or + journal content. Automatic cleanup must still require 72 continuous hours + with every exact safety predicate unchanged after its timer is enabled. +9. Confirm the export contains every file in the reviewed manifest, the release + archive is reproducible, and the checksum and provenance record name the + candidate commit. +10. Confirm every reader-facing version reference names the release being + published. `tools/publish-release` checks this across `README.md` and every + Markdown file under `docs/`, nested directories included; the changelog may + retain older release headings. ## Build a candidate -Use a fresh clone at one exact commit: - -```bash -git clone https://github.com/dob323/session-kit.git session-kit-candidate -cd session-kit-candidate -git checkout <full-candidate-commit> -test -z "$(git status --short)" -./install.sh --check -tests/run -tools/check-doc-links -tools/public-scan . --git-history -``` - -The private source history may contain private operational context, so that -command scans it for credentials without treating known private project names -as a release failure. After the exported tree is committed in the public -repository, run the strict gate there: - -```bash -tools/public-scan . --git-history --private-markers -``` - -Do not publish a history that fails the strict gate. Rewriting a branch does -not remove data from existing clones; investigate any credential match and -rotate the credential before publication. - -Build the reviewed public tree and release artifact outside the source: - -```bash -tools/build-public-tree \ - --commit <full-candidate-commit> \ - --destination ../session-kit-public -tools/build-release-artifact \ - --commit <full-candidate-commit> \ - --output-dir ../session-kit-artifacts -``` - -Rebuild the artifact in another empty directory and compare the archive, +Use a clean clone at one full commit and keep that commit fixed. Run the +repository's preflight and complete test suite, documentation-link checker, and +private-data scanner. Those source-maintainer programs live under `tools/` and +are intentionally absent from an installed machine. + +Build the public tree into an empty directory outside the source worktree, then +run the strict private-marker scan against the exported tree and its history. +Build the release artifact from the same candidate commit into another empty +directory. Repeat the artifact build elsewhere and compare the archive, checksum, and provenance bytes. -Release metadata schema 1 is the pre-package layout. Schema 2 adds the package -marker and common helpers. Schema 3 also requires lifecycle, provider, and -private-state modules. New builds emit schema 3; verification retains the -bounded older schemas for installed rollback targets. +The release manifest uses `schema_version` 1. The lifecycle metadata uses +`lifecycle_schema_version` 2. Verification may understand older installed +rollback targets, but a new release must emit those current values; do not +describe internal package-layout generations as public schema versions. ## Acceptance -The installer must not start or restart services. Record process and daemon -generations before and after each acceptance step. +Install, update, and rollback perform a bounded service refresh. On Linux they +reload user-unit definitions, may enable only a newly introduced timer with no +prior enablement policy, and `try-restart` an already running watchdog. On +macOS they kickstart an already loaded watchdog. They never restart shpool or +the reaper. Record the selected release, service definitions, process +generations, and daemon generation before and after each lifecycle step. Test at least: -- fresh login and normal terminal fallback; -- Claude Code and Codex reply alerts; -- semantic color and no-color output; -- IDs hidden on normal rows and present in detail, JSON, and explicit search; -- provider exit leaving the managed terminal alive; -- exact reopen without latest-directory fallback; -- same-session move between two SSH windows; -- `k <number>` exact-ID confirmation and cached-state refusal; -- 72-hour cleanup boundary after timer enablement, reset conditions, and - retained provider history; -- update and rollback with detached sessions; +- a normal shell login and an on-demand `kit` launch; +- the key-driven picker and the managed-Bash TUI opt-in and fallback; +- Claude Code and Codex attention, status, titles, semantic colors, and + no-color output; +- IDs hidden on ordinary rows and available only in detail, JSON, and explicit + search; +- provider exit leaving exact recoverable history, followed by one exact + reopen without a latest-directory fallback; +- moving the same session between two terminals; +- key-driven `k`, list, range, and `all` targeting, plus TUI typed marks and + the default Close action, all under frozen proof and cached-state refusal; +- the 72-hour cleanup boundary, every reset condition, and retained provider + history; +- update and rollback while sessions stay detached and recoverable; - uninstall retaining private data. -## Publish - -After every gate passes: +For macOS, also test on a release-listed device with macOS 14 or newer, Python +3.11 or newer, Homebrew Bash 4 or newer, and official shpool 0.11.0. Exercise +native process generations, rapid PID churn, stable boot identity, inventory, +both providers, SSH disconnect and reconnect, explicit service enable and safe +disable, watchdog report mode, repair-mode refusal, and lifecycle changes with +live sessions. Release notes must name the exact OS, architecture, provider, +Python, Bash, and shpool versions used, and say which evidence came only from +CI. -1. add the release date to `CHANGELOG.md`; -2. confirm `main` points to the accepted commit; -3. create one annotated tag; -4. push the branch and tag; -5. publish matching notes, archive, checksum, and provenance file; -6. verify links and downloads from a logged-out view. +## Publish -Repository visibility and GitHub security settings are separate administrative -changes. Perform them only after the final repository and history audit. +Add the date to the `0.4.0` changelog section and commit it before publishing. +The source repository's release publisher is the only supported publication +path; it is a maintainer tool and is intentionally omitted from the public +export and installed tree. + +First rehearse against throwaway clones. The rehearsal must export the chosen +commit, create the public commit and tag, build the artifact from that same +commit, and prove tag, archive, and export agree byte for byte. Then run the +same publication once for real, either printing the push and GitHub-release +commands for review or using its guarded push mode, never both. + +Verify the generated chain record and inspect links and downloads from a +logged-out view. The publisher must refuse a dirty tree, unreachable commit, +existing tag, version drift, scan failure, or override during guarded push. Fix +the refusal; do not fall back to manual tagging. Repository visibility and +GitHub security settings remain separate administrative changes after the +final repository and history audit. diff --git a/docs/migrations/legacy-install.md b/docs/migrations/legacy-install.md index ea76989..f3a22f8 100644 --- a/docs/migrations/legacy-install.md +++ b/docs/migrations/legacy-install.md @@ -1,66 +1,118 @@ # Migrate an older installation -An older hand-managed shpool setup may contain live sessions, provider -identities, shell startup code, service units, aliases, journals, and launch -records. Do not copy files into a current installation while those inputs are -live. +This page is a procedure, not a command. Session Kit ships no migration tool for +a hand-managed shpool setup, and the public beta promises no automatic migration +from an unversioned private layout. What follows is the sequence that keeps live +sessions, provider identities, and recovery state intact while you move to a +released installation. -The public beta does not promise an automatic migration from unversioned -private layouts. A safe migration starts with a new dedicated account or uses a -reviewed plan for one exact source layout. +The safest migration is not a migration. A new dedicated account gets a clean +install with nothing to convert, and the old account can be retired at leisure +once its sessions are closed. Convert in place only when you have a reviewed +plan for one exact source layout. -## Required evidence +What makes an in-place migration dangerous is that the source state is live +while you copy it. A hand-managed setup can hold running sessions, provider +identities, shell startup code, service units, aliases, journals, and paired +launch records, and every one of those can change under a copy. Do not copy +files into a current installation while any writer is still running. + +Session Kit will not overwrite what it finds. `install.sh` writes +`projects.tsv`, `inventory.json`, and `~/.config/shpool/config.toml` only when +they are absent, and on macOS it refuses a shpool `shell` setting that conflicts +with the validated modern Bash rather than rewriting it. That protects an +existing setup from a careless install, and it also means a fresh install alone +converts nothing: whatever the old layout holds is still the old layout's. + +## Capture the evidence first Before changing anything, collect a private backup of: -- the installed helper commit or exact file set; -- Bash and shpool configuration that may change; -- relevant systemd user units; -- one frozen shpool list; +- the installed helper commit, or the exact file set if there is no commit; +- the Bash and shpool configuration that the migration may change; +- the relevant systemd user units or LaunchAgents; +- one frozen `shpool list --json` capture; - one exact provider identity inventory; - file checksums, modes, owners, and types; - a completion marker tied to the backup manifest. -Verify the backup in an isolated directory. It may contain UUIDs, paths, and -terminal data and must never enter the public repository. - -## Safe sequence - -1. Disable automatic picker, cleanup, watchdog, and new journals. -2. Verify the backup. -3. Build and verify the target immutable release. -4. Dry-run stable launcher installation. -5. Convert host configuration without changing active sessions. -6. Replace shell integration with one guarded source block. -7. Convert names under the private configuration lock. -8. Plan recovery-state conversion from frozen and current identity evidence. -9. Review each affected conversation before applying the plan. -10. Take a strict live inventory and compare generations. -11. Publish the target integration marker only after checks pass. -12. Verify listing and a new login without moving, killing, repairing, or +Verify the backup in an isolated directory rather than in place. It can contain +UUIDs, private paths, and terminal data, so treat it as private data and never +let it reach the public repository. + +## The sequence + +1. Stop every writer of the mutable source state, the automatic picker, + cleanup, watchdog, new journals, and anything else that can touch it, and + verify they are stopped before you copy a byte. +2. Capture a second frozen shpool list and provider inventory. Stop if the + daemon generation or the managed-session set moved since the first capture: + the source was not frozen, so the first capture describes a system that no + longer exists. +3. Verify the backup. +4. Run the read-only preflight against the target source, `./install.sh + --check`. It validates the platform, the required commands, the source + provenance, and the lifecycle roots without writing anything, and it refuses + a Git worktree with uncommitted or untracked files. +5. Install the target release with `./install.sh --non-interactive + --no-import-projects`. It creates the immutable release, selects it through + `current`, installs the stable launchers, and writes service definitions. On + Linux it reloads the user manager and may enable a newly introduced kit timer + when systemd has no prior decision for it; on macOS a first install leaves + LaunchAgents unloaded. An already-running watchdog is refreshed on either + platform. The shpool daemon is never restarted by this step. Non-interactive + installation leaves shell integration and journals off unless their flags + are supplied. +6. Copy the mutable configuration and state once, from the frozen source. + Verify the copied bytes, checksums, file types, modes, and owners before + anything reads them as live. +7. Convert the host configuration without touching active sessions. +8. Replace the old shell integration with one guarded source block. +9. Convert names under the private configuration lock, + `${XDG_STATE_HOME:-$HOME/.local/state}/session-kit/config.lock`, which is the + lock the kit's own name and color writers hold. +10. Plan the recovery-state conversion, `recovery-manifest.json` and + `recovery-pending.json`, from the frozen and current identity evidence. +11. Review each affected conversation before applying that plan. +12. Take a strict live inventory and compare generations. +13. Publish the target integration marker and start the target services, now, + and only after every copy, conversion, and verification check has passed. +14. Verify listing and a fresh login without moving, killing, repairing, or recovering an active session. -13. Keep the rollback material until an independent rollback check passes. +15. Keep the rollback material until an independent rollback check passes. + +Never copy or synchronize mutable state over a target whose Session Kit services +are already running. If a target writer started too early, stop it with the +required operational approval, freeze the source again, and repeat the copy and +verification from the beginning. A later overwrite is not equivalent evidence: +it proves the final bytes, not that nothing read the intermediate ones. -Stop on a changed daemon generation, ambiguous provider identity, unsafe file, -unexpected state shape, missing backup, or configuration mismatch. +Stop on a changed daemon generation, an ambiguous provider identity, an unsafe +file, an unexpected state shape, a missing backup, or a configuration mismatch. ## Names and recovery -If old and current title maps contain the same UUID, define one precedence rule -before writing. Preserve unrelated keys and keep the old bytes in the private -backup. +If the old and current title maps hold the same UUID, decide one precedence rule +before writing either. Preserve unrelated keys and keep the old bytes in the +private backup. -Recovery conversion must separate plan from apply. Bind the plan to the exact -old bytes, target commit, daemon generation, and current provider identities. -Apply under the inventory lock and publish a private receipt. +Recovery conversion separates plan from apply. Bind the plan to the exact old +bytes, the target commit, the daemon generation, and the current provider +identities; apply it under the inventory lock; publish a private receipt. -Never let an older release interpret a newer paired launch-record format. Keep -both records together and return to a compatible release instead of deleting -one side. +Launch records come in pairs. Each managed start writes `<shpool-id>` and +`<shpool-id>.expected` into the launch-record directory, +`~/.local/state/shpool-start/` by default, and rollback validation reads both: a +mismatched pair, a duplicate record, an unsafe file type, or a directory that is +not owner-only mode `0700` blocks the rollback outright. +Deleting one side of a pair to quiet a complaint removes the evidence and keeps +the block. Keep both records together and return to a release that understands +their format instead. ## Completion -Migration is complete only when the selected release and integration marker -agree, strict inventory has no unexplained warning, current sessions retain -identity and attachment state, a fresh login works, rollback checks pass, and -all private archives remain verified. +Migration is complete when the selected release and the integration marker +agree, a strict inventory carries no unexplained warning, current sessions +retain their identity and attachment state, a fresh login works, the rollback +checks pass, and every private archive still verifies. Until all six hold, the +old installation is still the one you can fall back to, keep it. diff --git a/docs/picker-navigation.md b/docs/picker-navigation.md new file mode 100644 index 0000000..c4257fe --- /dev/null +++ b/docs/picker-navigation.md @@ -0,0 +1,149 @@ +# Picker navigation + +Type `kit` from an ordinary shell to open Session Kit's session picker. The +home screen keeps managed sessions, new-session choices, projects, closed +sessions, and help in one place. + +## Reading the list + +Sessions that are ready in this window appear before sessions open elsewhere. +Within each group the order is `question`, `needs you`, `working`, then `idle`, +followed by provider, newest activity, and stable session identity. Machine +sessions sit behind one counted row. + +A session row protects the facts that matter most as the terminal narrows: +state, a nonzero subagent count, and last activity. The time may shorten to +`3h` or `now`, then disappear at the narrowest width. Metadata and long names +give up space first. + +| State | Meaning | +| --- | --- | +| `question` | Claude has a blocking prompt open now. Codex does not claim this state yet. | +| `needs you` | The provider has finished its turn and is waiting for you. | +| `working` | The provider is driving the current turn. | +| `idle` | A needs-you transcript has not moved for the configured window. | + +`pending` is the placeholder for a value the kit cannot read. It is not a +fifth state. The idle window is 30 minutes by default; an unreadable +`<state>/session-idle-minutes` file disables the `idle` label instead of +guessing. + +The model cell shows the conversation's current model when the provider record +supplies one, so it follows an in-session model change. A shell has no model. +Any model or account value the kit cannot read says `pending`. + +## Enter and back + +Enter takes the most likely choice throughout the picker: + +- at home, it opens the top session; an empty session list opens New session; +- New session starts with Claude Code selected; +- a session open elsewhere defaults to Move it here; +- a closed conversation defaults to Restore. + +`b` backs out of a secondary screen. At the home screen, back means leave. +Escape first clears typed input, then closes the current panel, returns from a +secondary view, and finally leaves when nothing remains to clear or back out +of. `q` leaves from an unfiltered home screen, and `Ctrl-D` leaves from +anywhere. + +Guards run before the action, and the next line says what happened. A refusal +changes nothing. + +## The key-driven picker + +The home footer begins `↵ open <number>` or `↵ new`. As space narrows, footer +segments survive in this priority: Enter, number selection, kill, new, more, +needs you, help, history, leave. + +The main keys are: + +| Input | What it does | +| --- | --- | +| Enter | open the first visible session, or start New session when none is listed | +| `<number>` | open that session, or show Move it here when it is open elsewhere | +| `k <numbers>` | close one or more visible sessions; lists and ranges work | +| `h <number>` | show history without opening the session | +| `n` | start a new session | +| `m` | open the More screen | +| `a` | show sessions that need you | +| `?` | show picker help | +| `b`, `q` | back or leave | + +A close keeps the number you typed while the picker refreshes, so the result +and the next list still refer to the same selection. Searches match names, +providers, accounts, models, and projects; they do not promise to match a +translated state word. + +## The cursor-driven picker + +The cursor-driven screen adds arrows, Page Up, Page Down, Home, End, marks, +live filtering, and mouse input. Digits create marks; commas and inclusive +ranges such as `1,4,7-9` select a set. Enter on a marked set opens the action +panel with Close selected. + +Letters filter the list as they are typed. Escape clears that input before it +backs out. The wheel scrolls. One click highlights a row and a second click +opens it; clicking the mark column toggles its mark. The footer's Enter hint is +clickable, but the other footer text is a key reference rather than a row of +buttons. Hold the terminal's selection modifier, commonly Shift, when copying +text while mouse reporting is active. + +## Session actions + +A regular session can offer: + +| Action | What it does | +| --- | --- | +| Open | open a session ready in this window | +| Move it here | disconnect the other window and open the session here | +| History | read settled session history | +| Close | close the session and retain its recoverable conversation | +| Change account | move the exact conversation to another enrolled account | +| Change model | resume the exact conversation on a configured model | +| Rename | set the session name | +| Color | choose from the provider's palette | + +A marked set offers only actions safe for every target; today that means +Close. A closed Claude or Codex conversation offers Restore first. A closed +shell has no provider conversation to restore, so it offers History only. + +Change model reads `~/.config/session-kit/models.tsv`, one +`provider<TAB>model` entry per line. An empty file produces an empty panel; the +kit never invents a model name. + +## Machine sessions + +Drills, workers, and automation-created sessions are machine sessions. They +stay behind one counted row until expanded. The row also says when one of them +needs you. + +Creation records the origin. `sp new --origin machine` declares automation; +`--origin human` declares a person. A new session started from inside another +managed session defaults to machine origin, while one started by the picker +defaults to human origin. Restore and repair preserve the recorded origin. + +## Closed sessions + +Closing records the provider, exact conversation, title, directory, and close +order in the private closed-session ledger. The picker deliberately shows +`login time unknown` rather than presenting the close timestamp as a login +time. Restore brings an exact Claude or Codex conversation back; shell rows +retain history only. + +If the ledger exceeds the normal safety ceiling, the picker refuses the +ordinary path and names the bounded recovery command. No confirmation prompt +is needed because a successful close is recoverable from this list. + +## Refresh and release changes + +The list refreshes in the background. Search, page, grouping, compact mode, and +the jump marker survive an action. A picker that notices a newly selected +release reopens through the current launcher at a safe point and carries its +view forward. If the new target is degraded, the existing picker remains in +place. + +The two picker implementations have separate layout engines but share the +inventory, state vocabulary, session order, and proof-bound actions. See +[Troubleshooting](troubleshooting.md#which-picker-kit-opens) for selection +and fallback details. diff --git a/docs/pinned-internal-formats.md b/docs/pinned-internal-formats.md new file mode 100644 index 0000000..eaece4d --- /dev/null +++ b/docs/pinned-internal-formats.md @@ -0,0 +1,168 @@ +# Vendor formats this kit depends on + +Both vendors document these files as internal and free to change between +releases. The kit reads five file types and writes three of them, because the +interfaces that would replace those accesses either do not exist yet or cannot +be reached from a stdlib-only Python process. + +That dependency is not the danger by itself. The danger is how it fails: a +format that changes shape says nothing. The reader finds no matching record, +returns its empty answer, and a title, a colour or a whole history quietly stops +working while every command still exits 0. So this page is the inventory, +every place the kit touches a vendor internal, the exact assumption it makes, +and what a person sees when that assumption stops being true. + +`session-kit doctor` reads one live example of each and reports the row +`internal-formats`, naming every format and every field it could not confirm: + +* **fail** when an example is present and a field a feature cannot work without + has gone, at that point something a person depends on is already broken; +* **warn** when the file is still readable and a field named below as degrading + rather than breaking has gone (`statusUpdatedAt`, `messagingSocketPath`, + `thread_name`), or when no readable fixture is available, an unchecked + format is not known to be healthy; +* **ok** only when all five were found and every field was there. + +A file that is still being written, a transcript created a second ago, a line +cut off mid-append, is not a moved format, and never produces the fail. The +scan carries a wall-clock budget (`SESSION_KIT_DOCTOR_FORMAT_SECONDS`, default +10) and bounded traversal, so a slow or enormous home ends the check instead of +hanging doctor. + +`tests/test_pinned_formats.py` fails when a kit file starts reading or writing +one of these paths without being listed here, so the inventory cannot go stale +in silence. + +--- + +## Claude Code + +### 1. The per-process session record, READ and WRITE + +`~/.claude/sessions/<pid>.json` (per account profile, so also +`$CLAUDE_CONFIG_DIR/sessions/<pid>.json`). + +| assumption | read by | +|---|---| +| a JSON object, one file per live process, named `<pid>.json` | `providers_claude._enrich_claude_payload`, `sessionkit_messages.claude_socket.find_target` | +| `sessionId` is the conversation uuid | both | +| `nameSource` says where the current window name came from | `providers_claude._enrich_claude_payload` | +| `name` is the title shown by Claude's own session picker | `providers_claude` (read), `names_push` (write) | +| `statusUpdatedAt` is Claude's own millisecond stamp for `status` | `providers_claude._enrich_claude_payload` → `attention.merge` | +| `messagingSocketPath` is the session's inbox socket | `claude_socket.find_target` | +| the file's mtime moves when the session's status transitions | `pulse.watched_paths`, stat only, parses nothing | + +The reader recognises `bridgeSessionId, cwd, entrypoint, kind, +messagingSocketPath, name, peerProtocol, pid, procStart, sessionId, startedAt, +status, statusUpdatedAt, updatedAt, version`. + +**Sanctioned alternative:** `claude agents --json` is the documented command and +the kit already runs it, but it returns seven fields only (`pid, cwd, kind, +startedAt, sessionId, name, status`), none of the four above. There is nothing +to migrate to today. + +**When it breaks:** name provenance is lost (the kit stops distinguishing a +derived window label from a person's rename); the attention merge loses its +tie-breaker, so a hook record can no longer be aged out by a newer poll and a +record that says "needs you" stays authoritative until the hook replaces it; +socket delivery reports `no-socket-path` and falls back to the spawned sender; +and the pulse stops seeing status transitions, so the picker learns about a +waiting session on the timed poll instead of within a second. Nothing crashes; a +person sees stale titles, later attention and slower delivery. + +### 2. The transcript JSONL, READ and WRITE + +`~/.claude/projects/<slug>/<uuid>.jsonl`. + +| assumption | used by | +|---|---| +| one JSON object per line, each with a `type` | every reader below | +| `{"type":"ai-title","aiTitle":…,"sessionId":…}` carries the conversation's auto-title | `providers_claude.read_claude_transcript_signals` (read), `self_name` (write) | +| `{"type":"agent-name","agentName":…,"sessionId":…}` carries a kit-assigned name | `providers_claude` (read), `names_push` (write) | +| `{"type":"agent-color","agentColor":…,"sessionId":…}` carries the session colour | `providers_claude` (read), `names_push` (write) | +| the file is append-only and safe to append to | `names_push`, `self_name` | +| `sp history` can render the whole conversation from it | `transcript_text` | + +**Sanctioned alternatives, and why they are not in yet:** + +* the **hook `transcript_path`** field is sanctioned, and + `config/claude/nameintent_title.sh` already uses exactly that, it never + guesses a path. That reader is already on a supported interface; +* `/export` is interactive only; +* the TypeScript SDK's `getSessionMessages` / `renameSession` / `tagSession` + would replace the reads and the name write, but reaching them needs a Node + process, the kit is stdlib-only Python by design, so this is a decision, not + a patch; +* the messaging socket's rename control frame is available and + `sessionkit_messages.claude_socket.rename()` implements it. It renames a + RUNNING session through the vendor's own channel instead of appending an + `agent-name` record to its transcript. Migrating the name push onto it is a + possible alternative to the transcript write, but it changes the live + delivery and fallback contract and therefore requires its own tested change. + +**When it breaks:** `sp history` for Claude renders nothing (the Codex fallback +in `transcript_text` does not apply to a Claude conversation), auto-titles and +colours stop being recovered, and the name push appends records nothing reads. + +### 3. The messaging auth key, READ + +`~/.claude/sessions/<pid>.<sha256 of the resolved socket path>.key`, mode 0600, +`{"peerToken": "<32 hex>", "procStart": "…"}`. + +Read by `claude_socket.read_peer_token`. The digest is over the **resolved** +socket path, `/run/user/<uid>/cc-socks/<pid>.sock`, rather than the spelling +of an unresolved link. + +**Sanctioned alternative:** none; the token is what the protocol requires and +the file is where the vendor publishes it. + +**When it breaks:** delivery reports `no-auth-key` and never sends blind, so the +caller falls back to the spawned sender. This is the failure mode this format +was designed to have. + +--- + +## Codex + +### 4. Rollout JSONL, READ + +`~/.codex/sessions/**/rollout-*.jsonl`. + +Read by `providers_codex` (turn state, bounded tail reads by descriptor) and +`transcript_text.render_rollout` (history). Assumption: JSON lines, each with a +`type` naming the event and a `payload` object under it. `pulse.watched_paths` +also depends on this path, stat only, because a Codex session waiting for an +approval appends a rollout record and raises no other signal. + +**Sanctioned alternative:** the Codex **app-server** JSON-RPC exposes +conversation state, and the kit already speaks it for steering. Migrating the +history and turn-state reads onto it is real work with a live dependency on the +app server being up, it belongs to the app-server items, not to this page. + +**When it breaks:** Codex history renders empty and turn state falls back to +silence-only evidence, which is exactly the Codex-side blindness the ledger +already records. + +### 5. Session index, READ and WRITE + +`~/.codex/session_index.jsonl`, read by `names`, `names_push` and +`providers_codex` to map a conversation id to its rollout, and stat-ed by +`pulse.watched_paths`. It is JSONL, with each entry carrying the conversation +`id` and `thread_name`. `names_push` appends a new exact-id row when it mirrors +or applies a title; the last row for an id wins. + +**Sanctioned alternative:** the app-server's conversation listing covers the +same lookup, with the same live dependency as the rollout reads above. + +**When it breaks:** a Codex session cannot be matched to its history, so titles +and history lookups miss. + +--- + +## The rule for anything new + +A new reader or writer of a vendor internal file is added to the table above in +the same change that adds the code, with: the exact path, the exact fields, the +sanctioned alternative (or "none today"), and what a person sees when the +assumption fails. `tests/test_pinned_formats.py` enforces the first half of +that; the rest is the point. diff --git a/docs/projects.md b/docs/projects.md new file mode 100644 index 0000000..6079ba8 --- /dev/null +++ b/docs/projects.md @@ -0,0 +1,150 @@ +# Projects + +A project is a directory. Everything Session Kit knows about your work, +the shortcut you launch it by, the settings it launches with, and the sessions +running in it, hangs off that one directory. + +- [What a project is](#what-a-project-is) +- [The project manifest](#the-project-manifest) +- [Why a manifest has to be trusted first](#why-a-manifest-has-to-be-trusted-first) +- [Startup commands](#startup-commands) +- [Worktrees](#worktrees) +- [Reading a project from the command line](#reading-a-project-from-the-command-line) + +## What a project is + +A project's identity is its **canonical absolute root directory**. Two things +can point at that root: + +- a row in `projects.tsv`, the host's own shortcut list, one entry per + directory, carrying an alias and at most a *default* provider. The default is + never a lock: which provider opens the directory is chosen when a session + starts, so one directory stays one project however many providers you work in + it with. See [Project aliases](configuration.md#project-aliases). +- a `session-kit.toml` committed at the root of the repository itself. + +Anything with a working directory belongs to the project whose root is the +**deepest** one at or above that directory. That one rule places a live +session and the directory you are standing in, so the picker and `sp new` never +disagree about what is in a project. + +A directory with neither a manifest above it nor a shortcut row above it is in +no project. Its sessions are still listed; they are simply not grouped. + +A shortcut always launches its own directory. If you add a shortcut for a +subdirectory of a project, sessions there are still grouped under the project, +but the launch uses that shortcut's own settings, not the manifest further up. +A manifest governs launches for its own directory. + +## The project manifest + +`session-kit.toml` lives at the root of your repository and is committed with +it, so the setup travels with a clone instead of living on one machine: + +```toml +# session-kit.toml +name = "demo-api" +description = "Demo API service" +provider = "codex" +account = "work" +model = "gpt-5-1-codex" +``` + +| Key | Meaning | +|---|---| +| `name` | Short name shown wherever the project is named. Lowercase letters, numbers, `_`, `-`. | +| `root` | Optional. The project root relative to the manifest. Must stay inside the manifest's own directory; defaults to `.`. | +| `description` | Optional one-line description. | +| `provider` | `claude`, `codex`, or `shell`. | +| `account` | An account alias already enrolled on the host. | +| `model` | The model identifier to launch with. | +| `startup` | A command to run after launch. See [Startup commands](#startup-commands). | + +Session Kit reads a documented subset of TOML, single-line strings, integers, +booleans, and single-line arrays, with the same reader on +every supported Python, so a manifest cannot mean one thing on Python 3.13 and +another on 3.10. Anything outside the subset is refused with its line number +rather than half-applied. Check a manifest before committing it: + +```text +session-kit projects check . +``` + +A manifest that fails to parse never changes a launch, and the project it +describes still resolves, the problem is reported, and the sessions running +there stay grouped where they belong. + +## Why a manifest has to be trusted first + +A manifest is repository content. Whoever can push to a repository can write +it, and cloning a repository is not a decision to let it choose what runs on +your machine. So a manifest's launch settings apply only for a project **on +this host's project list**: + +```text +session-kit projects add demo /absolute/path/to/demo +``` + +Until then the manifest is read, shown, and reported, you can see exactly what +the repository proposes, but the provider, account, model, and startup command +are not applied. Adding the project is the deliberate act that turns them on. A +[worktree](#worktrees) of a listed repository inherits that decision. + +## Startup commands + +`startup` is a command line arriving from a repository, so it is approved once +per project, by its exact text: + +- an unapproved command is shown, never run; +- approving records the command's digest for your account only; +- editing the command in the repository withdraws the approval, and it must be + approved again; +- a non-interactive launch, a script, never approves + anything. It starts without the startup command and says so. + +Review and approve one from the command line: + +```text +session-kit projects launch-plan <alias> # what would start, and why +session-kit projects approve-startup <alias> # approve the exact command text +``` + +`sp new <alias>` says which of the two states it found, `not approved here, so +it was not run`, or `changed since it was approved, so it was not run`, and +names the file the command lives in. The approval records the command's digest +for your account only; nothing about it travels with the repository. + +## Worktrees + +A linked git worktree contains the same committed manifest as the repository it +was cut from, so it resolves as its own root with the same settings. Session +Kit reads the worktree's `.git` pointer file to find the main repository and +groups them together: one project, several working copies, rather than several +unrelated projects that happen to share a name. + +## Reading a project from the command line + +Every verb prints JSON and is safe to run at any time; none of them change a +session. `approve-startup` is the one that writes: it records an approval for +your account, and nothing else. + +```text +session-kit projects resolve # the project you are standing in +session-kit projects resolve /some/path +session-kit projects list # every project this host knows +session-kit projects launch-plan demo # what `sp new demo` would start, and why +session-kit projects context demo # sessions in the project +session-kit projects check . # validate a session-kit.toml +session-kit projects approve-startup demo # approve this project's startup command +``` + +`launch-plan` reports a `decisions` map naming the source of every applied +value, `flag`, `manifest`, `shortcut`, or `default`, so a session that +differs from what you typed can always be explained. + +`context` answers "where did I leave this?": the live sessions in the project +and its worktrees. When a store cannot be read it is named in `unavailable`, so +an empty list never quietly means "nothing is happening". + +Exit codes: `0` an answer, `1` no project covers the target, `2` bad arguments, +`3` a malformed manifest. diff --git a/docs/provider-integration.md b/docs/provider-integration.md index 4a62f54..af3f116 100644 --- a/docs/provider-integration.md +++ b/docs/provider-integration.md @@ -1,23 +1,61 @@ # Claude Code and Codex integration -Session Kit can manage Claude Code, Codex, or both in one shpool inventory. +Session Kit manages Claude Code, Codex, or both from one local session +inventory. The providers continue to own credentials and conversation data; +the kit supplies isolation, identity proof, names, colours, and guarded launch +and resume paths. + +## Account isolation + +Each enrolled Claude account has its own `CLAUDE_CONFIG_DIR`. Each enrolled +Codex account has its own `CODEX_HOME`. Session Kit selects the configured root +when it launches or resumes a provider and records only the local alias and +verified account description needed to show the choice. + +It does not copy credentials between profiles, put tokens in its account +registry, or print them in picker and detail output. + +Enrollment writes each Claude profile's settings with +`autoContinueAtUsageLimit` off: in a managed profile the account layer is the +only thing that decides when a reset usage window starts being spent again. +Turn it back on per profile in Claude's `/config` if you want a limited +session to resume by itself. + +## Project directory names + +Claude Code 2.1.234 added `CLAUDE_CODE_PROJECT_DIR_NAME`: with +`CLAUDE_CONFIG_DIR` set, it names the per-project directory under +`<config>/projects/` instead of deriving a munged path from the working +directory. When a session launches at the registered root of a project +shortcut, the kit exports the shortcut's alias as that name, so transcripts +and auto memory live under a short, stable directory (`projects/api/` rather +than `projects/-home-me-projects-api/`). + +The export happens only with every proof in hand: a launcher of 2.1.234 or +newer, exactly one shortcut registered for that root, an alias that passes +Claude's own naming rules, and no directory conflict. An existing munged +directory is renamed to the alias in one atomic move first, and only while +no other session of that profile is running inside the root, because a +half-migrated project would hide its own memory. Anything unprovable leaves +the launch on the munged-name behaviour it has today; `session-kit doctor` +reports the naming state per profile. Set `SESSION_KIT_PROJECT_DIR_NAME=off` +to disable the feature entirely. ## Exact provider identity -Claude Code identity comes from structured Claude agent state joined to the -exact Linux process tree. +Claude identity comes from structured agent state joined to the exact native +process tree. Codex identity comes from the native Codex process and its +structured conversation record: an open rollout on Linux, and the process's +`CODEX_THREAD_ID` matched to one owner-controlled rollout on macOS. -Codex identity comes from the native Codex root process and its open CLI -rollout, joined to local metadata when available. - -Working directory, title, timestamp, and terminal output are never enough to -choose a conversation. A missing, duplicated, or malformed provider identity -is shown as unavailable and cannot be mutated. +Directory, title, timestamp, and terminal text are display context. A missing, +duplicated, changed, or malformed provider identity produces `pending` and +blocks mutation. A visible terminal is not enough proof to choose a +conversation. ## Task names -A provider's user-level instructions may ask a new managed root conversation -to assign a short title: +Provider instructions may ask a new managed root conversation to name itself: ```text At the first substantive request in a new Session Kit-managed root @@ -29,69 +67,124 @@ If exact identity is not ready, retry once on the next root turn. Do not run this command from a child agent. ``` -Review the current provider documentation before changing its instruction -files. +`sp self-name` accepts a two-to-five-word Title Case name and proves the +managed root identity before and after writing it. A manual name always wins +and permanently blocks an automatic replacement. Set +`SESSION_KIT_AUTO_NAME=0` to stop new automatic names without deleting any +already retained. + +Claude can claim a name at the first title-hook event. Codex must wait until +its first turn creates a conversation identity. A durable claim prevents a +second hook, later inventory pass, or restart from renaming the same +conversation again. + +`session-kit doctor` checks provider instructions for `sp self-name`. For +Claude it also checks the installed naming hook and its `SessionStart`, +`UserPromptSubmit`, and `Stop` registrations. + +## Titles and provider chrome + +Claude stores its generated title separately from the name shown in its prompt +box. Before a human-facing inventory, Session Kit fills an absent native name +from the retained title and leaves an explicit `/rename` unchanged. A running +Claude window may not repaint immediately; `sp detail` then says the title +`waits for the session to restart` instead of adding another state to the +picker row. -`sp self-name` accepts a 2–5 word Title Case name and verifies the managed root -identity before and after the write. Manual names take priority. Set -`SESSION_KIT_AUTO_NAME=0` to stop new automatic naming without deleting retained -titles. +Codex receives its terminal-title items and Session Kit theme as per-launch +configuration. The kit does not edit `~/.codex/config.toml`. A new Codex +process may begin before its conversation title exists; the stored title is +applied through the guarded resume path once exact identity is available. -## Reply state +Claude's supported colour names are red, blue, green, yellow, purple, orange, +pink, and cyan. Codex loads the kit's separate theme files. Keeping the +palettes disjoint makes the colour an additional provider cue. See +[Display setup](usage.md#display-setup) for the installed files and terminal +behaviour. -Session Kit marks `needs your reply` only when structured provider state proves -that a question is unresolved. +## State evidence + +Session Kit derives screen state from structured evidence rather than prose or +punctuation. For Codex: -- unresolved `request_user_input` without `autoResolutionMs` needs a reply; +- an unresolved `request_user_input` without `autoResolutionMs` means + `needs you`; - a picker with `autoResolutionMs` is optional; -- the matching tool output resolves the question; -- a new task supersedes an unanswered picker from the earlier task; -- completed or aborted tasks do not keep an alert. +- matching tool output resolves the request; +- a new task supersedes an unanswered picker from an earlier task; +- completed or aborted tasks do not keep an attention state. -The classifier does not search prose for question marks. An incomplete or -malformed event window produces an unavailable state instead of a guess. -Claude Code reply state comes from its structured agent inventory. +Codex remains `needs you` because its currently read app-server records do not +prove that a picker or approval is open at this instant. -## Provider exit +Claude `question` requires an unmatched top-level `AskUserQuestion`, or an +unresolved top-level tool use correlated by timestamp with the current +permission-prompt hook. Sidechains are excluded. Other structured Claude +attention is `needs you`. A state word the mapping does not understand becomes +`pending`, never raw provider copy. -Claude Code or Codex runs as a child of the managed terminal shell. A normal -provider exit returns to that shell and records an exited-provider state. It -does not destroy the shpool terminal. +A needs-you session becomes `idle` only after its transcript path, size, and +nanosecond modification time remain unchanged for the configured idle window. +This evidence is provider-neutral and is unrelated to a vendor's own `idle` +notification. + +## Provider exit -Opening the provider-exited terminal presents four choices: +Claude and Codex run as children of the managed session shell. A clean provider +exit means the person is finished: the shell records the recoverable +conversation, closes the session, and returns its number to quarantine. Closed +sessions offers Restore. -- reopen the exact conversation; -- mark the terminal to keep; -- open an ordinary shell, which permanently excludes that terminal from - automatic cleanup; -- close the terminal. +A crash is different. The shell reopens the exact conversation once and says +so. A second crash within a minute stops the loop. The session closes only when +the conversation is proved recoverable; otherwise it remains open, returns to +the picker when possible, and states why. There is no four-choice exit menu. -The dashboard must not describe an exited provider as actively running. +The provider command `/kit` is the deliberate way to leave a healthy +conversation running and return to the picker. `/exit` closes it. ## Resume and fork -Exact resume forms are: +The provider forms carried by the guarded launch path are: ```text -claude --resume <exact-uuid> +claude [--name <stored-title>] --resume <exact-uuid> codex --no-alt-screen resume <exact-uuid> -``` - -Independent forks use: - -```text claude --resume <exact-source-uuid> --fork-session codex --no-alt-screen fork <exact-source-uuid> ``` -Session Kit verifies both the source and resulting provider UUID. It refuses an -ambiguous, already-active, or changed identity. +Session Kit verifies the source identity and the resulting provider UUID. An +ambiguous, already-active, or changed identity is refused. + +## Changing an account + +An account change resumes the same conversation; it is not a fork. Session Kit +retains its provider UUID, history, title, project, colour, and boot-scoped +session number. Before stopping anything, it proves that the exact provider +generation has no active turn, tool, hook, subagent, child agent, or background +work, then checks again under the action lock. + +The target profile must be enrolled, verified, healthy, and signed in. The kit +checkpoints the exact conversation data, resumes the same UUID under the target +profile, and records the new alias only after resulting identity is proved. A +failed resume restores the checkpoint and attempts the original profile; an +unproved rollback fails closed. + +Older terminals created before account-aware launch records may need a one-time +managed-shell recreation. That migration is explicit and retains the exact +conversation and display identity. + +Automatic account switching is opt-in and still bounded to a verified, +idle-enough conversation and a configured reserve. It never enables an account +or silently changes models. ## Compatibility -Provider local formats and command interfaces can change. Each beta release -must publish the exact Claude Code and Codex versions used in install, reply, -exit, resume, recovery, and fork tests. Versions outside that release evidence -are best-effort until verified. +Provider local formats and commands can change. Each release records the +provider versions, operating system, and architecture used in its acceptance +checks. Combinations outside that evidence are best-effort until verified. +The private-format assumptions and their visible failure modes are listed in +[Vendor formats](pinned-internal-formats.md). diff --git a/docs/security-and-data.md b/docs/security-and-data.md index 5e224a2..931fdf9 100644 --- a/docs/security-and-data.md +++ b/docs/security-and-data.md @@ -2,9 +2,17 @@ ## Local operation -Session Kit has no hosted service, account, analytics, update beacon, or -telemetry. It reads local shpool state, Linux process metadata, provider -metadata, configuration, and its own state. +Session Kit has no hosted service, Session Kit cloud account, analytics, update +beacon, or telemetry. It reads local shpool state, provider metadata, +configuration, and its own state. Process evidence comes from Linux `/proc` or +from native macOS process, start-time, and boot metadata. + +Optional project discovery reads only project paths already stored in Claude +Code's local project map and history, and in Codex's local configuration and +conversation database. It keeps only existing directories, ignores configured +temporary roots, and does not inspect directory contents or crawl the +filesystem. Import writes only Session Kit's owner-only `projects.tsv` and its +local backup. External notifications are optional and off by default. @@ -14,37 +22,62 @@ Session Kit is designed for one cooperative Unix account. Before a mutation it checks file ownership and mode, process start times, daemon and terminal generations, provider ancestry, and exact conversation UUIDs. -These checks prevent stale or ambiguous dashboard state from selecting a -different session. They do not isolate mutually hostile processes running as -the same Unix user. +Those checks exist to stop stale or ambiguous picker state from selecting a +different session than you meant. They do not isolate mutually hostile processes +running as the same Unix user, and no amount of checking would: a process with +your privileges can read owner-accessible terminal state and edit owner-writable +files directly. + +One Unix account may contain several isolated provider subscription profiles. +This separation prevents accidental cross-account launches; it is not a security +boundary against another process running as that Unix user. ## Data stored locally Private state may include: -- shpool IDs and boot-scoped terminal numbers; +- shpool IDs and boot-scoped session numbers; - provider conversation UUIDs; -- titles and working directories; +- titles, session color overrides, and working directories; - process IDs, start times, and generations; -- recovery records and release receipts; +- recovery records, install receipts, and release receipts; +- imported project aliases, provider names, and working directories; +- provider account aliases, email descriptions, verification state, and + provider-supplied plan, health, quota, or reset descriptions when available; +- service-definition receipts and launch records; +- the expected shpool binary fingerprint; - cleanup eligibility and health events; - short-lived action proofs; -- optional terminal journals. +- shell startup-file and transaction backups; +- watchdog, reaper, and macOS LaunchAgent logs; +- optional session history. -The picker-action log is privacy-minimal. Each record contains only schema -version, a fixed action label, a fixed outcome label, and time. It contains no +The picker-action log is privacy-minimal. Each record holds only a schema +version, a fixed action label, a fixed outcome label, and a time. It contains no session ID, UUID, title, path, prompt, response, terminal output, IP address, or -credential. Each append removes entries older than seven days and caps the file -at 1,000 records and 256 KiB. +credential. Each append drops entries older than seven days and caps the file at +1,000 records and 256 KiB. + +Provider authentication remains in each provider-owned `CLAUDE_CONFIG_DIR` or +`CODEX_HOME`. Session Kit never copies or links tokens between profiles and never +stores, prints, or logs token values in its registry, picker, detail output, or +action log. Account email and status are private local metadata and should not be +shared in public diagnostics. -Internal IDs are hidden from normal dashboard rows. They remain available in -owner-only detail and JSON output and through an explicit search because exact -identity is required for diagnosis and confirmation. +Internal IDs are hidden from the picker. They stay available in +owner-only detail and JSON output, and through an explicit search, because exact +identity is what makes diagnosis and action proof possible. -## Terminal journals +## Logs and session history -Journals are off by default. When enabled, they can contain every byte shown by -the terminal, including: +Watchdog, reaper, and macOS LaunchAgent stdout and stderr logs are local +operational evidence. They can contain process details, private paths, or error +text. Session Kit creates its macOS log files with mode `0600`, but launchd does +not give Session Kit a verified retention or rotation policy, so review and +manage their size and retention as private data. + +Terminal journals are off by default. When enabled they can contain every byte +the terminal displayed, including: - credentials and tokens; - source code and private prompts; @@ -52,51 +85,146 @@ the terminal, including: - personal or regulated data; - terminal control sequences. -Keep state and journal directories mode `0700` and files mode `0600`. Define a -backup and retention policy before opting in. Never attach a journal to a -public issue. +Keep state and journal directories mode `0700` and files mode `0600`. Decide a +backup and retention policy before opting in. Never attach a journal or a local +service log to a public issue. -Uninstall retains existing journals unless the user performs a separate, -reviewed removal. The cleanup observer does not delete, truncate, compress, or -archive journals. +Uninstall retains existing state, logs, journals, and archives unless you +perform a separate, reviewed removal. The cleanup observer does not delete, +truncate, compress, or archive journals. ## Mutating actions -Open, move, kill, repair, recovery, fork, and cleanup actions use fresh live +Open, move, close, repair, recovery, fork, and cleanup actions use fresh live evidence. A stale, duplicate, partial, malformed, or unsafe target is refused. -`k <number>` resolves the visible number to one exact shpool ID, displays the -title, provider, and exact ID, and asks for confirmation. Cached inventory -disables the action. +Changing a running conversation's subscription account is also a mutating +action. It requires an explicit confirmation, the exact provider UUID and +generation, no active turn, tool, hook, child, or background provider work, and +a freshly verified target account. A conversation waiting for the operator may +switch because its provider is idle. The new alias is committed only after the +exact conversation resumes successfully; a failed change restores its private +checkpoint and original binding before an exact resume is attempted on the +original profile. A local kill switch blocks account selection and account +changes without removing profile data. The switch is the owner-only +`$XDG_STATE_HOME/session-kit/account-switching-off` state sentinel, defaulting +to `~/.local/state/session-kit/account-switching-off`; its presence also blocks +enrollment and does not stop an already-running provider. + +An automatic account change carries the same proofs. It substitutes the +operator's confirmation for a decision made from published account facts, a +fresh usage feed, the account's own weekly number, a target still above the +reserve, and a per-conversation count of previous automatic moves, and it +takes every one of the manual path's safety proofs unchanged, twice, around the +same action lock. It also checks that the account the live process is signed in +to is the account found dry. If the provider was launched with an explicit +model, the handoff carries the same generation-bound model record and launch +key; it never resumes that conversation on an invented default. + +It never enables an account. Re-verifying a target profile re-reads it under +the registry lock and refuses if it was disabled while the provider was being +asked to identify itself, so a disable made mid-handoff stands. + +A successful move keeps the shell, the window and the exact conversation UUID: +the in-place handoff is the only path it uses. A move that does not prove +itself is rolled back without the picker's recreate tail, that tail closes the +session and re-creates it, which is not a conversation left working, and where +the rollback itself cannot be proven, nothing further is changed and the +refusal says so. + +One owner-only file records all of it: `account-auto-hops.json` in the state +directory holds, per conversation, the count of automatic moves, one entry per +move, and the notices the operator is still owed. A move is counted when it is +reserved rather than when it completes, and a notice is cleared only by a +delivery that succeeded. It carries account aliases and timestamps, never +conversation content. The kill switch above stops automatic changes as well as +manual ones, and nothing in Session Kit creates that file. + +In the key-driven picker, `k` accepts visible numbers, comma-separated lists, +ranges, or `all` for the unchanged page on screen. The interactive picker marks +the same visible numbers by typing digits, commas, and ranges, then opens an +action panel whose default is **Close**. Both surfaces validate the display +numbers before starting and create a separate frozen proof for each selected +session. The proof is revalidated under the action lock immediately before the +close, so there is no additional confirmation prompt and normal screens never +print internal IDs. A refusal leaves that item unchanged, but it does not undo +an earlier close in the same batch. Cached inventory disables the action. ## Automatic cleanup Automatic close is limited to disconnected provider-exited terminals. The -cleanup timer must be enabled, and a target must then remain in the same exact +cleanup timer must be enabled, and a target must then hold the same exact eligible state for 72 continuous hours. Every close rechecks the exact daemon, terminal generation, exited provider -identity, attachment state, child processes, reply state, and recovery -conflicts. Changed or missing evidence resets or blocks eligibility. Journals -and provider-native conversations are retained. +identity, attachment state, child processes, and recovery conflicts +twice against fresh evidence. Changed or missing evidence resets or blocks +eligibility. Journals and provider-native conversations are retained. + +The reaper supports the guarded 72-hour close and manual prune on Linux and +macOS. Temporary picker-file expiry and stale immutable-release garbage +collection are currently Linux-only maintenance tasks. ## Watchdog -The installed watchdog defaults to report-only mode. It records local evidence -such as a manager timeout, direct handoff failure, terminal thread mismatch, or -changed running binary. It does not attach, detach, move, kill, restart, or -repair in that mode. +The installed watchdog defaults to report-only mode on Linux and macOS. It +records local evidence such as a manager timeout, a direct handoff failure, a +terminal thread mismatch, or a changed running binary. In that mode it does not +open, detach, move, close, restart, or repair anything. + +**It raises no alert anywhere until you configure a notifier.** The watchdog +detects and logs either way, but with `SESSION_KIT_WATCHDOG_NOTIFY` unset the +only record is the owner-only watchdog log, and nobody is told. See +[Watchdog alerts](configuration.md#watchdog-alerts). + +### The shpool binary fingerprint + +The watchdog compares the running daemon's executable against a recorded +fingerprint, because shpool is usually installed with Cargo and a routine +reinstall silently replaces a patched build with the stock one. Without this +check, sessions simply start failing again with nothing to explain why. -An advanced `SESSION_KIT_WATCHDOG_MODE=repair` opt-in can close a terminal with -direct proof of an unrecoverable handoff failure and relaunch the exact provider -conversation. This can lose terminal-only state and depends on exact -provider-native recovery. Enable it only after reviewing owner-only evidence -and testing manual repair. The installer never opts in. +Two states make the check useless rather than merely absent, and both are quiet: + +- **No fingerprint recorded.** The check is skipped entirely, so nothing breaks, + and nothing is watched either. +- **A stale fingerprint.** After you legitimately rebuild shpool, the recorded + value no longer matches, and the watchdog reports a changed binary every time + it looks. A check that is always complaining is one you stop reading, which + costs you the real event later. + +Record the fingerprint again as the last step of any deliberate shpool rebuild. +The [shpool patch guide](../shpool-patch/README.md) has the exact command, and +`session-kit doctor` reports whether the recorded value is missing or no longer +matches the running daemon. + +### Repair mode + +Advanced `SESSION_KIT_WATCHDOG_MODE=repair` is Linux-only. macOS refuses repair +mode outright, because it lacks the Linux daemon-thread evidence that action +depends on. On Linux, repair mode can close a terminal only with direct proof of +an unrecoverable handoff failure, and can relaunch the exact provider +conversation. This can lose terminal-only state. The installer never opts in. Quiet output alone is not failure evidence. +## Service activation + +Install, update, and rollback copy service definitions and refresh safe +kit-owned processes. On Linux they reload the systemd user manager, enable a +new kit timer only when systemd has no prior enablement decision for it, and +try-restart an already-running watchdog. On macOS they kickstart an already +loaded watchdog. They do not restart the shpool daemon or socket, because that +would end managed sessions; a service that cannot be refreshed keeps its prior +code and the command prints the remedy. + +On macOS, `session-kit services enable` installs and loads the generated +LaunchAgent definitions, and `session-kit services disable` refuses to unload +the shpool job unless two fresh checks prove no sessions remain. + ## Backups -Installer and update operations create and verify a private backup before -replacing host files. Backups may contain private paths, UUIDs, and -configuration. Store them as private data and never commit them. +Install, update, rollback, project import, and login-integration operations +create private backups before replacing host files. Backups may contain shell +startup content, private paths, UUIDs, configuration, or earlier service +definitions. Store them as private data and never commit them. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 892d055..c27315f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3,26 +3,132 @@ Start with read-only checks: ```text +session-kit doctor sp health sp list ``` Before sharing output, remove titles, UUIDs, working directories, process -details, and journal content. +details, service logs, and journal content. + +`session-kit doctor` also audits provider version output, all fourteen +installed Codex theme files, the user-level self-name instructions, provider +title integration, active kill switches, internal provider formats, journal +growth, and the private `release-acceptance.json` record. It never alters +provider state. Most migration findings are warnings, but an internal format +that is known to be incompatible is a failure because provider actions would +be unsafe. + +These checks report the running installation rather than the files on disk, so +they can fail an installation that installed cleanly: + +| Check | Reports | Fix it with | +| --- | --- | --- | +| `shpool-version` | the installed shpool against the 0.11.0 release the kit is tested and patched against | `cargo install shpool --version 0.11.0 --locked` | +| `watchdog` | whether `session-kit-watchdog.service` is enabled and running, not only installed | `session-kit services enable` | +| `units` | the enabled and active state of each Session Kit unit; the socket-activated and timer-activated units are reported without being required | `session-kit services enable` | +| `units-loaded` | whether the service manager has loaded the definitions currently installed on disk | run `session-kit update` or `session-kit rollback` again; if the named definition is still pending, apply it at a safe service restart | +| `release-running` | whether the running watchdog uses the selected release | run `session-kit update` or `session-kit rollback` again to refresh the watchdog | +| `linger` | whether logind keeps your user manager alive after logout | `loginctl enable-linger "$USER"` | +| `tab-title` | whether Claude and Codex have the installed title integrations | rerun `session-kit update`, then inspect the named provider file before forcing an unrelated setting | +| `journals` | owner-private recording size and whether recording is on | review the reported directory; the kit measures it but does not age active recordings automatically | +| `transcripts` | whether every provider session in the last recorded inventory snapshot still resolves to a transcript on this machine, searching the provider default root, a configured `CLAUDE_CONFIG_DIR`, and every account profile the kit created | name the flagged session and read its logs; a session that has not written a turn yet resolves on its next one | + +`watchdog`, `units`, and `linger` read live state only when the systemd user +manager answers on its own socket. When it is reachable only through the +local-machine transport, doctor reports them as unchecked instead of guessing. +Kill-switch findings print supported variable or sentinel names only, never +their values. + +The owner-only acceptance record uses this exact schema. Provider values are +the exact recognized `--version` output, or `null` when that provider is not +installed. Evidence values are short local references or notes, not secrets. + +```json +{ + "schema_version": 1, + "release_id": "40-character-release-commit", + "platform": "linux", + "provider_versions": { + "claude": "2.1.221 (Claude Code)", + "codex": "codex-cli 0.145.0" + }, + "accepted_on": "2026-08-04", + "evidence": { + "unique_colors": "local acceptance step or evidence reference", + "thread_titles": "local acceptance step or evidence reference", + "resume_roundtrip": "local acceptance step or evidence reference" + } +} +``` + +Doctor accepts the record only when its release, platform, installed provider +versions, non-future ISO date, and all three evidence fields match the current +installation. + +## `kit` does not open -## Picker does not open +The picker is opened on demand with `kit`. Login integration adds the +commands and managed Bash integration; it does not open the picker +automatically. Confirm that: -- the login shell is interactive Bash; +- `$HOME/.local/bin/kit` is executable and on `PATH`; - standard input and output are terminals; -- `$HOME/.no_shpool` is absent; -- `$HOME/.local/bin/shpool_login` is executable; -- the guarded Session Kit block exists in the active Bash startup file. +- the guarded Session Kit block exists in the relevant shell startup file; +- `session-kit doctor` reports the current release and helper launchers. + +On Linux, login integration manages `.bashrc`. On macOS, it manages `.bashrc` +and `.bash_profile` for Bash and adds only the command path to `.zshrc`. It does +not replace zsh or make the Bash picker behavior automatic in zsh. + +`session-kit disable-login` removes the managed startup-file integration +without uninstalling the commands. + +## Which picker `kit` opens + +The installed `kit` executable opens the key-driven picker. In an interactive +Bash shell with Session Kit's managed integration loaded, `kit` is a shell +function that goes through the screen launcher. Only that Bash function honors +`SESSION_KIT_TUI=on` or a `tui-on` file in the state directory. Directly +running `~/.local/bin/kit`, using zsh, or using another shell opens the +key-driven picker. + +For managed Bash, turn the interactive screen on or off with: + +```bash +touch ~/.local/state/session-kit/tui-on # turn the interactive screen on +rm ~/.local/state/session-kit/tui-on # back to the key-driven picker +``` + +Where it is on, it still has an automatic fallback: if it ends any way other +than you leaving it deliberately, it cannot draw on this terminal, the Python +it needs is missing, or it fails mid-screen, that `kit` invocation is handed +to the key-driven picker. Login integration opens a normal shell; it does not +open either picker automatically. + +Every fall back is recorded, one line each: + +```bash +cat ~/.local/state/session-kit/tui-fallback.log +``` + +Each line is the time, the exit status the screen ended with, and the program +that ended. Nothing else changes: the same sessions, the same commands, the +same actions, driven with the older screen's letter keys. + +To point the managed Bash launcher at a different program for either screen, +export the override before running `kit`: -Noninteractive shells skip the picker. Press Enter in the picker for a normal -terminal. `session-kit disable-login` removes automatic picker startup without -removing Session Kit commands. +```bash +export SESSION_KIT_PICKER_FALLBACK_CMD=~/.local/bin/shpool_login +export SESSION_KIT_TUI_CMD=~/.local/bin/shpool_login_tui +export SESSION_KIT_TUI_FALLBACK_LOG=~/somewhere/else.log +``` + +An empty log after using the interactive screen means no fallback was recorded +during those invocations. ## Color is hard to read @@ -35,31 +141,94 @@ export SESSION_KIT_NO_COLOR=1 `NO_COLOR` is also supported. The text labels and symbols carry the full meaning without color. +## Terminal titles or colors are missing + +Run `session-kit doctor` and read its `tab-title` result. It distinguishes a +disabled title switch, a missing or malformed Codex title template, unsupported +Codex title items, a missing thread-title item, and Claude title integration +that is absent or disabled. Rerun the installer for missing kit-owned files; +do not overwrite an unrelated provider setting without reviewing it. + +The picker uses terminal color when available and remains complete in plain +text. `NO_COLOR` or `SESSION_KIT_NO_COLOR` disables color intentionally. +Ghostty supports the kit's titles and colors in its stock configuration; no +Ghostty display override is required. See [Display setup](usage.md#display-setup) +for the Claude status line, Codex status bar, terminal titles, and session +colors. + ## Inventory is unavailable or cached Live actions require: - a responding shpool daemon; - valid `shpool list --json`; -- readable Linux `/proc`; -- Python 3.10 or newer; +- readable Linux `/proc` or native macOS process evidence; +- Python 3.10 or newer on Linux, or Python 3.11 or newer on macOS; - exact provider metadata for provider-specific actions. -Cached inventory is display-only. Kill, move, repair, and recovery actions stay -disabled until a fresh guard succeeds. +Cached inventory is display-only. Close, move, repair, and recovery actions +stay disabled until a fresh guard succeeds. + +If a manager probe times out, do not repeatedly open or close sessions. +Preserve owner-only logs and inspect the daemon first. + +## A session record with no shell ("Unavailable") + +The session manager can keep listing a session whose shell process is gone: a +close that hit an already-dead process aborts its own bookkeeping and leaves +the entry behind. Session Kit quarantines such an entry -- no session number, +never drawn in the picker, and refused by every `sp` verb, because there is no +shell to open, close, or repair. `sp help unavailable` explains the state. + +They clear without intervention: the cleanup timer closes a record whose shell +has been provably absent for the full observation window (72 hours by default), +and restarting the session manager clears every stale entry at once -- which +also ends every live session, so it is not a routine step. + +The optional shpool patch `0003` removes this kind of dead-shell record at the +manager layer instead of waiting for Session Kit's cleanup window. + +To clear one immediately, use the session manager directly, outside Session +Kit: + +```bash +shpool list +shpool kill <name> +``` + +Check `sp list` first: anything Session Kit still shows with a session number +is a live session, and `shpool kill` would end it for real. + +## macOS services are not running + +The first install generates LaunchAgent templates but leaves them unloaded. A +signed-in desktop user session is required so the GUI launchd domain is +available. Then run: -If a manager probe times out, do not repeatedly attach or kill. Preserve -owner-only logs and inspect the daemon first. +```bash +session-kit services enable +session-kit services status +``` + +Enable refuses to start a second daemon when an unmanaged shpool daemon already +responds or cannot be safely ruled out. If only some jobs load, `services +status` reports the partial state. Jobs loaded before the failure can remain +running. Do not keep retrying enable without inspecting the reported labels and +owner-only logs. -## Reply alert is missing +Update and rollback kickstart an already loaded watchdog so it uses the +selected release. They do not restart shpool or the reaper. A loaded shpool or +reaper job can therefore have a pending definition. At a safe point with no +managed sessions, apply it with: -Session Kit uses structured provider events, not prose. Check `sp detail` for -the session and confirm the provider version is within the tested release -matrix. +```bash +session-kit services disable +session-kit services enable +session-kit services status +``` -For Codex, only a live unresolved `request_user_input` without automatic -resolution needs a reply. Completed, aborted, superseded, malformed, or -optional questions do not show the alert. +Disable refuses to unload shpool while sessions exist or their absence cannot +be proved. ## New Codex session says setup is incomplete @@ -69,7 +238,7 @@ first proves the exact process tree, then binds the UUID when it appears. If instructed: ```text -sp verify-start <terminal-number|shpool-id> +sp verify-start <session-number|shpool-id> ``` Do not delete paired launch records. @@ -79,65 +248,192 @@ Do not delete paired launch records. Selecting the row shows a move action. You can also run: ```text -sp takeover <terminal-number|shpool-id> +sp takeover <session-number|shpool-id> ``` -Moving disconnects the earlier terminal view after confirmation. It does not -create a second provider process. +Moving displays the exact target, revalidates it, and disconnects the earlier +terminal view without an additional prompt. It does not create a second +provider process. ## Provider exited -A normal Claude Code or Codex exit should leave the managed terminal alive and -label it as provider exited. Open that row to reach the provider-exit menu, -where you can reopen the exact conversation, keep the terminal, open an -ordinary shell, or close the terminal. +A clean Claude or Codex exit closes the session and lands you at the picker. +The conversation is in Closed sessions and one Restore brings it back. -If the entire row disappears, collect a sanitized `sp health`, `sp detail`, and -relevant owner-only event timestamps. Do not publish UUIDs or terminal output. +A crash is different. The conversation reopens itself once, with a one-line +notice saying so. A second crash within a minute stops the loop, so a +conversation that cannot start cannot spin. The session then closes into Closed +sessions if its conversation can be brought back, and stays open, saying why, if it cannot. There is no menu to answer. -## `k <number>` changes nothing +A reopen that cannot run names what was missing, including an inexact live +inventory, a missing row, a changed shell generation, or a session the +inventory no longer records as provider exited, and hands the window back in +that same step. It +never closes the session: one of those reasons is that a provider is still +running in the terminal, and a refusal is not evidence that anything finished. -The kill shortcut accepts only a visible number from a fresh dashboard. It -refuses cached state, an invalid or hidden number, changed identity, and unsafe -proof files. +If the entire row disappears, collect a sanitized `sp health` and `sp detail`. +Do not publish UUIDs or terminal output. -After resolving the target, it displays the title, provider, and exact shpool -ID and waits for confirmation. A refusal leaves the session unchanged and -refreshes the dashboard. +## Close does nothing -## Quiet session +Marking accepts the numbers on screen, comma-separated lists, and ranges, from +a fresh picker. Close refuses cached state, an invalid or hidden number, +changed identity, and unsafe proof files. + +Every marked number is checked before the batch starts. Session Kit freezes one +proof per session and revalidates it immediately before the close. A refusal +leaves that session unchanged and says so; it does not undo a close that +already happened in the same batch. The list refreshes afterwards. + +Nothing is confirmed first, because nothing has to be: every deliberate close +is written to the ledger behind the Closed sessions row, and restoring from +there brings the exact conversation back. + +## Quiet session or repair refusal Quiet output does not prove failure. A provider can be working without recent terminal output. -Use `sp repair` only when the health evidence names a direct, unrecoverable -handoff failure. +The watchdog runs in report-only mode by default on both platforms. Automatic +watchdog repair is Linux-only; macOS explicitly refuses repair mode because the +required Linux daemon-thread evidence is unavailable. Manual `sp repair` is a +deliberate operator action: it still requires an exact, detached, unreachable +session with a recoverable provider conversation, but it does not require the +watchdog's recorded handoff-failure evidence. + +For automatic decisions, an old journal line is not enough. The watchdog +requires a named handoff failure from the current daemon generation and ignores +it when a later successful attach exists. Unknown recent-output age makes the +condition report-only. A global serving-thread count mismatch warns about the +manager but is never assigned to one session and never triggers a repair. ## Automatic cleanup did not run This is usually a safe refusal. A provider-exited terminal must remain detached and in the same exact safe state for 72 continuous hours after the cleanup -timer is enabled. Any live provider, child process, reply, attachment, recovery +timer is enabled. Any live provider, child process, attachment, recovery conflict, generation change, or missing evidence blocks cleanup. -Manual inspection remains available through `sp detail` and `sp prune`. +Confirm that the Linux reaper timer or macOS reaper LaunchAgent is active with +`session-kit services status`. Manual inspection remains available through `sp +detail` and `sp prune`. + +The guarded 72-hour close, manual prune, and stale app-server-directory cleanup +work on Linux and macOS. Temporary picker-file expiry and stale +immutable-release garbage collection are currently Linux-only. ## Terminal cannot be opened -For direct shpool handoff failure evidence: +If the daemon log for the named session contains the full heartbeat chain +`joining heartbeat_h`, `waiting for heartbeat ack`, and a receive timeout, the +optional `0001` patch prevents that delayed ack from destroying the session's +terminal-serving thread. Rebuilding and restarting the daemon is required; +repeatedly opening the already-wedged terminal cannot repair its lost thread. + +For a deliberate repair after you have established that the exact detached +session is unreachable: + +```text +sp repair <session-number|shpool-id> +``` + +Repair ends the unreachable session and resumes the exact provider conversation +in a new one. It refuses attached, ambiguous, or UUID-less targets. + +## The window echoes what you type and the session does not see it + +You open a session and the window goes deaf. What you type appears as literal +text over the application's first screen, arrow keys show as `^[[B`, Ctrl-L +shows as `^L`, Ctrl-L and Esc do nothing, and nothing reaches the session. + +**Get the window back.** From a second window, find the deaf window's terminal +and reset it: ```text -sp repair <terminal-number|shpool-id> +stty -F /dev/pts/<N> raw -echo ``` -Repair ends the unreachable managed terminal and resumes the exact provider -conversation in a new one. It refuses attached, ambiguous, or UUID-less -targets. +The next key you press goes to the session. Nothing is lost, and the session was +never in trouble: it is the terminal's input mode that is wrong, not the +session. Run `tty` inside the deaf window to read `<N>`, or +`ps -o tty= -p <pid>` from outside. + +**Why it happens, and why it should not any more.** + +The session's client refuses to touch the terminal at all if any one of its +three standard channels is not a terminal, silently, and it goes on shuffling +bytes as if nothing were wrong. So the window keeps the ordinary line-typing +mode it had, and a full-screen application never gets the keys. + +One picker bug used to hand it exactly that: closing the live-updates +descriptor also pointed the picker's error channel at nowhere, permanently, for +the rest of that window's life. Every session opened from that window +afterwards came up deaf. That is fixed at its source. + +Two further guards are in place regardless of where a bad channel comes from: + +- Before handing a session over, the client is given a terminal on all three + channels, so it can never silently decline again. +- If a session ends leaving the window unusable, your input modes are put back + and what was found is recorded in + `~/.local/state/session-kit/handoff-repair.log`. + +**If it happens anyway**, that is new and worth capturing: + +1. Note the terminal (`/dev/pts/<N>`) and the time. +2. Recover with the `stty` line above. +3. Keep `~/.local/state/session-kit/action-events.jsonl` and say when it + happened. + +The optional shpool patch `0002` also restores the terminal input mode in the +client itself. It is useful when shpool is used outside Session Kit's guarded +handoff path. + +## Every session is unreachable at once + +The daemon is alive and still accepting connections, but every `sp` command, +attach, and detach blocks forever. Nothing times out and it does not recover on +its own. + +This is not Session Kit state, and no Session Kit command can clear it: the +commands block for the same reason everything else does. `sp repair` will not +help. + +The cause is a detach deadlock in shpool 0.11.0. Upstream `handle_detach` holds +the global session-table lock across an unbounded send and receive on two +rendezvous channels, so a single client whose socket has stopped draining parks +the lock for every session. One stalled SSH window is enough. + +The fix is the optional `0004` patch, which requires rebuilding the shpool +binary and restarting the daemon. Read +[the patch notes](../shpool-patch/README.md) first. + +After any daemon restart, conversations are not lost. Open a new terminal, start +the picker, and press `u` to review exact conversation recovery; opening that +view changes nothing on its own. An inventory that looks empty immediately after +a restart is a stale read, not deleted work. + +## Attach exits with the wrong status or resize bursts damage scrollback + +These are shpool-layer issues rather than Session Kit state: + +- Optional patch `0005` makes the attach client return the remote command's + exit status instead of reporting success after a failed command. +- Optional patch `0006` coalesces a burst of terminal resize events for 120 + milliseconds, applies the final size, and skips a duplicate size. This + avoids repeated redraws that can leave noisy scrollback. + +Both require rebuilding shpool and restarting its daemon. Read +[the patch notes](../shpool-patch/README.md), and plan that restart separately +because it ends the currently managed session processes. ## Changed shpool binary report A package or Cargo update may have replaced the binary used by the running -daemon. Do not restart the daemon as a diagnostic step. Compare the running +daemon. This is easy to miss, because a rebuilt shpool silently discards any +patch you had applied, and the failure only appears later as the deadlock +above. Do not restart the daemon as a diagnostic step. Compare the running binary, prior binary, active sessions, and optional patch choice first. ## Report a problem diff --git a/docs/uninstall.md b/docs/uninstall.md index 57b88ca..3fcdb6a 100644 --- a/docs/uninstall.md +++ b/docs/uninstall.md @@ -1,22 +1,138 @@ # Uninstall Session Kit -Disable automatic login first and inspect active sessions: +`session-kit uninstall` removes Session Kit's integration with the account: the +guarded shell-login blocks, the stable commands under `$HOME/.local/bin`, and +the install receipt. It stops no service, closes no session, and deletes no +conversation, journal, or private state. Two optional flags remove the installed +release code and the configuration root, and even those leave every retained +data directory in place. + +Order matters. Disabling the services is a separate step that comes first, and +on macOS uninstall enforces it. + +## Check what is running ```bash -session-kit disable-login sp list +sp health +session-kit services status +``` + +`sp list` shows the managed sessions a service change would affect. +`services status` reports each Session Kit job: `systemctl --user status` for +the shpool socket and the reaper timer on Linux, or one `LOADED`/`UNLOADED` line +per LaunchAgent on macOS. Close or move any session you still want before going +further. Nothing below closes one for you. + +## Disable the services + +```bash +session-kit services disable +``` + +On macOS this step is mandatory and uninstall enforces it twice over. Uninstall +refuses while any of the three Session Kit labels is loaded, and refuses again +while any of the three plist files remains in `~/Library/LaunchAgents`, even +unloaded, even after a manual `launchctl bootout`. Removing those files by hand +is the case `services disable` exists to replace: before it touches an active +plist it proves that file is byte-identical to the template this installation +generated, or that it matches the digest recorded in the private LaunchAgent +receipt. An edited or unowned plist is refused rather than deleted. + +`services disable` on macOS holds the Session Kit creation lock, proves shpool +reports zero sessions, unloads the watchdog and the reaper, proves none +appeared, and only then unloads shpool. Any of those steps can refuse +and leave shpool running. The two proofs bracket the window in which a session +could appear between them, because unloading the shpool job stops the daemon and +takes the sessions it holds with it. + +On Linux, `services disable` runs `systemctl --user disable --now +shpool-reaper.timer session-kit-subagent-sweep.timer shpool.socket`, which +disables and stops the socket, the hourly cleanup timer, and the five-minute +sub-agent sweep. It makes no empty-session proof and never signals shpool +directly. What happens to a running daemon depends on how that daemon was +started: the shipped `shpool.service` declares `Requires=shpool.socket`, so a +systemd-started daemon is stopped along with the socket, while a daemon started +outside the user manager is not under its control. Confirm the outcome with +`sp list` rather than assuming either one. + +## Remove the integration + +```bash session-kit uninstall ``` -The default uninstall removes the managed Bash block and stable helper -commands. It does not stop services, remove shpool, close sessions, delete -provider conversations, or delete private data. +Before deleting anything, uninstall recovers any pending lifecycle transaction, +validates the lifecycle roots, and checks every command it is about to remove +against the active release: each helper launcher must be byte-identical to that +release's `deploy/session-kit-launcher`, and `session-kit` must match that +release's own copy. A launcher that was edited, replaced, or turned into a +symlink is refused and uninstall stops. Session Kit does not delete a file it +cannot prove it wrote. -`--purge-code` removes installed Session Kit release code. -`--purge-config` removes Session Kit configuration. Both retain state, -journals, archives, launch records, and provider storage. +The default uninstall removes: -## Retained data +- Session Kit's provenance-marked `UserPromptSubmit` handler from Claude and + Codex configuration; unrelated handlers in the same matcher group are + retained, and a file, empty `hooks` object, or empty event list that existed + before installation is restored byte-for-byte; +- the guarded login block from `.bashrc`, and on macOS from `.bash_profile` and + `.zshrc` as well; +- the private integration marker; +- `kit`, `sp`, `shpool_login`, `shpool_status`, `shpool_reaper`, + `codex_resume_here`, and `session-kit` from `$HOME/.local/bin`; +- on Linux, the copied systemd user units in `~/.config/systemd/user/`; +- the install receipt, once the managed files above are verified and gone. + +It does not close sessions, delete provider conversations, remove shpool, remove +the installed release code, remove configuration, or delete private state. It +starts, stops, restarts, and signals nothing. + +On Linux the unit files go with the uninstall, and the enablement is checked +first. A unit systemd still holds a link to -- `enabled`, `enabled-runtime`, +`linked`, or `linked-runtime` -- stops the uninstall with the command that +clears it, because deleting a unit file does not deactivate the unit and a +removed file behind a live enablement is worse than a file left in place. A unit +that is merely *running* does not stop it: that daemon is holding live sessions, +removing its file does not stop the process, and the way out of an installation +must not go through killing every session in it. Uninstall names any unit it +leaves running. A unit path that is a symlink is refused, not followed, and +nothing is removed until every path has passed. + +## Remove the code and configuration + +```bash +session-kit uninstall --purge-code --purge-config +``` + +`--purge-code` removes the installed release root, +`$HOME/.local/lib/session-kit`, including every retained release and the +`current` pointer. `--purge-config` removes the configuration root, +`${XDG_CONFIG_HOME:-$HOME/.config}/session-kit`, which holds `projects.tsv`, +`inventory.json`, and on macOS the generated LaunchAgent templates. + +Each flag requires proof of ownership before it deletes a directory: a current +install receipt whose recorded roots name that exact path, plus a +`.session-kit-owned.json` marker inside the target naming the same path and +kind. A missing receipt, an outdated receipt schema, a mismatched marker, or a +symlinked target is refused. Neither flag removes a directory it cannot +attribute to this installation. + +Pass these flags on the uninstall invocation itself. Afterwards the receipt and +the `session-kit` command are both gone, so there is no supported path back to a +proved purge. + +Disable the services before purging code. The installed units execute paths +inside the root being deleted, on Linux the watchdog unit runs +`~/.local/lib/session-kit/current/bin/session_kit_watchdog` and the reaper unit +runs `~/.local/bin/shpool_reaper`, so purging while they are enabled leaves +them enabled and pointing at nothing. Do not purge code while a managed session +still depends on an installed release. + +Both purge options retain state, journals, archives, launch records, logs, +backups, and provider storage. + +## What stays behind By default Session Kit keeps: @@ -25,20 +141,42 @@ ${XDG_CONFIG_HOME:-$HOME/.config}/session-kit/ ${XDG_STATE_HOME:-$HOME/.local/state}/session-kit/ ${XDG_STATE_HOME:-$HOME/.local/state}/shpool-journal/ ${XDG_STATE_HOME:-$HOME/.local/state}/shpool-journal-recovery/ +${XDG_STATE_HOME:-$HOME/.local/state}/shpool-archive/ ${XDG_STATE_HOME:-$HOME/.local/state}/shpool-start/ ``` -These locations can contain exact IDs, private paths, action receipts, and -terminal content. +Shell startup backups, lifecycle transaction backups, and on macOS the +LaunchAgent job logs all sit beneath the Session Kit state root. Between them +these locations can hold exact IDs, private paths, action receipts, service +history, logs, and terminal content. +[Security and local data](security-and-data.md) describes what each class +contains. -## Removing retained data +Some files the installer wrote sit outside all of those roots. +`~/.config/shpool/config.toml` is created only when it is absent, and it is the +account's own file from that moment on. The kit's Codex +theme files, `${CODEX_HOME:-$HOME/.codex}/themes/sk-*.tmTheme`, are installed +into the Codex home so Codex can read them; remove them by hand if you want them +gone. The Claude Code title hook and status line at +`~/.claude/hooks/nameintent_title.sh` and `~/.claude/statusline.sh` are owned by +the kit. `claude-integration.json` records their installed digests. Uninstall +removes them only while their content still matches that record, keeps both an +edited file and its ledger entry, and removes only the kit's exact settings +registrations. `claude-statusline-backups.json` records the operator's prior +`statusLine`; uninstall restores its current recorded value instead of deleting +it. Edited files and unrelated hooks stay in place. The generated per-account +quota probes and caches beneath `~/.claude/cache/session-kit-quota/` are removed +with the integration; provider credentials and conversation storage are not. See +[the Claude Code integration notes](../config/claude/README.md). -Data removal is a separate manual decision. Before deleting anything: +## Removing retained data -- prove no live process writes to the target; -- list each exact path and data class; -- make and verify a backup if recovery matters; -- reject symlinks, unexpected owners, and broad path targets; -- never include Claude Code or Codex provider storage by accident. +Deleting that data is a separate, manual decision, because only the owner can +judge what a journal or a recovery record is still worth. Before deleting +anything, prove that no live process writes to the target, list each exact path +and data class, and make and verify a backup if recovery matters. Reject +symlinks, unexpected owners, and broad path targets, and never sweep Claude Code +or Codex provider storage in by accident, those directories hold the +conversations themselves, not Session Kit's view of them. -Session Kit does not provide a broad “delete everything” command. +Session Kit provides no broad "delete everything" command. diff --git a/docs/update-and-rollback.md b/docs/update-and-rollback.md index eee4e42..8667cb9 100644 --- a/docs/update-and-rollback.md +++ b/docs/update-and-rollback.md @@ -1,71 +1,342 @@ # Update and roll back -Session Kit updates from a reviewed local clone. It does not fetch source or -restart a service on its own. +Session Kit installs from reviewed local source and never fetches it. Release +selection does not disturb shpool or its live sessions. As part of install, +update, and rollback it does refresh the service manager safely: on Linux it +reloads unit definitions, may enable a newly introduced timer, and restarts an +already running watchdog; on macOS it kickstarts an already loaded watchdog. +It never starts, stops, or restarts the shpool daemon in that transaction. -```bash -git pull --ff-only -tests/run -session-kit update --source "$PWD" -session-kit doctor -``` +Review and test an updated local Git checkout first. From that checkout, run +`session-kit update --source "$PWD"`, then run `session-kit doctor`. + +`session-kit update` can omit `--source` when the source recorded by the current +install receipt is still available. If that path no longer exists, give it a +reviewed local Git checkout or a supported extracted release source explicitly. -## Immutable releases +## Updating does not disturb running sessions + +This is the property the whole layout exists to provide, so it is worth being +precise about why. ```text $HOME/.local/lib/session-kit/ - launcher current -> releases/<full-commit-id> releases/<full-commit-id>/ $HOME/.local/bin/ - sp -> ../lib/session-kit/launcher - shpool_login -> ../lib/session-kit/launcher - shpool_status -> ../lib/session-kit/launcher - shpool_reaper -> ../lib/session-kit/launcher - codex_resume_here -> ../lib/session-kit/launcher + session-kit + kit + sp + shpool_login + shpool_login_tui + shpool_login_launcher + shpool_status + shpool_reaper + codex_resume_here ``` -The stable launcher resolves `current` once and pins that physical release for -the command's lifetime. Existing commands finish on their pinned release; new -commands use the selected release. +Each release directory is immutable and named for its exact source commit. The +files in `$HOME/.local/bin` are stable executable launcher copies, not symlinks +into a release. A launcher resolves `current` once and pins that physical +release for the lifetime of the command. A command already running therefore +finishes on the release it started with, and only new commands use the newly +selected one. + +Lifecycle commands use the separately pinned `manager` release. A rollback can +therefore select an older runtime without reviving that runtime's obsolete +updater. On update, the current manager validates the reviewed source and hands +the transaction to that source's manager, so new transaction rules apply on +the first update rather than one update later. `session-kit update` checks compatibility, installs the exact source commit, -keeps the prior release, and switches the pointer atomically. Updates default -journals to off; pass `--journal on` only when the local retention decision -still applies. The existing login choice is preserved unless a login flag is -supplied. +keeps the prior release, refreshes the stable launchers, and switches the +pointer atomically. Release selection does not restart, stop, signal, attach +to, or detach from shpool. Your open terminals keep running. + +Updates preserve the current journal choice. Supply `--journal on` or +`--journal off` only when you mean to change it. The existing login-integration +choice is also preserved unless you supply an explicit login flag. + +Claude profiles are handled independently. If a profile's `settings.json` +cannot be safely parsed or written, the command names that file once, skips its +registrations, and continues the release change; rollback is never held up by +an unreadable or invalid provider settings file. The one exception is a +symlinked `settings.json`: the kit never edits through a symlink, so it names +the link and stops; replace the link with a regular copy and rerun. Install and update refuse to replace an +unrelated `statusLine` by default. After reviewing the named profile, rerun with +`--force` to preserve its current value and install the kit status line. + +The owner-private state file `claude-statusline-backups.json` records the +operator value that uninstall restores. `claude-integration.json` records the +installed Claude hook and status-line files by digest, so uninstall removes +only unchanged kit copies and retains the record for an edited copy it leaves +in place. + +## Service activation during update + +On Linux, update runs `systemctl --user daemon-reload`. It enables a timer only +when that timer is newly introduced and `is-enabled` reports no prior policy; +an explicitly disabled timer stays disabled. It then uses `try-restart` for an +already running watchdog, so the watchdog begins using the selected release +without starting a service that was off. Read-only source and doctor probes can +fall back to systemd's local-machine transport when the direct user socket is +unavailable; service control still requires the normal direct user socket. + +On macOS, update regenerates the owner-private LaunchAgent templates and +kickstarts the watchdog only when that label is already loaded. It does not +load a fresh install automatically. -Release selection does not restart, stop, signal, attach to, or detach from -shpool. +Neither platform restarts shpool or the reaper. If one of those definitions +changed, reach a safe point with no managed sessions and apply it deliberately: -## Rollback +```bash +session-kit services disable +session-kit services enable +session-kit services status +``` + +`services disable` refuses to unload shpool while live sessions exist, or when +their absence cannot be proved. `services enable` refuses to overwrite a loaded +job whose active definition differs from the generated template. + +## Roll back `session-kit rollback` selects the prior installed release recorded by the last -install or update. Use: +install or update. For any other retained release: ```text session-kit rollback --to <full-commit> ``` -for another retained release. +Rollback validates the retained release, updates the pointer, regenerates +platform service templates, updates the private integration marker and +receipt, and performs the same safe service refresh as update. It never +restarts shpool. A loaded shpool or reaper definition still needs the same safe +disable and enable cycle above when its definition changed. + +The unit list belongs to the release that is running, so a rollback away from a +release that added a systemd unit reads a name the target release never +carried. On Linux that unit is not installed -- it does not exist to install -- +and its file is removed from `~/.config/systemd/user/`, with the `systemctl +--user disable` line to run printed for it. Leaving the file would leave systemd +starting the new release's program out of the old release's tree. A unit path +that is a symlink is left alone rather than followed. Rollback is never refused +over a unit file: a release that adds a unit must not remove the way back past +itself. + +The recovery-capable management launcher is stable across rollback. If power +is lost after the pointer changes, it reads the owner-private pending journal +and runs the exact pre-flip manager that created it. The launcher itself is not +rewritten after journal removal, eliminating the former kill window where an +older manager could become active without durable recovery instructions. Both +journal publication and removal are followed by a directory sync so the +decision survives a machine crash. + +### When an old validator refuses a modern launch record + +A retained old release may predate the remedy text and target-aware validation +used by the current manager. After rolling back, its validator can refuse a +newer launch record without explaining how to proceed. Do not delete or edit +the record to get past that refusal. + +Use the **target release's own** retained +`deploy/session-kit-release` rollback program to complete the rollback under +the rules that release understands. The other safe choices are to wait until +the launch record ages out, or quarantine that exact record for diagnosis and +retry. Which option is safe depends on whether its provider process can still +be live; preserve the record until that has been established. + +### Inventory publishing fence + +Releases with the generation-2 inventory publisher replace +`inventory.lock` with an owner-only refusal sentinel and publish through +`inventory-v2.lock`. The sentinel prevents an already-loaded older collector +from replacing a newer whole-session list. A release that predates that code +cannot open the sentinel, so a fresh login picker under such a release receives +no list even though `inventory.json` is still present. + +`session-kit rollback` inspects the retained target's `StateLock` code before +changing `current`. When that target has no generation-2 path, the currently +installed manager serializes with `inventory-v2.lock`, accepts only the exact +owner-owned mode-0400 sentinel, and writes an owner-only durable rollback hold. +While retaining `inventory-v2.lock`, it replaces the sentinel with a plain +mode-0600 `inventory.lock` and changes `current`. A generation-2 publisher that +was already waiting wakes after the flip, sees the hold, and refuses read-only +instead of recreating the sentinel behind legacy code. The hold remains while +the legacy release is selected. After a forward update, a newly launched and +currently selected generation-2 publisher retires it under the versioned lock +before recreating the refusal fence. + +The crash boundaries deliberately leave only three pairs: generation-2 code +with the sentinel, generation-2 code with the plain lock, or legacy code with +the plain lock. The durable hold makes each pair retryable and prevents the +last pair from being re-fenced by an older in-flight publisher. + +The automatic step is the supported path. Use the following manual un-fence +only after some older rollback mechanism has already selected a pre-generation-2 +release and a newly opened picker cannot list sessions. Do not run a status +refresh concurrently. The script refuses a symlink, wrong owner or mode, +linked file, changed pathname, or lookalike content; it changes only the exact +sentinel: + +```bash +state_dir=${SESSION_KIT_STATE_DIR:-${XDG_STATE_HOME:-"$HOME/.local/state"}/session-kit} +python3 - "$state_dir" <<'PY' +import fcntl, os, pathlib, stat, sys, tempfile + +root = pathlib.Path(sys.argv[1]) +legacy = root / "inventory.lock" +versioned = root / "inventory-v2.lock" +expected = b"session-kit publishing lock generation 2\n" +flags = os.O_RDWR | os.O_CREAT +read_flags = os.O_RDONLY +for name in ("O_CLOEXEC", "O_NOFOLLOW"): + flags |= getattr(os, name, 0) + read_flags |= getattr(os, name, 0) + +def require(condition, message): + if not condition: + raise SystemExit(f"session-kit: refusing manual un-fence: {message}") + +root_info = root.lstat() +require( + stat.S_ISDIR(root_info.st_mode) + and not stat.S_ISLNK(root_info.st_mode) + and stat.S_IMODE(root_info.st_mode) == 0o700 + and root_info.st_uid == os.geteuid(), + f"unsafe state directory: {root}", +) + +lock_fd = os.open(versioned, flags, 0o600) +try: + lock_info = os.fstat(lock_fd) + lock_path_info = versioned.lstat() + require( + stat.S_ISREG(lock_info.st_mode) + and stat.S_IMODE(lock_info.st_mode) == 0o600 + and lock_info.st_uid == os.geteuid() + and lock_info.st_nlink == 1 + and (lock_info.st_dev, lock_info.st_ino) + == (lock_path_info.st_dev, lock_path_info.st_ino), + f"unsafe generation-2 lock: {versioned}", + ) + fcntl.flock(lock_fd, fcntl.LOCK_EX) + locked_path_info = versioned.lstat() + require( + (lock_info.st_dev, lock_info.st_ino) + == (locked_path_info.st_dev, locked_path_info.st_ino), + f"generation-2 lock changed: {versioned}", + ) + sentinel_fd = os.open(legacy, read_flags) + try: + info = os.fstat(sentinel_fd) + path_info = legacy.lstat() + body = os.read(sentinel_fd, len(expected) + 1) + finally: + os.close(sentinel_fd) + require( + stat.S_ISREG(info.st_mode) + and stat.S_IMODE(info.st_mode) == 0o400 + and info.st_uid == os.geteuid() + and info.st_nlink == 1 + and (info.st_dev, info.st_ino) == (path_info.st_dev, path_info.st_ino) + and body == expected, + f"not the exact generation-2 sentinel: {legacy}", + ) + replacement_fd, replacement = tempfile.mkstemp( + prefix=".inventory.lock.", dir=root + ) + try: + os.fchmod(replacement_fd, 0o600) + os.fsync(replacement_fd) + os.close(replacement_fd) + replacement_fd = -1 + os.replace(replacement, legacy) + directory_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + if replacement_fd >= 0: + os.close(replacement_fd) + try: + os.unlink(replacement) + except FileNotFoundError: + pass +finally: + os.close(lock_fd) +PY +``` + +After it succeeds, open a new picker. If it refuses, preserve both lock files +and the rollback receipt for diagnosis instead of deleting more state. + +### Lost collection ordering state + +The inventory publisher keeps an ordinary sequence counter and a separate +durable allocation floor. If one remains readable, the next refresh safely +rebuilds the pair above that record and above every readable collection marker. +If both are missing or unreadable while any published collection document or +marker remains, refresh stays read-only. Do not invent a sequence from the +documents: a collector that has not published yet may already hold a larger +one. + +Install and upgrade prevent the common missing-floor case before selecting the +new release. When the floor does not exist, they copy the existing sequence +counter exactly. If the counter is empty, they seed the floor with `1`. An +existing floor is preserved byte for byte. If the missing floor cannot be +created safely, release selection stops and prints the exact reset command +below instead of leaving the new collector unable to publish. + +If both records are permanently lost, run the one-command reset below from an +ordinary local shell immediately after a machine restart and before starting a +picker, `sp`, `shpool_status`, or any Session Kit user service. The restart is +what proves that no old collector still holds an unpublished allocation. The +command archives the six collection-derived documents, their markers, and both +sequence records; it does not delete them or touch session recordings, +transcripts, closed-session history, names, origins, account records, or project +settings. The next refresh is then a genuine first publication and starts at +sequence 1. + +```bash +python3 ~/.local/lib/session-kit/current/bin/reset-collection-order.py +``` + +The command refuses unless the state directory and every entry it moves are +owner-only real files/directories, the allocation records are both unreadable, +and at least one lost collection-order entry exists. +It prints the owner-only archive directory. Keep that archive until the rebuilt +picker has been checked. + +## State written by a newer release + +A release that changes the format of private state must say whether an older +release can still read it, because rollback does not rewrite state. -Rollback updates stable launchers and the private integration marker without -restarting services. A state-format change must document whether the older -release can read new state before the newer release is activated. +**Session colors.** Current releases split session colors into a Claude palette +and a Codex palette. A retained older release ignores a stored color override +it does not recognize and falls back to its own palette; its validator drops +the unknown name rather than failing. A session that showed a newer color +simply returns to an older color. No state needs to be removed before rolling +back. ## shpool binary updates -Session Kit release selection and shpool binary replacement are separate. -Replacing shpool may require a daemon restart and can end every managed -terminal process. Never combine that restart with a routine Session Kit update. +Session Kit release selection and shpool binary replacement are separate +operations. Replacing shpool may require a daemon restart, which can end every +session process. Never combine that restart with a routine Session Kit +update. -The optional patch has separate build and rollback instructions in -[the shpool patch guide](../shpool-patch/README.md). +Rebuilding or reinstalling shpool also discards any patch previously applied to +it, and that loss is silent. The optional patch has its own build and rollback +instructions in [the shpool patch guide](../shpool-patch/README.md). -## Interrupted update +## An interrupted update -Release commands write transaction state before changing exposed paths. A later -invocation completes a committed transaction or restores an incomplete one. +Lifecycle commands record the exact pre-change file state before touching +exposed paths. A later Session Kit lifecycle invocation either completes a +committed transaction or restores an incomplete one. -Do not delete a transaction receipt to bypass a refusal. Preserve it and report -the sanitized error. +Do not delete a transaction receipt to get past a refusal. Preserve it and +report the sanitized error: the receipt is what makes the recovery exact. diff --git a/docs/usage.md b/docs/usage.md index f4e7e5b..869d2e6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,97 +1,352 @@ # Use Session Kit -## SSH picker +## The picker -When enabled, the picker opens in an interactive Bash login: +SSH opens an ordinary shell. Type `kit` to open the picker. With shell +integration enabled, Linux Bash may also print a short `kit: open sessions` +hint; neither Bash nor macOS zsh starts the picker until you ask. + +The picker is one list. Sessions **ready** for this window come before sessions +**open elsewhere**. Inside each group the state order is `question`, `needs +you`, `working`, then `idle`; machine sessions sit behind one counted row. +New session, Projects, Closed sessions, and Help follow the session rows. + +```text +Enter open the top or highlighted row; on an empty list, start new +b go back; from the home screen, quit to an ordinary shell +digits mark sessions: 5 · 1,4 · 7-9 +letters filter the list as you type +q quit from the home screen when no filter text is typed +Esc clear text, step back, then quit when nothing else remains +Ctrl-C abandon the line being typed +Ctrl-D quit the picker from anywhere +↑ / ↓ move the highlight on the cursor-driven screen +mouse scroll, highlight, and open on the cursor-driven screen +``` + +Nothing on the screen asks you to confirm anything. An action runs and then +says what it did, `Closed 3 sessions.`, and a close is recoverable from +Closed sessions. [Picker navigation](picker-navigation.md) covers the list, +marking, filtering, the mouse, and the action panel in full. + +Opening a session that is open elsewhere defaults to moving it here after a +fresh identity check. The earlier window returns to its picker and the +conversation is never duplicated. New session defaults to Claude Code. The +action panel for a Claude or Codex session also offers account, model, history, +name, colour, close, and, where applicable, restore actions. + +Picker rows and ordinary action lines hide shpool IDs and conversation UUIDs. +`sp detail` and the JSON modes expose exact identity for diagnosis. Commands +accept an ID wherever they accept a number, so a script that already holds one +keeps working. + +### Keys in the key-driven picker + +The installed `kit` command starts this key-driven surface directly. An +interactive Bash shell that loaded Session Kit's shell integration can instead +route its `kit` function through the cursor-driven launcher when that feature +is enabled; see [Troubleshoot](troubleshooting.md#which-picker-kit-opens). +The key-driven surface accepts: + +```text +Enter open the top row, or start new when the list is empty +number open a session, or show what can be done with one open elsewhere +h number read a session's history without opening it +n new session +k numbers close sessions: k 5 · k5 · k 5, 6, 8 · k 4-7 · k all +name number give a Claude or Codex conversation a name of your own +name reset number drop that name and show the provider's again +fork number start a separate fork of a Claude or Codex conversation +model number move an idle Claude or Codex conversation to another configured model +x show or hide machine sessions behind their counted row +a everything that needs you +g move to the next session that needs you +d number dismiss a repair that failed, from the Needs you screen +/text filter names, providers, accounts, models, and projects as you type +group group by state, provider, or project +c compact rows: no headings, more sessions on screen +next / prev move between pages; > and < do the same +r refresh the list and clear the filter +m more: sessions outside the kit, closed sessions, projects +p projects: add, review, hide, or restore new-session directories +o show sessions outside the kit, read-only +u restore a closed conversation +? show this table from the picker itself +b / q quit the home screen for a regular shell +quit / exit the same, by name +``` + +A key takes its number with or without the space: `k5` and `k 5` are the same +close. Closing keeps the typed number through the result and refresh. `?` +prints the same table from the picker itself, so the two cannot drift apart. + +#### Why the key-driven picker's screen clears while you type + +An idle picker keeps itself current. It collects a snapshot in the background +every few seconds and repaints only when something visible changes, so it never +runs in front of your prompt. Session numbers are stable, so a repaint never +changes what a number means, and the active search and page survive one. + +A repaint clears the screen. Characters you typed without pressing Enter live in +the terminal's own input queue, which this process cannot inspect: in canonical +mode the kernel reports an incomplete line as no input at all, on both Linux and +macOS. So a repaint landing mid-command erases the *echo* of what you typed +while the characters themselves stay queued and intact. Press Enter and the +command you started still runs. + +Set `SESSION_KIT_PICKER_REFRESH_SECONDS=0` to switch this off, or to a number of +seconds (minimum 2) to change the pace. + +When activation selects a newer release, a running picker reloads the new +launcher at a safe refresh point and restores its filter, page, and selection. +If the new launcher cannot start, the current picker stays in place and reports +the degraded target. + +## Command help + +```text +sp help every command, grouped, with a line each +sp help <topic> one area in detail +sp --help, sp -h the same reference +``` + +`sp help` answers before anything else is required, including on a machine +where shpool is missing or half-installed, that is the moment the instructions +are most needed. The topics are `sessions`, `names`, `accounts`, `history`, +`selectors`, `unavailable`, `exit-codes`, `completion`, and `machine`. + +Every Session Kit command follows the same rule: `-h` and `--help` print their +reference to standard output and exit 0, and an argument mistake prints usage to +standard error and exits 2. `session-kit help` covers installation and +maintenance; `sp help` covers day-to-day session work. + +The verbs beginning `picker-`, plus `sp restore-exact`, are not part of that +human list. The picker and the managed shell call them with proof arguments a +person does not hold; `sp help machine` names them for anyone reading the code. + +`shpool_status --help` separates the modes that never touch disk from the ones +that refresh the cached inventory, fill in pending provider titles, or change +recovery state, several of them look like reads and are not. + +## Tab completion + +Session Kit installs one Bash completion file under each command name that +takes arguments: ```text -number open a ready session or inspect one open elsewhere -n create a Claude Code, Codex, or shell session -k <number> kill the exact displayed session after confirmation -x <number> compatibility alias for k -/text search names, providers, projects, and exact IDs -r refresh live state and clear search -o show provider roots outside shpool, read-only -u review exact recovery records -name <number> set a local provider title -name reset # remove the local title -fork <number> create an independent provider conversation -next / prev change page -? show help -Enter return to a regular terminal +~/.local/share/bash-completion/completions/sp +~/.local/share/bash-completion/completions/session-kit +~/.local/share/bash-completion/completions/shpool_status +~/.local/share/bash-completion/completions/shpool_reaper +~/.local/share/bash-completion/completions/codex_resume_here ``` -Selecting a session already open elsewhere shows a move menu. Session Kit never -moves it without a separate choice and confirmation. +With the `bash-completion` package present, a new shell loads the right file on +demand. Without it, source one copy from your own startup file: -Normal rows omit shpool IDs and provider UUIDs. Use `sp detail`, JSON, or an -explicit search for exact identity. Colors identify provider, availability, -attention, danger, and secondary status; text labels preserve the same meaning -when color is disabled. +```bash +source ~/.local/share/bash-completion/completions/sp +``` -## Start +It completes verbs, help topics, flags, provider names, color names, and the +aliases in your projects file. It never runs a Session Kit command and never +asks the manager for state, so a Tab press cannot change anything or stall on a +snapshot. That is also why it does not complete session numbers: the only +honest source for those is a live inventory, and building one on Tab would be a +state change nobody asked for. + +Set `SESSION_KIT_COMPLETION_DIR` to install the copies elsewhere, or to `none` +to skip them. Uninstall removes only files that still carry the kit's own marker +line, so an edited copy is left alone. + +## Exit codes + +Every Session Kit command uses the same statuses. + +| Status | Meaning | +| --- | --- | +| 0 | the command did what it was asked to do | +| 1 | refused or failed; the reason is on standard error | +| 2 | wrong arguments, or a selector that matched no session | +| 3 | live evidence was refused as stale, partial, or ambiguous (`shpool_status --strict-json`, `--guard-json`), or `shpool_reaper` found its disable sentinel | +| 64 | the launcher was invoked under a name that is not a Session Kit command | +| 69 | something required is unavailable: the current-release link, a helper missing from the pinned release, or Bash 4 or newer on macOS | +| 74 | the picker refused the action it was asked to take | +| 75 | the picker's attach failed after the action was allowed | +| 76 | a reopen ran after a provider crash and the reopened provider exited non-zero; the session closes if its conversation can be brought back, and otherwise stays open with the reason on screen | +| 78 | the launcher found a release or manager link outside the install root | +| 127 | `codex_resume_here` could not find the `codex` command | +| 143 | `sp` was terminated (SIGTERM or SIGHUP) | + +A refusal is a status, not a suggestion: nothing partial has happened. Session +Kit disables a whole action rather than performing part of it. + +## Start a session ```text sp new claude [project-alias] sp new codex [project-alias] sp new shell [project-alias] sp new <project-alias> +sp new claude [project-alias] --account <alias> +sp new codex [project-alias] --model <model-id> +sp new codex [project-alias] --origin machine ``` -The last form uses the provider stored for the project alias. +The last form uses the provider stored for that project alias. When the +project has a committed `session-kit.toml` and is on your project list, its +provider, account, and model are used unless a flag here overrides them; see +[Projects](projects.md). + +Guided New asks for the provider, project, and account before launch. When Matrix +has current, healthy advice that names the same provider, its recommended account +is preselected and Enter confirms it. Missing, stale, unhealthy, logged-out, or +provider-mismatched advice leaves the account unselected. Codex currently has no +preselection because the available Matrix advice is not provider-qualified for +Codex; choose its account manually. This fail-closed behavior prevents advice +for one provider from selecting an account for the other. + +`--model` starts the provider on that model; the identifier is validated for +the provider before anything is launched. `--origin machine` stamps the session +as a machine session, so it stays out of the picker's default list and lives +behind the counted row instead; `SESSION_KIT_ORIGIN=machine` in the launching +environment does the same. + +A session started from **inside another session** is a machine session even +without either of those, because that is how agents start sessions and none of +them says so. If you run `sp new` in one of your own windows and want the new +session in your own list, say `--origin human`. Everything the picker starts is +yours; the picker runs in the login shell, which is inside no session. + +That rule is about creating. `sp restore-exact` and `sp restore` bring back a +conversation that already had an owner, so they carry the origin it was closed +with, whichever way it goes and wherever you run them, and a conversation with +no record is yours. See +[picker-navigation](picker-navigation.md#machine-sessions) for the full rule, +including the one case where an unstamped session is listed as a machine's. + +Sessions use modern Bash on macOS even when the account's normal SSH +shell is zsh. + +A new Claude session starts with an explicit UUID, so native macOS process +evidence can bind it immediately. A new Codex TUI has no conversation UUID until +its first message; before then the picker labels it `Codex started, no messages +yet`. Opening and closing that terminal work normally, while recovery, fork, +name, and color actions wait for Codex to publish its conversation identity, +because each depends on that UUID. With the optional Codex App Server integration +active, Session Kit also accepts an exact conversation rollout held open by the +managed app-server process, and refuses the association when that server has +zero or multiple candidate conversations. + +## Accounts -A terminal number stays with the session for the current host boot. It is not a -durable provider identity. +```text +sp account list +sp account enroll <claude|codex> <alias> <email> +sp account verify <claude|codex> <alias> +sp new <claude|codex> [project-alias] --account <alias> +``` -## List, inspect, and search +Enrollment creates a separate provider profile. Claude Code profiles use +separate `CLAUDE_CONFIG_DIR` roots and Codex profiles use separate `CODEX_HOME` +roots. Complete the provider's normal sign-in, then run `verify`. Session Kit +does not ask for, copy, or show the provider token. + +The home screen shows a short account alias in an aligned column beside `CLD` or +`CDX`. Session detail shows the bound email and provider-supplied plan, and `sp +account list` includes the last verification time. The account-choice screen +shows current Matrix health, usage, and any provider-qualified recommendation. +Missing details remain `unknown`; Session Kit does not estimate them. + +To change the account for an existing Claude or Codex conversation, open the +session's actions in the picker and choose `Change account`. It does not take +over a session that is open in another window. Choose a verified account; the +switch runs on Enter and the line says what changed. Nothing is asked first, +the evidence below is taken before the switch, not after a question. + +Session Kit proceeds only when fresh evidence proves the exact conversation has +no active turn, tool, hook, subagent, child agent, or background provider work. A +provider waiting for your reply is idle enough to switch. Session Kit transfers +only the exact conversation artifacts, resumes the same provider UUID under the +selected profile, and verifies it before changing the displayed alias. The +conversation history, title, project, color, and session number stay the same. +If the target resume or account check fails, Session Kit restores the private +checkpoint and original binding, then tries to resume the exact conversation on +the original profile. It reports the exact boundary if rollback cannot be +proved. + +A session created before account profiles existed needs a one-time managed-shell +recreation for its first account change. Session Kit explains this, then proves +the conversation has no active work before it starts. The original window +briefly reconnects, but the exact +conversation UUID, history, title, project, color, and session number remain. +Later changes use the account-aware shell and do not repeat that migration. + +One account change is automatic. When the usage feed shows the account a live +conversation is signed in to has no weekly quota left, Session Kit carries that +conversation to another enrolled account and tells you afterwards. It happens +at most once per conversation: a session that spends a second account stops and +reports rather than walking to a third. It never moves into an account with +less than a quarter of its weekly window left, and it never moves at all while +the usage feed is stale or unreadable. If nothing qualifies, it stands down and +waits for you. + +Four things it will never do on its own: switch an account back on that you +switched off, move a session whose live account disagrees with its record, +silently drop a model you asked for by name (the handoff carries the same model +record), or leave a failed move needing recovery, if the handoff does not +prove itself, the conversation stays where it was. + +If a message about a move cannot be delivered, it stays owed and is retried +rather than being marked as sent. + +`sp account-auto-switch <session>` says what would happen to one session and +changes nothing. Add `--apply` to actually move it. The periodic check is part +of the watchdog, which passes `--apply` itself; that only happens when the +watchdog is in `SESSION_KIT_WATCHDOG_MODE=repair`, the same setting that lets +it act on a live session at all. Only one pass at a time may run on a machine. + +When the +owner-only `$XDG_STATE_HOME/session-kit/account-switching-off` sentinel exists, +Session Kit disables enrollment, new account selection, manual switching, and +automatic switching, without deleting profiles, changing provider-owned login +data, or stopping running providers. Its default path is +`~/.local/state/session-kit/account-switching-off`. Nothing in Session Kit +creates that file for you. + +## Find and inspect ```text sp list -sp detail <terminal-number|shpool-id> +sp detail <session-number|shpool-id> sp find <text> sp health ``` -Provider roots outside shpool appear in a separate read-only view. They have no -terminal number and cannot be opened through Session Kit. +`sp detail` also lists each live child shell or worker that has been running +for at least one hour. Its age appears on the same row. This reuses the normal +session process inventory; a recorded child conversation with no exact live +process is not given an estimated age. + +Sessions outside the kit appear in a separate read-only view. They have no +number and cannot be opened here. ## Open or move ```text -sp go <terminal-number|shpool-id> -sp takeover <terminal-number|shpool-id> +sp go <session-number|shpool-id> +sp takeover <session-number|shpool-id> ``` `sp go` opens a detached exact managed session. `sp takeover` moves an attached -session to the current terminal after a fresh identity check and confirmation. -The earlier window returns to its picker; the provider conversation is not -duplicated. +session to the current terminal after a fresh identity check; the earlier window +returns to its picker, and the provider conversation is not duplicated. -## Provider exited - -When Claude Code or Codex exits normally, the managed shpool terminal stays -alive. The dashboard labels it as provider exited. - -Open the row to reach this menu: +## Names ```text -r reopen the exact conversation -k keep the terminal and disable automatic cleanup -s open an ordinary shell and permanently exclude the terminal from cleanup -c close the terminal -``` - -Do not use a generic latest-conversation command. If exact identity cannot be -proved, reopen is disabled. From an ordinary managed shell, `keep_session` -disables automatic cleanup, `unkeep_session` allows it to resume after all -safety conditions are met, and `bye` closes the terminal only after exact-ID -confirmation. A directly typed Bash `exit` keeps its normal shell behavior. - -## Name - -```text -sp name <terminal-number|shpool-id> <title> -sp name reset <terminal-number|shpool-id> +sp name <session-number|shpool-id> <title> +sp name reset <session-number|shpool-id> ``` Title priority is: @@ -103,71 +358,566 @@ Title priority is: 5. shortened identity fallback; 6. shell command or `Idle shell`. -Reset removes only the local manual name. +Reset removes only the local manual name. It does not hand the session back to +automatic naming: once you have named a session, nothing renames it for you. + +Renaming inside the provider counts too. `/rename` in Codex or Claude is read +back, becomes the session's name everywhere Session Kit shows it, and is kept +as a name you chose, so no automatic pass restores what it replaced. It also +wins over an earlier `sp name`: Session Kit knows the last title it pushed into +the provider itself, so a provider title that differs from it was typed after +that push, and the newer of your two names is the one you meant. Your capitals +are yours; a name is stored and shown exactly as you typed it. A session renamed +this way before you upgraded settles itself the first time the kit reconciles; +you do not have to rename anything twice. + +## Display setup + +### Recommended terminal: Ghostty + +Session Kit is developed and tested against Ghostty. Stock Ghostty already +supports the title pushes and truecolour sequences the kit uses, so it needs no +display-related setting. Keep your own font, theme, shell-integration, and +window settings. -## Kill +Any truecolour terminal can run Session Kit. The key-driven picker writes +24-bit colour and uses synchronized terminal output during repaint. The +cursor-driven picker falls back to a smaller palette when the terminal reports +fewer than 256 colours. Set `NO_COLOR=1` or `SESSION_KIT_NO_COLOR=1` for plain +text; state words and labels keep the same meaning. -From the picker: +### Claude Code status line + +Installation places `config/claude/statusline.sh` at +`~/.claude/statusline.sh` and registers it in every managed Claude profile with +a two-second refresh interval. Line 1 is: + +```text +<session name>, [MODEL] user@host:/directory | N% context +``` + +The session name uses its kit colour. The inventory lookup for that tint uses +the default `~/.local/state/session-kit/inventory.json`; a custom +`SESSION_KIT_STATE_DIR` or `XDG_STATE_HOME` therefore gets the safe bold-name +fallback. The rest of the line still works. + +Line 2 is an extension point: + +```text +5h N% <time left> · 7d N% <time left> · account@example.invalid +``` + +Without a valid cache it reads `quota --` and leaves line 1 intact. An +executable `~/.claude/statusline-quota-refresh.sh` may fill the cache. Session +Kit calls it in the active profile, no more than once every 180 seconds and in +the background. It must write `$HOME/.claude/cache/quota_headers` as `key: +value` lines containing: + +- `x-probe-account`, matching the profile email; +- `anthropic-ratelimit-unified-5h-utilization` and + `anthropic-ratelimit-unified-5h-reset`; +- optionally the matching `7d` utilization and reset fields. + +The installed example is +`~/.local/lib/session-kit/current/extras/statusline-quota-refresh.example`. +It deliberately leaves the vendor endpoint as a TODO and makes no network call +until you supply one. Copy it beside the status line only after filling that +TODO; the core kit never handles credentials or makes this request for you. + +The status-line quota segment uses Linux utilities, including GNU `stat`; the +name, title, and colour integrations do not depend on it. + +### Claude attention evidence + +The optional `attention_hook.sh` makes `needs you` react to Claude events +between polls. It is hand-deployed: install, update, rollback, and uninstall do +not copy, register, or remove it. Register the installed canonical copy for +Claude's `Notification`, `UserPromptSubmit`, and `SessionEnd` events, as shown +in [the Claude integration contract](../config/claude/README.md#how-the-kit-learns-a-session-is-waiting-for-you). +`SESSION_KIT_ATTENTION_SOURCE=auto` merges hook and polling evidence; `poll` +ignores hook records, and `hook` is intended for drills. + +### Codex status bar + +Session Kit does not configure or replace the Codex status bar. Codex owns that +surface. For each managed launch, the kit passes two command-line overrides: +the installed `tui.terminal_title` items (`activity` and `thread` by default) +and the session's `sk-<colour>` theme. These options apply to that process only +and never edit `~/.codex/config.toml`. + +A new Codex process can start before the conversation has a name, so its own +bar may temporarily show an identifier. Opening a detached, proven-idle +provider can refresh it automatically. For an attached provider, the +key-driven picker offers the guarded refresh action only when the exact process +is idle, has no subagents, and has a stored title. That maintenance action is +not exposed by the cursor-driven action panel. An App Server refresh restarts +only the generation-bound remote TUI, not the server or conversation. + +### Terminal window and tab titles + +`sp go`, `sp takeover`, `sp new`, and picker open or move push the session name +with the standard OSC 0 terminal-title sequence. The kit strips control bytes, +collapses whitespace, and caps the title at 64 characters. Returning to the +picker restores `session kit`. A restore does not claim the tab before the +session is actually opened. + +Claude may update its own title at SessionStart or after the next submitted +prompt. Codex receives `activity` and `thread` as per-launch title items. Set +`SESSION_KIT_TAB_TITLE=off` to disable both the kit's title pushes and the Codex +override; Session Kit deliberately does not set Claude's vendor title-disable +switch because that also suppresses hook-provided titles. + +### Session colours + +Every Claude Code and Codex session carries an identity-derived colour on its +picker row and in provider chrome. Shell sessions have none. Collisions walk +the provider's palette until every colour is in use: + +- Claude: red, blue, green, yellow, purple, orange, pink, cyan; +- Codex: lime, magenta, silver, sand, sky, sea. + +The palettes are separate, so a Claude and Codex session never share a kit +colour. Codex receives an installed theme with a per-launch override. Claude +receives name and colour transcript records before its first visible frame; +an explicit provider `/rename` or `/color` remains authoritative. + +To settle existing live assignments after a palette or authority change: + +```bash +sp color reconcile +``` + +The command moves only sessions that need to move and is safe to repeat. A +running Claude window applies a changed provider colour at its next start or +resume. Picker semantics never depend on colour alone. + +## When a provider exits + +A clean exit closes the session. `/exit` inside Claude, the provider +ending with status 0, ends the shell with it, returns the session number to +the ordinary quarantine, and lands you at the picker. The conversation is in +Closed sessions, and one Restore brings it back. There is no opt-in marker: one +policy, on every machine. + +A crash is different. On a non-zero status the conversation reopens itself +once, with a one-line notice saying so. A second crash within a minute stops +the loop, so a conversation that cannot start cannot spin. What happens then +depends on one question, and the session is told which way it went: + +- if the conversation can be brought back, the session closes and lands in + Closed sessions under its own name, exactly as a clean exit does; +- if it cannot, no conversation was ever recorded, its transcript is gone or + unreadable, the Closed list could not be written, or you had marked the + session `keep`, the session STAYS OPEN and says which of those it was. + Closing it would be the one move that loses the conversation for good, so + the kit leaves it for you to close from the picker. + +A reopen that is REFUSED rather than run never closes the session, whatever the +reason: a refusal is not evidence that a conversation is finished, and one of +the reasons is that a provider is still running in that terminal. + +Nothing asks you to choose. + +Two keys end your time in a session, and the difference between them is the +whole point. + +**Ctrl-Q leaves.** The work keeps running and you land back at the picker. The +session's number brings you back to the same live conversation later. It is the +same outcome as closing the terminal window without typing anything, without +having to close a window to get it. Ctrl-Q works in every session, whichever +provider is running, because the session manager handles it before the provider +ever sees the key — including in a window where the provider has stopped +answering the keyboard at all. + +**Ctrl-D closes.** The provider exits, the session ends, its number returns to +the ordinary quarantine, and the conversation lands in Closed sessions where one +Restore brings it back. Codex and a shell session close on a single press. +Claude Code answers the first press with `Press Ctrl-D again to exit`. That +confirmation belongs to Claude's exit action rather than to the key, so the kit +cannot configure it away, and binding the same action to a different key keeps +the identical prompt. + +In Claude each key has a typed twin: `/kit` does what Ctrl-Q does, and `/exit` +does what Ctrl-D does, closing on the first press with nothing to confirm. Codex +has neither, so in Codex it is the keys. + +Session Kit puts the Ctrl-Q binding in the shpool config it writes for a fresh +setup; shpool's own name for the action is `detach`. It is not shpool's default, +stock shpool detaches on the two-key chord **Ctrl-Space Ctrl-q**, so if you +already had a shpool config before installing Session Kit, yours was left alone +and Ctrl-Q may do nothing there. +Add this to `~/.config/shpool/config.toml`. shpool compiles keybindings once +per attachment, so it needs no daemon restart, but it reaches only windows you +open or REOPEN after the edit. A window you are already sitting in keeps the +binding it started with, which is why Ctrl-q can work in one of your terminals +and do nothing in the one next to it: + +```toml +[[keybinding]] +binding = "Ctrl-q" +action = "detach" +``` + +Why there is no `/kit` in Codex: in codex-cli 0.145.0 the slash namespace is +built-in commands only. Its app-server protocol has no channel for a custom +command, `~/.codex/prompts/` is not read at all, and a file installed as a +skill is discovered but never appears under `/`. An older Codex did honour +`~/.codex/prompts/`, which is why this page used to say so. If a future Codex +brings custom slash commands back, measure it before believing it, +`tests/test_install.py` pins what was measured. + +If you installed an earlier release, `~/.codex/prompts/kit.md` is still on +disk. Nothing reads it and nothing runs it, so it is clutter rather than a +risk, and the installer deliberately does not delete it: an upgrade that +removes files from a provider's own home is a worse habit than an inert +leftover. Delete it yourself whenever you like. + +The kit installs the Claude verb into `~/.claude/commands/kit.md` AND into +every enrolled Claude account profile under +`~/.local/share/session-kit/accounts/`, because a session launched on an +account reads that account's commands and nothing else. It is kept with the +release. It runs one line, `shpool detach`, which detaches the session named +in `$SHPOOL_SESSION_NAME`. A provider that is not installed on the machine gets +nothing, an existing `kit.md` that is not a plain file is left alone, and the +account profile a session happens to be using is never written to. + +Only a typed `/kit` ever leaves a session. Claude Code treats a file in `commands/` as +a skill it may invoke on its own when the conversation looks relevant, so the +verb carries `disable-model-invocation: true`: the description is not placed in +the model's context at all, and nothing but the typed command can fire it. +Without that, typing the bare word `kit` as an ordinary message was enough to +empty a terminal mid-turn. + +Do not use a generic latest-conversation command to come back. If exact +identity cannot be proved, reopen is disabled rather than guessed. + +From an ordinary managed shell, `keep_session` disables automatic cleanup, +`unkeep_session` allows it to resume once all safety conditions are met, and +`bye` closes the session immediately, no question, and recoverable from Closed +sessions like any other close. A directly typed Bash `exit` keeps its normal +shell behavior. + +## Close a session + +In the picker, type the numbers you want, press Enter, and choose Close. From a +shell: ```text -k <number> +sp close <session-number|shpool-id> ``` -From a shell: +Before closing, Session Kit refreshes live evidence and resolves every number to +one exact shpool ID. Cached inventory, changed generations, hidden numbers, or +ambiguous identity disables the whole action rather than part of it. Nothing is +confirmed first, and the line afterwards says what happened: `Closed 3 +sessions.` + +Every deliberate close is written to a ledger, provider, conversation, title, +directory, and the time, and the conversation is listed under Closed sessions +whenever it is not already open. The retained row is hidden while the exact +conversation is live and reappears if that live session is later lost. That is +what makes a confirmation unnecessary: a close you did not mean is one Restore +away. + +Closing ends the session. It does not delete provider-native conversation +history or optional journal files. + +## Change the model ```text -sp close <terminal-number|shpool-id> +sp change-model <session-number|shpool-id> <model-id> ``` -Before killing, Session Kit refreshes live evidence, resolves the number to one -exact shpool ID, displays the title, provider, and exact ID, and requires -confirmation. Cached inventory, changed generations, or ambiguous identity -disables the action. +The exact conversation comes back on the model you named, through the same +launch path `sp new --model` uses. In the picker it is the `Change model` +action, which offers the models you have configured: one `provider<TAB>model` +line each in `~/.config/session-kit/models.tsv`. Session Kit ships no model +catalog and never guesses a model name. The picker shows only identifiers the +same provider-specific launch gate accepts (`claude-...` for Claude; `gpt-...`, +`o3...`, `o4...`, or `codex-...` for Codex), so every offered choice can reach +the model-change safety checks. + +### The model you chose is the model you get + +**Session Kit never changes a model by itself, and says so before a session +starts rather than after it has run on something else.** Both `sp new --model` +and `sp change-model` ask one more question before anything launches: does this +machine really serve that model? Two local answers exist, and the first that +applies wins: + +1. **What was served last time.** When something reads the model a session is + really on, it records the pair with `session-kit`'s `model-served` verb. A + recorded answer that differs from the request is a silent downgrade, and the + next launch refuses it by name. +2. **Your `models.tsv`.** A model that is not on this machine's list is + refused, and the refusal prints the list. + +With neither, the answer is `unknown`, said as unknown, never as verified, and +the session starts exactly as asked. + +A refusal names the model you asked for and what would serve it instead, and +nothing starts. Nothing is ever substituted: `--model <what-serves-it>` takes +the other model, and `--model-anyway` repeats the request as it was. + +```text +sp new claude myproject --model claude-fable-5 +You asked for claude-fable-5. +The last session that asked for it actually ran on claude-haiku-4-5 instead. +Start it on claude-haiku-4-5 with --model claude-haiku-4-5, or repeat the +command with --model-anyway to ask for claude-fable-5 anyway. +session-kit: no session was created +``` -Killing ends the managed terminal. It does not delete provider-native -conversation history or optional journal files. +A session that is already running is never restarted by this check, it applies +to a launch and to a model change, not to recovery. -## Cleanup +## Isolated workers -The scheduled observer considers only disconnected provider-exited terminals. -Automatic close begins only after the cleanup timer is enabled. A candidate +**Delegated work gets its own copy of the code.** A machine session, one +started with `--origin machine`, or with `SESSION_KIT_ORIGIN=machine` in the +environment, is given a git worktree of its own without asking, on a branch +the kit cuts for it, so two workers on one repository never edit the same +files. A directory that is not a git repository has nothing to isolate: the +session runs in it directly and says so. An automation that means the checkout +itself says `--no-worktree`. + +A person's own session is never moved out of the directory they chose. A +worktree is theirs to ask for, by branch name: + +```text +sp new claude --worktree <branch> +sp new codex myproject --worktree <branch> +``` + +The branch is materialized as a worktree under the state directory +(`worktrees/trees/<project>-<8 hex>/<branch>`, with any `/` in the branch name +written as `-`; the hex tells two projects of the same name apart, and +`sp worktree list` prints the exact path). The session starts there. The call +is idempotent per repository and branch: starting a second session on the same +branch reuses the same directory rather than forking a second one. A branch +already checked out somewhere else, or a project that is not a git repository, +refuses the launch, no session is created. + +Rows for isolated sessions carry their branch in the picker and in `sp list`, +and `sp detail` shows it as `Worktree`. + +```text +sp worktree list +sp worktree materialize --repo <directory> --branch <branch> +sp worktree teardown --branch <branch> [--merged-into <ref>] [--force] +sp worktree sweep [--active <shpool-id>]... | --none-alive | --active-stdin +``` + +**Closing the session gives the copy back.** Every close does it, `sp close`, +the picker's `k`, `bye` or a clean provider exit, and the reaper's scheduled +pass, so nobody has to remember a teardown. + +**`git status` is not the question.** Before a copy goes, everything work can +be is checked, and any one of these keeps it, with the reason said out loud: + +- uncommitted, staged or untracked files; +- **files the repository ignores**, a log, a screenshot, a report, a `.env`, a + one-off script. A fresh copy starts with none, so every one of them was made + by whoever worked there; +- the commit the copy is actually standing on not being in the reference + (`HEAD` by default), its **real** HEAD, so a commit made on a detached HEAD, + which lives nowhere but that copy, is never removed with it; +- a stash made from it; +- a merge, rebase, cherry-pick, revert or bisect left half-finished; +- a submodule with anything of its own in it; +- anybody still working in the directory, named by pid; +- the checkout there not being the one recorded, a directory made by hand + where a copy used to be is somebody else's; +- a copy the kit did not cut for that session: a worktree asked for by branch + name is the operator's, and so is every worktree that was on the machine + before this shipped. Those are removed by `sp teardown` and nothing else; +- **a question git could not answer.** Every check above exists to prove the + work is safe, so one that could not run has not passed. An unreadable HEAD, + a `git stash list` that failed, a repository that has gone missing, a + comparison that errored, a process table that could not be read: each is + named and each keeps the copy. + +`--force` covers uncommitted, staged, untracked and ignored files, a stash, a +submodule, an unmerged branch, and a check that could not run. It does **not** +cover: + +- a commit on no branch that is not *proved* to be in the reference, the copy's + own reflog is the only thing that reaches it, and removing the directory takes + that reflog with it; +- a half-finished merge, rebase, cherry-pick, revert or bisect, the replay + state lives only in the copy. Finish it or undo it there first; the refusal + names the command; +- a checkout that is not the recorded one; +- a repository the kit may not write into (below); +- a live session standing in the directory. + +A copy kept because a worker was still writing is not kept forever: the reaper +runs the sweep against the live session list on **every** scheduled pass, +including the ones with no session to close, which are most of them. Run it +yourself with `sp worktree sweep --active <shpool-id>…`; with no `--active` and +no `--none-alive` it refuses, because a sweep that is told nothing must never +read that as "nothing is alive". `--active-stdin` is the machine form: it reads +a `shpool list --json` payload and refuses outright on anything it cannot fully +account for, so a reader that broke can never look like an idle machine. A copy +whose session was never recorded is reported and left alone however old it is, +age is not ownership. + +**A checkout that is the running thing is never copied.** Some directories are +served live, and a copy of one is work that never takes effect, the session +edits the copy, the site keeps serving the original, and the check that says +"done" reads the old page. + +Three ways a repository is recognised as one of those, and the first needs +nobody to have configured anything: + +- **it is under a system root**, `/srv`, `/var`, `/opt`, `/usr`, `/etc`, + `/root`, `/run`, `/bin`, `/sbin`, `/lib*`, `/boot`, `/dev`, `/proc`, `/sys`, + or `/` itself. That is where a machine keeps the software it runs. This is on + by default, deliberately: a guarantee that has to be switched on is not one; +- it carries a `.session-kit-shared` file at its root; +- the host lists it in `SESSION_KIT_SHARED_REPOS` (colon-separated absolute + paths). + +`SESSION_KIT_COPYABLE_REPOS` (same format) is the way to say a particular +directory *is* yours to copy, and it overrules all three. + +A delegated session in one of these runs in the checkout itself and says why. +Nothing is written into such a repository, no branch, no worktree +administrative directory, no removal, and no session ever edits a copy of it by +accident. The same rule applies to a linked worktree of one: a worktree +resolves to the repository it belongs to, so it is covered by whatever covers +that repository. + +**No repository-wide prune, ever.** Giving a copy back unregisters exactly the +one entry that names that directory. `git worktree prune` is repository-wide +and drops the administrative directory, index, HEAD, reflog, half-finished +rebase, of *every* worktree of that repository it cannot reach at that moment, +including one on a volume that simply is not mounted right now. + +When you want the close and the prune in one verb: + +```text +sp teardown <session-number|shpool-id> [--merged-into <ref>] [--force] +``` + +`--force` removes a directory that is dirty or unmerged anyway. It does not +override a live session standing in it, and neither form ever deletes the +branch, the commits outlive the worktree. + +## Automatic cleanup + +This is a BACKSTOP, not the ordinary route. A session whose provider exits +closes itself at once (see "When a provider exits" above), so a terminal that +reaches the 72-hour window is one the shell could not close: a session started +before this release landed, a close that failed, or a session the manager still +lists after its shell is gone. Two shapes are deliberately outside it and stay +visible until you close them from the picker, a terminal with no provider-exit +record, and one with no exact conversation, because a row whose state cannot +be proven is never cleaned up quietly. + +The scheduled observer considers only disconnected provider-exited terminals, +and automatic close begins only after the cleanup timer is enabled. A candidate then needs 72 continuous hours with every exact safety predicate unchanged. -Attachment, a live provider, child work, pending reply, recovery conflict, -identity change, missing evidence, or a new terminal generation resets or +Attachment, a live provider, child work, a recovery conflict, +an identity change, missing evidence, or a new terminal generation resets or blocks eligibility. -`sp prune` performs fresh checks and confirmation for a manual cleanup. +`sp prune` runs fresh checks for a manual cleanup. ## History and recovery ```text -sp history <terminal-number|shpool-id> +sp history <session-number|shpool-id> +sp find <text> sp recover +sp recover --allow-large-ledger +sp restore --allow-large-ledger <number> ``` -History requires journals to have been explicitly enabled for that session. -Recovery uses an exact Claude Code or Codex UUID and never falls back to -recency, directory, or “latest.” - -An active UUID cannot be resumed into a second writable terminal. Open the -existing terminal or create an explicit fork. +History is the readable record of a session, and it is an action on every row +in the picker. Where a recording exists it is replayed as settled text in a +pager; a session with no recording, recording is off unless you turned it on, +and a session started without it can never be recorded after the fact, falls +back to the conversation transcript and says so on the line above the text. +Claude and Codex transcripts use the same layout for operator messages, model +messages, tool calls, and tool output. + +`sp find` searches stored recordings case-insensitively. Each recording keeps +its own result group and example-line allowance. A current or recoverable +session uses its recorded name; older recordings without trustworthy title +metadata use their start date and first readable request instead. Storage paths +and internal session identifiers are never printed. When recordings have the +same display name, a recording ordinal keeps their result headers distinct. +With no result it prints +`Matches: none.` A damaged recording is skipped with a one-line notice while +the rest of the search continues. + +Clean recordings are prepared incrementally in the background. If the clean +copy trails its capture by more than the two-second live-write grace period, or +an on-demand refresh fails, `sp history` +says that the last readable version is being shown. If no clean version can be +produced, it says that the recording as captured is being shown. These are +successful recall notices, not errors; redirected stdout still contains only +the recalled text, with the notice on stderr. + +Restoring a closed conversation is the Closed sessions row in the picker, or +`sp restore` at the command line. Both read one list, so they show the same +conversations in the same order under the same names, and a row is acted on by +what that row shows: its session number, the one it has on every other screen +, or, when its number has been retired, the name beside it. Two rows that +answer to one name (everything nobody named reads "unnamed") each show the +time of their own event where the number would be, and that is what you type. +A word belongs to one row on the whole list or to none of it: a conversation +somebody named `2` does not answer to `2` while session 2 exists, and shows +the time of its own event instead. + +A name and an event time belong to the list rather than to the conversation, +so they do move to another row when the list is rebuilt, that is what they +are for, while a name is shared. What cannot happen is one moving between the +screen you read and the restore you type. Each screen writes down what it +printed beside each row; `sp restore` checks the word you hand it against that +and refuses in a sentence, restoring nothing, if it now means a different +conversation. The picker draws and acts out of one list, so a word typed there +always answers for the row in front of you. If a screen cannot be written +down it says so, because the check is missing rather than passing. + +A restore still resolves an exact Claude or Codex conversation identity and +never falls back to recency, directory, or "latest". One conversation cannot be +open in two writable sessions at once: open the session that has it, or fork it +deliberately. A live conversation is never offered. A shell session cannot be +resumed at all, its history is all that remains of it, and neither can a +conversation whose transcript this machine can no longer read; both are listed, +and both say so in a sentence rather than offering a restore that cannot +work. + +Normal refreshes stream and completely validate the durable Closed sessions +ledger. A pathological file above the normal safety ceiling is never shown as +an empty or partial list: recovery exits non-zero, names the exact size and +ceiling, and prints the command above. The explicit large-ledger form applies +the same complete row validation without the unattended-refresh ceiling; its +footer gives the matching restore command for the numbers it prints. ## Repair ```text -sp repair <terminal-number|shpool-id> +sp repair <session-number|shpool-id> ``` Repair is reserved for direct evidence of an unrecoverable shpool handoff -failure. The installed watchdog reports evidence and does not repair in its -default mode. Its advanced repair mode is a separate explicit opt-in with -terminal-state risk. Silence alone does not qualify. +failure. Silence alone does not qualify. + +The installed watchdog reports evidence and does not repair in its default mode. +Its advanced repair mode is a separate Linux-only opt-in that carries +terminal-state risk. The macOS watchdog is report-only. + +If *every* session becomes unreachable at once rather than one, that is a +different problem and repair will not help. See +[Troubleshoot](troubleshooting.md). -## Noninteractive confirmation +## Automation -Mutations are interactive by default. Noninteractive close requires an explicit -opt-in and the exact shpool ID: +Noninteractive close requires an explicit opt-in and the exact shpool ID: ```bash SESSION_KIT_NONINTERACTIVE=1 \ @@ -175,4 +925,6 @@ SESSION_KIT_CONFIRM_ID=<exact-shpool-id> \ sp close <exact-shpool-id> ``` -Do not automate moves, kills, repairs, or cleanup with display numbers. +Never automate moves, kills, repairs, or cleanup using display numbers. They are +stable within a boot, but they describe what is on screen, not which +conversation you mean. diff --git a/docs/voice.md b/docs/voice.md new file mode 100644 index 0000000..2a3af10 --- /dev/null +++ b/docs/voice.md @@ -0,0 +1,128 @@ +# Session Kit voice, the one way every surface speaks + +This file is the contract for every string the kit can show a person: +picker, `sp` output, in-session lines (bashrc), installer, doctor, watchdog, +reaper, completion, help, docs. Agents building any surface use these words +and only these words. Tests may grep this file. + +## Terms (one per concept, no synonyms anywhere) + +| Concept | The word | Never again | +|---|---|---| +| A managed terminal + what runs in it | session | managed terminal, thread, conversation (see below), row | +| The exact provider chat a session carries | conversation | thread | +| The screen that lists sessions | the picker | session picker, login chooser, dashboard, session list | +| A session waiting on the person | needs you | waiting on you, needs your reply, wants you, Needs a | +| A session the model is driving | working | none | +| When a session last did something | last active | recent output, process age, opened, last response | +| The model a session runs | its product name (`Opus 5`, `GPT-5-Codex`) | the raw identifier (`claude-opus-5`), unless the kit does not recognise it, in which case the identifier is shown rather than a name being invented | +| Openable in this window | ready | available, ready to open, available to open | +| Attached in another window | open elsewhere | already open in another window/SSH window | +| Sessions the kit does not manage | outside the kit | outside shpool, other provider sessions, provider roots | +| Machine-origin sessions (drills, workers) | machine sessions | throwaway, worker rows (in UI copy) | +| Full readable record of a session | history | clean history, terminal history, raw history, journal replay | +| End a session | close | end, teardown (`kill` is allowed only in a footer key hint such as `kill k #`) | +| Bring a closed conversation back | restore | recover, resume (UI copy; CLI verbs unchanged) | +| Start a session | new session | create, launch, start (as UI nouns) | +| The directories new sessions offer | projects | none | +| The daemon that keeps sessions alive | the session manager | shpool | + +Provider names are always `Claude` and `Codex`; a plain shell is `shell`. +`shpool` never appears in UI copy; on screen the daemon is the session +manager. Stall reasons, `unsurfaced`, `unanswered`, `silent`, are degrees +of **needs you** and render as that one state. + +There are four readable state words, in priority order: `question`, `needs you`, +`working`, and `idle`. `question` means Claude has a blocking prompt open right +now; Codex rows do not claim it until the provider supplies equally exact +evidence. `needs you` is the person's turn. `working` is the model's turn. +`idle` is a needs-you session whose transcript has not changed for the full +configured window. The older notification-derived `idle` was retired because +Claude and Codex described the same parked prompt differently. That reasoning +still stands: the new `idle` is provider-neutral transcript-movement evidence, +never a vendor notification. `pending` is not a state; it is the placeholder +for a value the kit cannot currently read. An unreadable idle-window setting +disables `idle` instead of guessing a duration. + +The mapping from collector evidence to those words is **total**. A status this +contract does not name, the collector's own `unknown`, or a word a future +provider release invents, reads as `pending`, never as itself. A collector +word has never been screen copy, and the day one reaches a row is the day this +contract has been broken. + +A session row's state, nonzero subagent count, and one time are protected +before its metadata and name. The count sits directly after the state and +before the time. As space narrows, the time shortens to an identifiable token +such as `3h` or `now`; at the narrowest status tier it may disappear. The state +word remains first and outranks the count. + +A session row carries exactly one time, and it answers one question: when did +this last do something. Two times on one row meant every row truncated at a +different point and the column stopped being a column. The process age is on +`sp detail`, where there is room for it. + +That detail view also gives each live child shell or worker at least one hour +old its own row, with the child's age on that row. A child conversation without +an exact live process does not get an estimated process age. + +Session order is one contract too: ready before open elsewhere; within each, +`question`, `needs you`, `working`, then `idle`, followed by provider, newest +activity, and the +stable session identity. The shell picker, `sp list`, and the TUI do not invent +their own tie-breaks. + +## Grammar (one form per interaction) + +- **Refusal:** state the fact, then the way forward, in one line. + `There is no session 20 on this screen. Numbers shown here work.` + Never bare imperatives ("Choose a number shown here"), never three styles. +- **Nothing happened:** exactly `Nothing changed.`, the only cancel wording. +- **Action confirmations (post-fact):** name what happened, past tense, one + line: `Closed 3 sessions.` `Restored Release notes review.` Every action + says what it did; no silent successes. +- **Errors:** `session-kit: <fact>.`, one prefix, stderr, exit codes per + `sp help exit-codes`; a no-match selector is always exit 2 with one + message: `session-kit: no session matches that selector`. + Screen copy is the UI: it prints unprefixed on stdout. Only a genuine + failure takes the prefix, and it goes to stderr. +- **Menus:** rows are `<thing>` plus an optional short clause. The default + row is marked; Enter takes it. No bracketed letters, no `[y/N]`, no typed + words, no confirmation steps of any kind, anywhere. +- **Going back and going on:** one grammar on every screen. `b` goes back, + and Enter takes the recommended choice, the same key meaning the same + thing from the home screen to the provider, project, and move-here choices. + Home Enter opens the top row; when the list is empty it starts a new session. + The provider chooser defaults to Claude Code. A session open elsewhere + defaults to moving it here. A prompt taking a typed name does not claim `b`, + because there `b` is a name. +- **Columns:** a list whose rows carry fields after a variable-width name pads + that name to one column, measured from the rows actually on the screen and + in terminal cells rather than characters. One wide character is two cells + and one `len()`; counting characters is how the columns and their colours + end up in different places. +- **Empty states:** one form, `<Thing>: none.` A count line disappears at + zero rather than printing `0`. +- **Footer hints:** lowercase and middot-separated. The key-driven home footer + is limited to keys it dispatches and begins `↵ open <number>` or + `↵ new`; as width shrinks it retains segments in this order: Enter, `#`, + kill, new, more, needs you, help, history, leave. Panel footers name Enter + and `b` as the route back where those keys apply. The cursor-driven footer + is an action legend: printable letters still enter the live filter unless a + panel explicitly binds them, so its labels are not promises of direct + one-key dispatch. +- **Tone:** short declarative sentences, present tense, name the thing then + the action. No exclamation marks. No jargon a first-time user would have + to look up. The same sentence never appears in two lengths on two screens. + +## Hard rules carried from the maintainer's decisions + +- No confirmation ever, for anything (D7). Guards run before actions. + Mistakes are handled by restore, not by asking first. +- Enter always takes the marked default (D7). +- Typing digits marks sessions; any letter makes the input a live filter + (D9, D12). Esc clears typed input, then steps back through the current + screen, and leaves only after there is nothing left to clear or go back from. +- The default picker view lists only sessions the person started; machine + sessions sit behind one counted row (D17). +- No screen prints a shpool ID or conversation UUID (existing invariant, +`sp help selectors`), including `bye` and every close line. diff --git a/extras/build-static-binary.md b/extras/build-static-binary.md new file mode 100644 index 0000000..bd981ee --- /dev/null +++ b/extras/build-static-binary.md @@ -0,0 +1,43 @@ +# Getting a static shpool binary + +The kit is built and tested against shpool 0.11.0, and the install preflight +refuses any other version (`lib/sh/session_kit_checks.sh`). Pin the version in +both routes below, an unpinned `cargo install shpool` fetches whatever is +current on crates.io, and the preflight then rejects it. + +**1. cargo**, if the server has a Rust toolchain: + +```bash +cargo install shpool --version 0.11.0 --locked +``` + +**2. Container build**, when the server has no compiler. A throwaway +container produces a fully static musl binary and copies it out; nothing is +installed on the host. Tested with shpool 0.11.0: + +```bash +mkdir -p ~/shpool-build +docker run --rm --cpus 2 -v ~/shpool-build:/out rust:alpine sh -c ' + apk add -q musl-dev && + cargo install shpool --version 0.11.0 --locked --quiet && + cp /usr/local/cargo/bin/shpool /out/ && + /out/shpool version +' +mkdir -p ~/.cargo/bin +install -m 755 ~/shpool-build/shpool ~/.cargo/bin/shpool +~/.cargo/bin/shpool version # -> shpool 0.11.0 +``` + +The shpool GitHub releases page may carry prebuilt archives for several Linux +architectures and C-library targets, but Session Kit accepts only shpool +0.11.0. Use one of the two pinned routes above unless an official archive is +that exact version and you verify it before installation. + +Podman works the same way: replace `docker run` with `podman run`. The build +needs no privileges beyond running a container, and the container is discarded +when it exits. + +The container route yields a static-pie executable for the container host's +architecture (check with `file`), independent of the host's glibc version. +Session Kit does not require `~/.cargo/bin/shpool`: installation records the +absolute result of `command -v shpool` in the generated service definition. diff --git a/extras/notify-chat-card b/extras/notify-chat-card new file mode 100755 index 0000000..2b0617b --- /dev/null +++ b/extras/notify-chat-card @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Example AWAY transport for SESSION_KIT_NOTICE_AWAY_CMD. +# +# extras/notify-desktop reaches a desktop popup, which is exactly the wrong +# place for a notice about being away. This one hands the notice to a chat bot +# that can reach a phone, without this script knowing anything about that bot: +# it shells out to whatever SESSION_KIT_NOTICE_CARD_CMD names. +# +# export SESSION_KIT_NOTICE_CARD_CMD="/path/to/your/bot post-card" +# export SESSION_KIT_NOTICE_AWAY_CMD="$HOME/.local/bin/notify-chat-card" +# +# The card command is called as: <card cmd> [extra flags] <one text argument> +# +# Two rules this script exists to keep: +# +# * The text is ONE argument, never a shell string. Session titles and alert +# bodies are written by other processes; interpolating them into a command +# line is how a title becomes a command. +# * The exit status is the truth. Non-zero means nothing was reached, and the +# router relies on that: a transport that always returned success is how a +# dropped alert came to be recorded as delivered. +# +# Unknown flags are ignored rather than refused, so the notifier contract can +# grow without silencing every alert. + +set -u + +title="Session Kit" +body="" +notice_class="" + +for argument in "$@"; do + case "$argument" in + --title=*) title=${argument#--title=} ;; + --body=*) body=${argument#--body=} ;; + --class=*) notice_class=${argument#--class=} ;; + *) ;; + esac +done + +card_cmd=${SESSION_KIT_NOTICE_CARD_CMD:-} +if [[ -z $card_cmd ]]; then + printf 'notify-chat-card: SESSION_KIT_NOTICE_CARD_CMD is not set, so nothing was sent\n' >&2 + exit 1 +fi + +# Word-split deliberately: the variable holds a command plus its own flags. +# shellcheck disable=SC2206 +argv=($card_cmd) + +text="**${title}**" +[[ -z $body ]] || text+=$'\n'"$body" +[[ -z $notice_class ]] || text+=$'\n'"(${notice_class})" + +exec "${argv[@]}" "$text" diff --git a/extras/notify-desktop b/extras/notify-desktop new file mode 100755 index 0000000..54c25fb --- /dev/null +++ b/extras/notify-desktop @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Example desktop notifier for SESSION_KIT_WATCHDOG_NOTIFY. +# +# Session Kit never picks a notification channel for you: the watchdog calls +# whatever executable SESSION_KIT_WATCHDOG_NOTIFY names, with +# +# --type=<alert type> --severity=<warning> --title=<subject> --body=<text> +# +# This is the smallest useful implementation of that contract, a desktop +# popup through notify-send on Linux or osascript on macOS. Copy it, point the +# variable at your copy, and change the last few lines to reach a phone, a +# chat room, or an alert hub instead. +# +# export SESSION_KIT_WATCHDOG_NOTIFY="$HOME/.local/bin/notify-desktop" +# export SESSION_KIT_ATTENTION_NOTIFY=1 # opt in to the queue alerts +# +# Unknown flags are ignored rather than refused: the contract may grow, and a +# notifier that starts failing on a new flag would silence every alert. + +set -u + +title="Session Kit" +body="" +severity="warning" +alert_type="session-kit" + +for argument in "$@"; do + case "$argument" in + --title=*) title=${argument#--title=} ;; + --body=*) body=${argument#--body=} ;; + --severity=*) severity=${argument#--severity=} ;; + --type=*) alert_type=${argument#--type=} ;; + *) ;; + esac +done + +# Both fields reach a desktop as data, never as a command line: they carry +# session titles and question text written by another process. +case "$(uname -s 2>/dev/null || true)" in + Darwin) + if command -v terminal-notifier >/dev/null 2>&1; then + exec terminal-notifier -title "$title" -message "$body" \ + -group "$alert_type" + fi + exec osascript -e 'on run {t, b} +display notification b with title t +end run' -- "$title" "$body" + ;; + *) + command -v notify-send >/dev/null 2>&1 || { + printf 'notify-desktop: notify-send is not installed\n' >&2 + exit 1 + } + urgency=normal + [[ $severity != critical ]] || urgency=critical + exec notify-send --app-name="Session Kit" --urgency="$urgency" \ + -- "$title" "$body" + ;; +esac diff --git a/extras/statusline-quota-refresh.example b/extras/statusline-quota-refresh.example new file mode 100755 index 0000000..9bf6ecb --- /dev/null +++ b/extras/statusline-quota-refresh.example @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Example quota refresher for config/claude/statusline.sh. +# +# The status line starts this script in the background. For an enrolled profile, +# HOME is an isolated probe directory whose .claude.json and credentials file +# point at that profile; for the ambient account, HOME is the normal home. +# This script must write $HOME/.claude/cache/quota_headers and nothing to the +# status line itself. + +set -euo pipefail + +cache_dir=$HOME/.claude/cache +account_file=$HOME/.claude.json +output=$cache_dir/quota_headers + +account=$(jq -r '.oauthAccount.emailAddress // empty' "$account_file" 2>/dev/null) +if [[ -z $account || $account == *$'\n'* || $account == *$'\r'* ]]; then + printf 'statusline-quota-refresh: account email is unavailable\n' >&2 + exit 1 +fi + +fetch_quota_headers() { + # TODO: Call the quota endpoint used by your account and print its response + # headers as `key: value` lines. Read credentials from this invocation's + # $HOME/.claude/.credentials.json; do not copy them into the output. No + # endpoint is supplied here because it is not a documented Session Kit API. + printf 'statusline-quota-refresh: TODO: configure a quota endpoint and transport\n' >&2 + return 64 +} + +mkdir -p "$cache_dir" +chmod 700 "$cache_dir" +raw=$(mktemp "$cache_dir/quota_headers.response.XXXXXXXX") +tmp=$(mktemp "$cache_dir/quota_headers.write.XXXXXXXX") +trap 'rm -f -- "$raw" "$tmp"' EXIT +chmod 600 "$raw" "$tmp" + +fetch_quota_headers > "$raw" + +header_value() { + awk -F':' -v wanted="$1" ' + tolower($1) == tolower(wanted) { + sub(/^[^:]*:[[:space:]]*/, "") + sub(/\r$/, "") + print + exit + } + ' "$raw" +} + +u5=$(header_value anthropic-ratelimit-unified-5h-utilization) +r5=$(header_value anthropic-ratelimit-unified-5h-reset) +u7=$(header_value anthropic-ratelimit-unified-7d-utilization) +r7=$(header_value anthropic-ratelimit-unified-7d-reset) + +fraction() { + awk -v value="$1" 'BEGIN { + exit !(value ~ /^[0-9]+([.][0-9]+)?$/ && value >= 0 && value <= 1) + }' +} + +epoch() { + [[ $1 =~ ^[0-9]+$ && $1 -gt 0 ]] +} + +if ! fraction "$u5" || ! epoch "$r5"; then + printf 'statusline-quota-refresh: endpoint omitted the valid 5h quota headers\n' >&2 + exit 1 +fi +if [[ -n $u7 || -n $r7 ]] && { ! fraction "$u7" || ! epoch "$r7"; }; then + printf 'statusline-quota-refresh: endpoint returned an incomplete 7d quota pair\n' >&2 + exit 1 +fi + +{ + printf 'x-probe-account: %s\n' "$account" + printf 'anthropic-ratelimit-unified-5h-utilization: %s\n' "$u5" + printf 'anthropic-ratelimit-unified-5h-reset: %s\n' "$r5" + if [[ -n $u7 ]]; then + printf 'anthropic-ratelimit-unified-7d-utilization: %s\n' "$u7" + printf 'anthropic-ratelimit-unified-7d-reset: %s\n' "$r7" + fi +} > "$tmp" +chmod 600 "$tmp" +mv -f -- "$tmp" "$output" diff --git a/install.sh b/install.sh index 08094b0..adca55f 100755 --- a/install.sh +++ b/install.sh @@ -1,6 +1,17 @@ #!/usr/bin/env bash set -euo pipefail +if [[ $(uname -s 2>/dev/null) == Darwin && ${BASH_VERSINFO[0]} -lt 4 ]]; then + for modern_bash in /opt/homebrew/bin/bash /usr/local/bin/bash; do + if [[ -x $modern_bash ]]; then + export PATH="${modern_bash%/*}:$HOME/.cargo/bin:$HOME/.local/bin:$PATH" + exec "$modern_bash" "$0" "$@" + fi + done + printf '%s\n' 'session-kit: macOS needs Homebrew Bash 4 or newer. Install it with: brew install bash python rust' >&2 + exit 69 +fi + repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) case "${1:-}" in diff --git a/lib/session_inventory.py b/lib/session_inventory.py index d4759d7..4c50066 100755 --- a/lib/session_inventory.py +++ b/lib/session_inventory.py @@ -3,8 +3,9 @@ The collector takes one shpool JSON snapshot and one Claude agents JSON snapshot, then joins provider identities to a bounded native process table. -Linux Codex identities come from a native Codex process's open root rollout; -the opt-in Darwin preview uses that exact process's ``CODEX_THREAD_ID``. It +Linux Codex identities come from an open root rollout owned by the native +Codex CLI or App Server process; Darwin uses that exact process's +``CODEX_THREAD_ID``. It never selects a conversation by cwd or recency. """ @@ -12,33 +13,119 @@ import argparse import contextlib -import ctypes -import ctypes.util import datetime as dt -import fcntl import hashlib +import fcntl import json import os from pathlib import Path import re -import shlex -import shutil -import sqlite3 +import shutil # noqa: F401 # re-exported facade symbol +import signal import stat as statmod import subprocess import sys import tempfile import time -from typing import Any, Callable, Iterable, Mapping, Sequence -import unicodedata +from typing import Any, Callable, Iterable, Iterator, Mapping, Sequence _SESSION_KIT_LIB_DIR = os.fspath(Path(__file__).resolve().parent) if _SESSION_KIT_LIB_DIR not in sys.path: sys.path.insert(0, _SESSION_KIT_LIB_DIR) +from sessionkit_inventory import collector as _collector # noqa: E402 +from sessionkit_inventory import accounts as _accounts # noqa: E402 +from sessionkit_inventory import attention as _attention # noqa: E402 +from sessionkit_inventory import colors as _colors # noqa: E402 from sessionkit_inventory import common as _common # noqa: E402 +from sessionkit_inventory import labels as _labels # noqa: E402 +from sessionkit_inventory import idle_state as _idle_state # noqa: E402 from sessionkit_inventory import lifecycle as _lifecycle # noqa: E402 +from sessionkit_inventory import model as _model # noqa: E402 +from sessionkit_inventory import migration as _migration # noqa: E402 +from sessionkit_inventory import names as _names # noqa: E402 +from sessionkit_inventory import names_push as _names_push # noqa: E402 +from sessionkit_inventory import closed_sessions as _closed_sessions # noqa: E402 +from sessionkit_inventory import origins as _origins # noqa: E402 +from sessionkit_inventory import transcripts as _transcripts # noqa: E402 +from sessionkit_inventory import processes as _processes # noqa: E402 +from sessionkit_inventory import recovery as _recovery # noqa: E402 +from sessionkit_inventory import recovery_list as _recovery_list # noqa: E402 +from sessionkit_inventory import printed_selectors as _printed # noqa: E402 +from sessionkit_inventory import render as _render # noqa: E402 from sessionkit_inventory import providers as _providers # noqa: E402 +from sessionkit_inventory import providers_claude as _providers_claude # noqa: E402 +from sessionkit_inventory import providers_codex as _providers_codex # noqa: E402 +from sessionkit_inventory import state_io as _state_io # noqa: E402 +from sessionkit_inventory import subagent_sweep as _subagent_sweep # noqa: E402 +from sessionkit_inventory import self_name as _self_name # noqa: E402 +from sessionkit_inventory import session_model as _session_model_reader # noqa: E402 +from sessionkit_inventory import snapshot as _snapshot # noqa: E402 +from sessionkit_inventory import terminal as _terminal # noqa: E402 +from sessionkit_inventory import validation as _validation # noqa: E402 +from sessionkit_inventory.collector import ( # noqa: E402, F401 + AVAILABILITY_ORDER, + PROVIDER_ORDER, +) +from sessionkit_inventory.terminal import ( # noqa: E402, F401 + TERMINAL_NUMBER_QUARANTINE_SECONDS, +) +from sessionkit_inventory.colors import ( # noqa: E402, F401 + CLAUDE_SESSION_COLORS, + CODEX_SESSION_COLORS, + COLOR_RESERVATION_MAX_AGE_SECONDS, + LAUNCH_COLOR_MAX_AGE_SECONDS, + SESSION_COLORS, + first_free_color, +) +from sessionkit_inventory.self_name import ( # noqa: E402, F401 + MAX_AUTO_TITLE_TRANSCRIPT_BYTES, + RESUME_CONTINUATION_TEXT, + _TITLE_TRAILING_STOPWORDS, +) +from sessionkit_inventory.names_push import ( # noqa: E402, F401 + MAX_CLAUDE_SESSION_RECORDS, + MAX_CODEX_LIVE_RENAME_FRAME, + MAX_CODEX_LIVE_RENAME_SOCKETS, +) +from sessionkit_inventory.render import ( # noqa: E402, F401 + DEFAULT_STALL_SECONDS, + _color_enabled, + _display_title, + _display_width, + _format_age, + lookup, + stall_threshold_seconds, +) +from sessionkit_inventory.validation import ( # noqa: E402, F401 + _missing_shell_generation_is_quarantinable, + _missing_shell_generation_is_quarantined, +) +from sessionkit_inventory.recovery import ( # noqa: E402, F401 + _generation_key, + _pending_conflict_fields, + _pending_evidence, + _pending_preferred_entry, +) +from sessionkit_inventory.model import ( # noqa: E402, F401 + _agent_identity, + _base_agent, + _empty_recovery, + _shell_title, + recovery_spec, +) +from sessionkit_inventory.providers import _arg_value, _parse_shpool_payload # noqa: E402, F401 +from sessionkit_inventory.providers_claude import ( # noqa: E402, F401 + MAX_CLAUDE_TITLE_SCAN_BYTES, + _is_native_claude, +) +from sessionkit_inventory.providers_codex import ( # noqa: E402, F401 + ROLLOUT_LIFECYCLE_SEARCH_BYTES, + ROLLOUT_TAIL_BYTES, + _codex_state_databases, + _codex_turn_state, + _is_native_codex, +) from sessionkit_inventory.common import ( # noqa: E402, F401 GENERATED_OPERATIONAL_ID_RE, LEGACY_OPERATIONAL_ID_RE, @@ -46,52 +133,77 @@ PROVIDERS, UUID_RE, CollectionError, + _load_json_file, _positive_float, _positive_int, + _utc_now, + _valid_aliases, + _valid_automatic_title_failures, + _valid_automatic_titles, automatic_naming_enabled, clean_text, + default_runner, + display_shpool_id, natural_name_key, normalize_automatic_title, shpool_id_mutation_policy, valid_uuid, ) +from sessionkit_inventory.state_io import ( # noqa: E402, F401 + StateLock, + _atomic_json_bytes, + _launch_fields, + _read_bounded_owner_file, + _read_private_launch_file, + _state_paths, + _terminal_generation_key, + atomic_write_json, +) +from sessionkit_inventory.processes import ( # noqa: E402, F401 + DARWIN_PLATFORM, + _children_index, + _DarwinBsdInfo, + _DarwinTimeval, + _darwin_bsd_info, + _darwin_generation, + _darwin_procargs2, + _decode_c_string, + _is_shpool_daemon, + _parse_darwin_procargs2, + _proc_environ, + _proc_stat, + _process_age, + _process_ancestor_chain, + descendants, +) SCHEMA_VERSION = 1 -PROVIDER_ORDER = {"claude": 0, "codex": 1, "shell": 2, "unknown": 3} -AVAILABILITY_ORDER = {"ready": 0, "attached": 1} MAX_PRIVATE_JSON_BYTES = 1024 * 1024 MAX_CODEX_SESSION_INDEX_BYTES = 4 * 1024 * 1024 DEFAULT_MAX_PROC_NODES = 16384 ABSENT_ALIAS_CONFIG_BACKUP = b"session-kit-alias-config-absent-v1\n" +# `lifecycle reopen` answers with the reopened provider's own outcome so the +# managed shell can make the same clean-close / crash-menu decision it makes +# for a first exit. 0 is a clean provider exit and this status is a crashed +# one; every other status is a refusal to reopen anything at all, which is +# why the reopened provider's own code is not passed through directly. +LIFECYCLE_REOPENED_PROVIDER_CRASHED = 76 Runner = Callable[[Sequence[str], float], str] -DARWIN_PREVIEW_ENV = "SESSION_KIT_MACOS_PREVIEW" -DARWIN_PLATFORM = "darwin" - def _runtime_platform() -> str: """Return the real platform, with a test-only override for Linux fixtures.""" - override = os.environ.get("SESSION_KIT_TEST_PLATFORM") - if os.environ.get("SESSION_KIT_TESTING") == "1" and override: - return override.casefold() - return sys.platform - - -def _darwin_preview_enabled() -> bool: - return os.environ.get(DARWIN_PREVIEW_ENV) == "1" + return _processes._runtime_platform( + environ=os.environ, + platform_name=sys.platform, + ) def _require_supported_platform() -> str: - platform = _runtime_platform() - if platform == DARWIN_PLATFORM and not _darwin_preview_enabled(): - raise CollectionError( - "macOS support is an experimental preview; set " - f"{DARWIN_PREVIEW_ENV}=1 to opt in" - ) - if platform != DARWIN_PLATFORM and not platform.startswith("linux"): - raise CollectionError(f"unsupported platform: {platform}") - return platform + return _processes._require_supported_platform( + runtime_platform=_runtime_platform, + ) def _home() -> Path: @@ -118,6 +230,218 @@ def default_state_dir() -> Path: ) +def _scoped_config_path(environ: Mapping[str, str] | None) -> Path | None: + """Resolve the naming document inside the caller's own environment. + + An explicit environment IS the caller's sandbox, the automatic namers + run inside one, and resolving the real home from in there would read (and + write) name ownership outside the sandbox that asked for the work. + """ + if environ is None: + return config_path() + env = dict(environ) + if not ( + env.get("SESSION_KIT_CONFIG") or env.get("XDG_CONFIG_HOME") or env.get("HOME") + ): + return None + return _common.config_path( + environ=env, + home=lambda: _common._home(environ=env, home_factory=Path.home), + xdg_path=lambda name, fallback: _common._xdg_path(name, fallback, environ=env), + ) + + +def _scoped_state_config(environ: Mapping[str, str] | None) -> dict[str, Any] | None: + """The state root the caller's own environment locks its naming writes in.""" + if environ is None: + return load_config() + env = dict(environ) + if not ( + env.get("SESSION_KIT_STATE_DIR") or env.get("XDG_STATE_HOME") or env.get("HOME") + ): + return None + return { + "schema_version": SCHEMA_VERSION, + "state_dir": _common.default_state_dir( + environ=env, + home=lambda: _common._home(environ=env, home_factory=Path.home), + xdg_path=lambda name, fallback: _common._xdg_path( + name, fallback, environ=env + ), + ), + } + + +def record_pushed_title( + provider: str, + uuid: str, + title: str, + *, + previous_native_title: str | None = None, + previous_native_name_since: Any = None, + previous_native_name_source: str = "", + environ: Mapping[str, str] | None = None, +) -> None: + """Remember what the kit just wrote into a provider's own store. + + Fail-open: naming is advisory, and a thread with no record simply falls + back to its retained automatic title as the last kit-authored value. + """ + path = _scoped_config_path(environ) + config = _scoped_state_config(environ) + if path is None or config is None: + return + try: + _names.record_pushed_title( + config, + provider, + uuid, + title, + previous_native_title=previous_native_title, + previous_native_name_since=previous_native_name_since, + previous_native_name_source=previous_native_name_source, + atomic_write_json=atomic_write_json, + config_path=lambda: path, + private_alias_document=_private_alias_document, + private_alias_parent=_private_alias_parent, + ) + except (CollectionError, OSError): + return + + +def adopt_native_rename( + provider: str, + uuid: str, + native_title: str, + *, + native_name_source: str = "", + native_name_since: Any = None, + environ: Mapping[str, str] | None = None, +) -> str: + """Take a provider-native rename as the permanent human-owned name.""" + path = _scoped_config_path(environ) + config = _scoped_state_config(environ) + if path is None or config is None: + return "" + try: + return _names.adopt_native_rename( + config, + provider, + uuid, + native_title, + native_name_source=native_name_source, + native_name_since=native_name_since, + atomic_write_json=atomic_write_json, + config_path=lambda: path, + private_alias_document=_private_alias_document, + private_alias_parent=_private_alias_parent, + ) + except (CollectionError, OSError): + return "" + + +def name_owner( + provider: str, uuid: str, *, environ: Mapping[str, str] | None = None +) -> str: + """Say who owns a session's name: human, automatic, or nobody (""). + + Fails open: a document that cannot be read owns nothing, because a + naming pass that cannot prove a name is taken is exactly the pass that + should keep looking rather than refuse to work at all. + """ + exact = _common.valid_uuid(uuid) + if provider not in PROVIDERS or not exact: + return "" + path = _scoped_config_path(environ) + if path is None: + return "" + try: + document = _private_alias_document(path, allow_missing=True) + except (CollectionError, OSError): + return "" + return _names._name_owner(document, f"{provider}:{exact}") + + +def human_named_keys(environ: Mapping[str, str] | None = None) -> frozenset[str]: + """Every session a person renamed, as provider:uuid keys.""" + path = _scoped_config_path(environ) + if path is None: + return frozenset() + try: + document = _private_alias_document(path, allow_missing=True) + except (CollectionError, OSError): + return frozenset() + keys = { + key + for key, record in _common._valid_name_ownership( + document.get("name_ownership") + ).items() + if record["owner"] == "human" + } + # Documents written before the ownership record still hold the evidence. + keys.update( + key + for key in _names._valid_aliases(document.get("aliases")) + if _names._name_owner(document, key) == "human" + ) + return frozenset(keys) + + +def claim_automatic_name( + provider: str, uuid: str, *, environ: Mapping[str, str] | None = None +) -> str: + """Take the one-shot automatic claim on a session's name. + + Returns "" when the claim landed, or the reason it did not. Naming is + advisory work that must never break a session, so every failure is + reported rather than raised. + """ + exact = _common.valid_uuid(uuid) + if provider not in PROVIDERS or not exact: + return "invalid automatic name claim request" + path = _scoped_config_path(environ) + config = _scoped_state_config(environ) + if path is None or config is None: + return "caller environment has no home; automatic name not claimed" + try: + return _names.claim_automatic_name( + config, + provider, + exact, + atomic_write_json=atomic_write_json, + config_path=lambda: path, + private_alias_document=_private_alias_document, + private_alias_parent=_private_alias_parent, + ) + except (CollectionError, OSError) as exc: + return f"automatic name not claimed: {exc}" + + +def release_automatic_name_claim( + provider: str, uuid: str, *, environ: Mapping[str, str] | None = None +) -> bool: + """Release an untouched claim after its first durable write failed.""" + exact = _common.valid_uuid(uuid) + if provider not in PROVIDERS or not exact: + return False + path = _scoped_config_path(environ) + config = _scoped_state_config(environ) + if path is None or config is None: + return False + try: + return _names.release_automatic_name_claim( + config, + provider, + exact, + atomic_write_json=atomic_write_json, + config_path=lambda: path, + private_alias_document=_private_alias_document, + private_alias_parent=_private_alias_parent, + ) + except (CollectionError, OSError): + return False + + def default_journal_dir() -> Path: return _common.default_journal_dir( environ=os.environ, @@ -142,63 +466,6 @@ def default_start_dir() -> Path: ) -def _load_json_file(path: Path) -> Any: - with path.open("r", encoding="utf-8") as handle: - return json.load(handle) - - -def _valid_aliases(raw: Any) -> dict[str, str]: - aliases: dict[str, str] = {} - if not isinstance(raw, Mapping): - return aliases - for key, value in raw.items(): - if not isinstance(key, str) or not isinstance(value, str): - continue - provider, separator, uuid = key.partition(":") - title = clean_text(value, 100) - if separator and provider in PROVIDERS and UUID_RE.fullmatch(uuid) and title: - aliases[f"{provider}:{uuid.lower()}"] = title - return aliases - - -def _valid_automatic_titles(raw: Any) -> dict[str, str]: - """Validate retained, provider/UUID-bound automatic display titles.""" - titles: dict[str, str] = {} - if not isinstance(raw, Mapping): - return titles - for key, value in raw.items(): - if not isinstance(key, str) or not isinstance(value, str): - continue - provider, separator, uuid = key.partition(":") - try: - title = normalize_automatic_title(value) - except CollectionError: - continue - if separator and provider in PROVIDERS and valid_uuid(uuid): - titles[f"{provider}:{uuid.lower()}"] = title - return titles - - -def _valid_automatic_title_failures(raw: Any) -> dict[str, int]: - failures: dict[str, int] = {} - if not isinstance(raw, Mapping): - return failures - for key, value in raw.items(): - provider, separator, uuid = ( - key.partition(":") if isinstance(key, str) else ("", "", "") - ) - if ( - separator - and provider in PROVIDERS - and valid_uuid(uuid) - and not isinstance(value, bool) - and isinstance(value, int) - and 0 < value <= 2 - ): - failures[f"{provider}:{uuid.lower()}"] = value - return failures - - def load_config() -> dict[str, Any]: """Load and validate configuration, with safe defaults.""" config = _common.load_config( @@ -225,48 +492,22 @@ def load_config() -> dict[str, Any]: config["colors"] = _valid_colors( raw.get("colors") if isinstance(raw, Mapping) else None ) - return config - - -def display_shpool_id(raw: str, limit: int = 32) -> str: - display_source = "".join( - " " if unicodedata.category(character).startswith("C") else character - for character in raw + # So does the record of what the kit last pushed into each provider's own + # store, the collector needs it to tell a native /rename from its own echo. + config["pushed_titles"] = _common._valid_pushed_titles( + raw.get("pushed_titles") if isinstance(raw, Mapping) else None ) - visible = clean_text(display_source, 10000) - if not visible: - visible = "(non-printing ID)" - return visible if len(visible) <= limit else f"{visible[: limit - 1]}…" - - -def _utc_now(now: float | None = None) -> str: - instant = dt.datetime.fromtimestamp(now if now is not None else time.time(), dt.timezone.utc) - return instant.isoformat(timespec="seconds").replace("+00:00", "Z") + config["pending_native_titles"] = _common._valid_pending_native_titles( + raw.get("pending_native_titles") if isinstance(raw, Mapping) else None + ) + config["name_ownership"] = _common._valid_name_ownership( + raw.get("name_ownership") if isinstance(raw, Mapping) else None + ) + return config def _command_from_env(env_name: str, default: str) -> list[str]: - value = os.environ.get(env_name) - if value: - command = shlex.split(value) - if command: - return command - return [default] - - -def default_runner(argv: Sequence[str], timeout: float) -> str: - completed = subprocess.run( - list(argv), - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=timeout, - check=False, - ) - if completed.returncode != 0: - detail = clean_text(completed.stderr, 240) or f"exit {completed.returncode}" - raise CollectionError(f"{shlex.join(argv)} failed: {detail}") - return completed.stdout + return _common._command_from_env(env_name, default, environ=os.environ) def _command_json( @@ -277,389 +518,109 @@ def _command_json( runner: Runner, timeout: float, ) -> Any: - fixture = os.environ.get(fixture_env) - if fixture: - return _load_json_file(Path(fixture).expanduser()) - prefix = _command_from_env(command_env, default_command[0]) - return json.loads(runner([*prefix, *default_command[1:]], timeout)) - - -def _parse_shpool_payload(payload: Any) -> list[dict[str, Any]]: - if not isinstance(payload, Mapping) or not isinstance(payload.get("sessions"), list): - raise CollectionError("shpool list --json returned an invalid object") - result: list[dict[str, Any]] = [] - seen: set[str] = set() - for item in payload["sessions"]: - if not isinstance(item, Mapping): - raise CollectionError("shpool list --json contained a non-object session") - name = item.get("name") - status_raw = clean_text(item.get("status"), 32).casefold() - if ( - not isinstance(name, str) - or not name - or name in seen - or status_raw not in {"attached", "disconnected"} - ): - raise CollectionError("shpool list --json contained an invalid or duplicate session") - started = item.get("started_at_unix_ms") - if not isinstance(started, int) or started < 0: - raise CollectionError(f"shpool session {name!r} has invalid started_at_unix_ms") - seen.add(name) - result.append( - { - "name": name, - "status": "Attached" if status_raw == "attached" else "Disconnected", - "started_at_unix_ms": started, - } - ) - return result + return _common._command_json( + fixture_env=fixture_env, + command_env=command_env, + default_command=default_command, + runner=runner, + timeout=timeout, + environ=os.environ, + load_json_file=_load_json_file, + command_from_env=_command_from_env, + ) def _parse_claude_payload(payload: Any) -> list[dict[str, Any]]: - if not isinstance(payload, list): - raise CollectionError("claude agents --json returned a non-array") - result: list[dict[str, Any]] = [] - seen_pids: set[int] = set() - for item in payload: - if not isinstance(item, Mapping): - continue - pid = item.get("pid") - uuid = valid_uuid(item.get("sessionId")) - if not isinstance(pid, int) or pid <= 0 or not uuid or pid in seen_pids: - continue - seen_pids.add(pid) - raw_status = clean_text(item.get("status"), 40).casefold() - waiting_for = item.get("waitingFor") - needs_you = bool(waiting_for) or raw_status in { - "waiting", - "needs_input", - "needs your reply", - } - status = "needs your reply" if needs_you else ("working" if raw_status == "busy" else raw_status) - result.append( - { - "pid": pid, - "uuid": uuid, - "cwd": clean_text(item.get("cwd"), 4096), - "kind": clean_text(item.get("kind"), 40), - "started_at_unix_ms": item.get("startedAt") - if isinstance(item.get("startedAt"), int) - else None, - "title": clean_text(item.get("name"), 120), - "ai_title": clean_text(item.get("aiTitle"), 120), - "name_source": clean_text(item.get("nameSource"), 20), - "status": status or "unknown", - "needs_you": needs_you, - } + """Read the poll rows, corrected by whatever the Notification hook recorded. + + The hook records are read once per snapshot, for exactly the sessions the + poll returned: a session Claude does not list is not a session the picker + can act on, so a record without a live row is nobody's evidence. + """ + source = _attention.attention_source(os.environ) + records: dict[str, dict[str, Any]] = {} + if source != "poll" and isinstance(payload, list): + state_dir = _attention.default_state_dir(os.environ, Path.home()) + records = _attention.read_all( + state_dir, + [item.get("sessionId") for item in payload if isinstance(item, Mapping)], ) - return result + return _providers_claude._parse_claude_payload( + payload, + palette=CLAUDE_SESSION_COLORS, + attention_records=records, + attention_source=source, + ) -MAX_CLAUDE_TITLE_SCAN_BYTES = 64 * 1024 +def read_claude_transcript_signals( + uuid: str, home: Path | None = None +) -> dict[str, str]: + """Return Claude's persisted per-conversation title and color evidence.""" + return _providers_claude.read_claude_transcript_signals( + uuid, + home, + environ=os.environ, + home_factory=Path.home, + palette=CLAUDE_SESSION_COLORS, + ) def read_claude_ai_title(uuid: str, home: Path | None = None) -> str: - """Return Claude's persisted conversation auto-title for one session. - - The TUI stores it as an ``ai-title`` transcript record, not in the session - record, so the derived window label and the visible conversation title can - disagree. Reads are bounded to the transcript's head and tail; the last - record wins. Any problem returns an empty title. - """ - exact_uuid = valid_uuid(uuid) - if not exact_uuid: - return "" - base = home if home is not None else Path(os.environ.get("HOME") or Path.home()) - projects = base / ".claude" / "projects" - try: - transcripts = sorted(projects.glob(f"*/{exact_uuid}.jsonl")) - except OSError: - return "" - title = "" - for transcript in transcripts[:4]: - if transcript.is_symlink(): - continue - try: - size = transcript.stat().st_size - with open(transcript, "rb") as handle: - chunks = [handle.read(MAX_CLAUDE_TITLE_SCAN_BYTES)] - if size > 2 * MAX_CLAUDE_TITLE_SCAN_BYTES: - handle.seek(size - MAX_CLAUDE_TITLE_SCAN_BYTES) - chunks.append(handle.read(MAX_CLAUDE_TITLE_SCAN_BYTES)) - elif size > MAX_CLAUDE_TITLE_SCAN_BYTES: - chunks.append(handle.read()) - except OSError: - continue - for chunk in chunks: - for line in chunk.split(b"\n"): - if b'"ai-title"' not in line: - continue - try: - record = json.loads(line.decode("utf-8", "strict")) - except (UnicodeDecodeError, ValueError): - continue - if ( - isinstance(record, Mapping) - and record.get("type") == "ai-title" - and record.get("sessionId") == exact_uuid - ): - candidate = clean_text(record.get("aiTitle"), 120) - if candidate: - title = candidate - return title + """Compatibility wrapper: the auto-title half of the transcript signals.""" + return _providers_claude.read_claude_ai_title( + uuid, + home, + transcript_signals=read_claude_transcript_signals, + ) def _enrich_claude_payload(payload: Any) -> Any: """Attach per-session nameSource and ai-title evidence for the collector.""" - if not isinstance(payload, list): - return payload - home = Path(os.environ.get("HOME") or Path.home()) - for item in payload: - if not isinstance(item, dict): - continue - pid = item.get("pid") - uuid = valid_uuid(item.get("sessionId")) - if not isinstance(pid, int) or pid <= 0 or not uuid: - continue - record_path = home / ".claude" / "sessions" / f"{pid}.json" - if "nameSource" not in item and record_path.is_file(): - try: - record = json.loads(record_path.read_text(encoding="utf-8")) - if isinstance(record, Mapping): - item["nameSource"] = record.get("nameSource") - except (OSError, ValueError): - pass - if "aiTitle" not in item: - item["aiTitle"] = read_claude_ai_title(uuid, home) - return payload - - -def _proc_stat(path: Path) -> tuple[int, int, int]: - raw = path.read_text(encoding="utf-8", errors="replace") - left = raw.find("(") - right = raw.rfind(")") - if left < 1 or right < left: - raise ValueError("malformed stat") - pid = int(raw[:left].strip()) - fields = raw[right + 2 :].split() - return pid, int(fields[1]), int(fields[19]) - - -def _proc_environ(path: Path) -> dict[str, str]: - values: dict[str, str] = {} - for entry in path.read_bytes().split(b"\0"): - if b"=" not in entry: - continue - key, value = entry.split(b"=", 1) - try: - values[key.decode("utf-8")] = value.decode("utf-8", errors="replace") - except UnicodeDecodeError: - continue - return values + return _providers_claude._enrich_claude_payload( + payload, + environ=os.environ, + home_factory=Path.home, + palette=CLAUDE_SESSION_COLORS, + transcript_signals=read_claude_transcript_signals, + ) def scan_process_table(proc_root: Path, max_nodes: int) -> dict[int, dict[str, Any]]: """Read one bounded process-table view from proc_root.""" - table: dict[int, dict[str, Any]] = {} - try: - entries = sorted( - (entry for entry in proc_root.iterdir() if entry.name.isdigit()), - key=lambda item: int(item.name), - ) - except OSError as exc: - raise CollectionError(f"cannot enumerate {proc_root}: {exc}") from exc - if len(entries) > max_nodes: - raise CollectionError( - f"{proc_root} has {len(entries)} processes, above max_proc_nodes={max_nodes}" - ) - for entry in entries: - pid = int(entry.name) - try: - before_pid, ppid, start_ticks = _proc_stat(entry / "stat") - if before_pid != pid: - continue - cmdline = [ - value.decode("utf-8", errors="replace") - for value in (entry / "cmdline").read_bytes().split(b"\0") - if value - ] - comm = clean_text((entry / "comm").read_text(encoding="utf-8", errors="replace"), 128) - except (OSError, ValueError): - continue - try: - environ = _proc_environ(entry / "environ") - except OSError: - environ = {} - try: - cwd = os.readlink(entry / "cwd") - except OSError: - cwd = "" - try: - after_pid, after_ppid, after_start_ticks = _proc_stat(entry / "stat") - except (OSError, ValueError): - continue - if (before_pid, ppid, start_ticks) != ( - after_pid, - after_ppid, - after_start_ticks, - ): - continue - table[pid] = { - "pid": pid, - "ppid": ppid, - "start_ticks": start_ticks, - "cmdline": cmdline, - "comm": comm, - "cwd": cwd, - "session_name": environ.get("SHPOOL_SESSION_NAME", ""), - } - return table - - -class _DarwinBsdInfo(ctypes.Structure): - """Stable prefix and start fields from Darwin's ``struct proc_bsdinfo``.""" - - _fields_ = [ - ("pbi_flags", ctypes.c_uint32), - ("pbi_status", ctypes.c_uint32), - ("pbi_xstatus", ctypes.c_uint32), - ("pbi_pid", ctypes.c_uint32), - ("pbi_ppid", ctypes.c_uint32), - ("pbi_uid", ctypes.c_uint32), - ("pbi_gid", ctypes.c_uint32), - ("pbi_ruid", ctypes.c_uint32), - ("pbi_rgid", ctypes.c_uint32), - ("pbi_svuid", ctypes.c_uint32), - ("pbi_svgid", ctypes.c_uint32), - ("rfu_1", ctypes.c_uint32), - ("pbi_comm", ctypes.c_char * 16), - ("pbi_name", ctypes.c_char * 32), - ("pbi_nfiles", ctypes.c_uint32), - ("pbi_pgid", ctypes.c_uint32), - ("pbi_pjobc", ctypes.c_uint32), - ("e_tdev", ctypes.c_uint32), - ("e_tpgid", ctypes.c_uint32), - ("pbi_nice", ctypes.c_int32), - ("pbi_start_tvsec", ctypes.c_uint64), - ("pbi_start_tvusec", ctypes.c_uint64), - ] - - -class _DarwinTimeval(ctypes.Structure): - _fields_ = [ - ("tv_sec", ctypes.c_long), - ("tv_usec", ctypes.c_int), - ] + def inventory_environ(path: Path) -> dict[str, str]: + values = _proc_environ(path) + return { + key: values[key] + for key in ( + "SHPOOL_SESSION_NAME", + "SESSION_KIT_REQUESTED_MODEL", + "SESSION_KIT_LAUNCH_IDEMPOTENCY_KEY", + "CLAUDE_CONFIG_DIR", + "CODEX_HOME", + "SESSION_KIT_ACCOUNT_ALIAS", + "SESSION_KIT_ACCOUNT_CAPABLE", + ) + if key in values + } -def _parse_darwin_procargs2(payload: bytes) -> tuple[list[str], dict[str, str]]: - """Parse a KERN_PROCARGS2 payload without trusting text delimiters.""" - integer_size = ctypes.sizeof(ctypes.c_int) - if len(payload) < integer_size: - raise ValueError("short KERN_PROCARGS2 payload") - argc = int.from_bytes(payload[:integer_size], sys.byteorder, signed=True) - if argc < 0 or argc > 65536: - raise ValueError("invalid KERN_PROCARGS2 argc") - values = payload[integer_size:].split(b"\0") - if not values: - raise ValueError("missing KERN_PROCARGS2 executable") - # First value is the executable path. Darwin may insert extra NUL padding - # before argv[0]. - index = 1 - while index < len(values) and not values[index]: - index += 1 - if len(values) - index < argc: - raise ValueError("truncated KERN_PROCARGS2 argv") - argv = [ - value.decode("utf-8", errors="replace") - for value in values[index : index + argc] - ] - environ: dict[str, str] = {} - for raw in values[index + argc :]: - if not raw or b"=" not in raw: - continue - key, value = raw.split(b"=", 1) + table = _processes.scan_process_table( + proc_root, + max_nodes, + proc_stat=_proc_stat, + proc_environ=inventory_environ, + ) + for pid, row in table.items(): try: - name = key.decode("utf-8") - except UnicodeDecodeError: + values = inventory_environ(proc_root / str(pid) / "environ") + except OSError: continue - environ[name] = value.decode("utf-8", errors="replace") - return argv, environ - - -def _darwin_libraries() -> tuple[Any, Any]: - if _runtime_platform() != DARWIN_PLATFORM: - raise CollectionError("Darwin native process APIs are unavailable") - libc = ctypes.CDLL(ctypes.util.find_library("c") or "libc.dylib", use_errno=True) - libproc = ctypes.CDLL( - ctypes.util.find_library("proc") or "/usr/lib/libproc.dylib", - use_errno=True, - ) - libc.sysctl.argtypes = [ - ctypes.POINTER(ctypes.c_int), - ctypes.c_uint, - ctypes.c_void_p, - ctypes.POINTER(ctypes.c_size_t), - ctypes.c_void_p, - ctypes.c_size_t, - ] - libc.sysctl.restype = ctypes.c_int - libc.sysctlbyname.argtypes = [ - ctypes.c_char_p, - ctypes.c_void_p, - ctypes.POINTER(ctypes.c_size_t), - ctypes.c_void_p, - ctypes.c_size_t, - ] - libc.sysctlbyname.restype = ctypes.c_int - libproc.proc_listallpids.argtypes = [ctypes.c_void_p, ctypes.c_int] - libproc.proc_listallpids.restype = ctypes.c_int - libproc.proc_pidinfo.argtypes = [ - ctypes.c_int, - ctypes.c_int, - ctypes.c_uint64, - ctypes.c_void_p, - ctypes.c_int, - ] - libproc.proc_pidinfo.restype = ctypes.c_int - return libc, libproc - - -def _darwin_procargs2(libc: Any, pid: int) -> bytes: - # CTL_KERN, KERN_PROCARGS2, pid - mib = (ctypes.c_int * 3)(1, 49, pid) - size = ctypes.c_size_t(0) - if libc.sysctl(mib, 3, None, ctypes.byref(size), None, 0) != 0: - raise OSError(ctypes.get_errno(), "KERN_PROCARGS2 size") - if size.value <= 0 or size.value > 16 * 1024 * 1024: - raise OSError("unsafe KERN_PROCARGS2 size") - buffer = ctypes.create_string_buffer(size.value) - if libc.sysctl(mib, 3, buffer, ctypes.byref(size), None, 0) != 0: - raise OSError(ctypes.get_errno(), "KERN_PROCARGS2 read") - return buffer.raw[: size.value] - - -def _darwin_bsd_info(libproc: Any, pid: int) -> _DarwinBsdInfo: - info = _DarwinBsdInfo() - # PROC_PIDTBSDINFO - received = libproc.proc_pidinfo( - pid, 3, 0, ctypes.byref(info), ctypes.sizeof(info) - ) - if received != ctypes.sizeof(info): - raise OSError(ctypes.get_errno(), "PROC_PIDTBSDINFO") - return info - - -def _darwin_generation(info: _DarwinBsdInfo) -> int: - seconds = int(info.pbi_start_tvsec) - microseconds = int(info.pbi_start_tvusec) - if seconds <= 0 or microseconds < 0 or microseconds >= 1_000_000: - raise ValueError("invalid Darwin process start time") - return seconds * 1_000_000 + microseconds - - -def _decode_c_string(value: bytes) -> str: - return value.split(b"\0", 1)[0].decode("utf-8", errors="replace") + row["requested_model"] = values.get("SESSION_KIT_REQUESTED_MODEL", "") + row["launch_idempotency_key"] = values.get( + "SESSION_KIT_LAUNCH_IDEMPOTENCY_KEY", "" + ) + return table def scan_darwin_process_table( @@ -670,242 +631,144 @@ def scan_darwin_process_table( args_reader: Callable[[int], bytes] | None = None, ) -> dict[int, dict[str, Any]]: """Read one bounded Darwin process view using libproc and KERN_PROCARGS2.""" - libc: Any = None - libproc: Any = None - if pids is None or bsd_reader is None or args_reader is None: - libc, libproc = _darwin_libraries() - if pids is None: - capacity = max_nodes + 1 - buffer = (ctypes.c_int * capacity)() - count = libproc.proc_listallpids(buffer, ctypes.sizeof(buffer)) - if count < 0: - raise CollectionError("cannot enumerate Darwin processes") - if count > max_nodes: - raise CollectionError( - f"Darwin has more than max_proc_nodes={max_nodes} processes" - ) - pids = [int(buffer[index]) for index in range(count) if buffer[index] > 0] - unique_pids = sorted(set(pids)) - if len(unique_pids) > max_nodes: - raise CollectionError( - f"Darwin has {len(unique_pids)} processes, above " - f"max_proc_nodes={max_nodes}" - ) - read_bsd = bsd_reader or (lambda pid: _darwin_bsd_info(libproc, pid)) - read_args = args_reader or (lambda pid: _darwin_procargs2(libc, pid)) - table: dict[int, dict[str, Any]] = {} - for pid in unique_pids: - try: - before = read_bsd(pid) - generation = _darwin_generation(before) - argv, environ = _parse_darwin_procargs2(read_args(pid)) - after = read_bsd(pid) - after_generation = _darwin_generation(after) - except (OSError, ValueError): - continue - before_identity = (int(before.pbi_pid), int(before.pbi_ppid), generation) - after_identity = ( - int(after.pbi_pid), - int(after.pbi_ppid), - after_generation, - ) - if before_identity != after_identity or before_identity[0] != pid: - continue - table[pid] = { - "pid": pid, - "ppid": before_identity[1], - "start_ticks": generation, - "generation_kind": "darwin-start-usec", - "cmdline": argv, - "comm": clean_text( - _decode_c_string(bytes(before.pbi_name)) - or _decode_c_string(bytes(before.pbi_comm)), - 128, - ), - "cwd": ( - environ.get("PWD", "") - if environ.get("PWD", "").startswith("/") - else "" - ), - "session_name": environ.get("SHPOOL_SESSION_NAME", ""), - "codex_thread_id": environ.get("CODEX_THREAD_ID", ""), - "claude_session_id": environ.get("CLAUDE_SESSION_ID", ""), - } - return table + return _processes.scan_darwin_process_table( + max_nodes, + pids=pids, + bsd_reader=bsd_reader, + args_reader=args_reader, + darwin_libraries=_darwin_libraries, + darwin_bsd_info=_darwin_bsd_info, + darwin_procargs2=_darwin_procargs2, + darwin_generation=_darwin_generation, + parse_darwin_procargs2=_parse_darwin_procargs2, + decode_c_string=_decode_c_string, + ) + + +def _darwin_libraries() -> tuple[Any, Any]: + return _processes._darwin_libraries(runtime_platform=_runtime_platform) def platform_process_table( proc_root: Path, max_nodes: int ) -> dict[int, dict[str, Any]]: - platform = _require_supported_platform() - if platform == DARWIN_PLATFORM: - return scan_darwin_process_table(max_nodes) - return scan_process_table(proc_root, max_nodes) - - -def _children_index(process_table: Mapping[int, Mapping[str, Any]]) -> dict[int, list[int]]: - children: dict[int, list[int]] = {} - for pid, process in process_table.items(): - children.setdefault(int(process.get("ppid", 0)), []).append(pid) - for values in children.values(): - values.sort() - return children - - -def _is_shpool_daemon(process: Mapping[str, Any]) -> bool: - argv = process.get("cmdline") or [] - executable = Path(argv[0]).name if argv else process.get("comm", "") - return executable == "shpool" and "daemon" in argv[1:] + return _processes.platform_process_table( + proc_root, + max_nodes, + require_supported_platform=_require_supported_platform, + darwin_process_table=scan_darwin_process_table, + linux_process_table=scan_process_table, + ) def shpool_roots( session_names: Iterable[str], process_table: Mapping[int, Mapping[str, Any]] ) -> tuple[dict[str, int], dict[str, list[str]]]: """Map a shpool name only through a unique daemon direct child.""" - wanted = set(session_names) - daemons = [pid for pid, process in process_table.items() if _is_shpool_daemon(process)] - roots: dict[str, int] = {} - diagnostics: dict[str, list[str]] = {name: [] for name in wanted} - if len(daemons) != 1: - reason = f"expected one shpool daemon in /proc, found {len(daemons)}" - for name in wanted: - diagnostics[name].append(reason) - return roots, diagnostics - daemon = daemons[0] - for name in wanted: - candidates = sorted( - pid - for pid, process in process_table.items() - if process.get("ppid") == daemon and process.get("session_name") == name - ) - if len(candidates) == 1: - roots[name] = candidates[0] - else: - diagnostics[name].append( - f"expected one daemon child for {name!r}, found {len(candidates)}" - ) - return roots, diagnostics + return _processes.shpool_roots( + session_names, + process_table, + is_shpool_daemon=_is_shpool_daemon, + ) def daemon_generation( - process_table: Mapping[int, Mapping[str, Any]] + process_table: Mapping[int, Mapping[str, Any]], ) -> dict[str, int] | None: - candidates = [ - process for process in process_table.values() if _is_shpool_daemon(process) - ] - if len(candidates) != 1: - return None - pid = candidates[0].get("pid") - start_ticks = candidates[0].get("start_ticks") - if not isinstance(pid, int) or not isinstance(start_ticks, int): - return None - return {"pid": pid, "process_start_ticks": start_ticks} - - -def descendants( - root_pid: int, - children: Mapping[int, Sequence[int]], - *, - max_nodes: int, - max_depth: int, -) -> list[int]: - """Bounded breadth-first descendant walk, including root_pid.""" - found: list[int] = [] - seen: set[int] = set() - queue: list[tuple[int, int]] = [(root_pid, 0)] - while queue: - pid, depth = queue.pop(0) - if pid in seen: - continue - seen.add(pid) - found.append(pid) - if len(found) > max_nodes: - raise CollectionError(f"process tree rooted at PID {root_pid} exceeded max_proc_nodes") - if depth >= max_depth: - continue - queue.extend((child, depth + 1) for child in children.get(pid, ())) - return found + return _processes.daemon_generation( + process_table, + is_shpool_daemon=_is_shpool_daemon, + ) -def _arg_value(argv: Sequence[str], option: str) -> str: - try: - index = argv.index(option) - except ValueError: - return "" - return argv[index + 1] if index + 1 < len(argv) else "" +def _payload_daemon_identity( + process_table: Mapping[int, Mapping[str, Any]], +) -> dict[str, int] | None: + """Exact holder generation for binding one shpool list payload.""" + # Linux exposes the authoritative listener inode and each candidate's fd + # tree. Darwin's bounded native scanner has no equivalent socket-owner + # primitive here, so its safe ordinary case is one definite daemon with + # the same generation on all three observations; multiple or unreadable- + # argv candidates still refuse in _kit_daemons. + require_holder = _require_supported_platform().startswith("linux") + return _processes.daemon_generation( + process_table, + is_shpool_daemon=_is_shpool_daemon, + require_socket_holder=require_holder, + ) def claude_subagents( root_uuid: str, process_table: Mapping[int, Mapping[str, Any]] ) -> list[dict[str, Any]]: - result: list[dict[str, Any]] = [] - for pid, process in process_table.items(): - argv = process.get("cmdline") or [] - if _arg_value(argv, "--parent-session-id").lower() != root_uuid: - continue - title = clean_text(_arg_value(argv, "--agent-name"), 80) or f"PID {pid}" - result.append( - { - "provider": "claude", - "uuid": None, - "pid": pid, - "title": title, - "status": "running", - } - ) - return sorted(result, key=lambda item: (item["title"].casefold(), item["pid"])) + return _providers_claude.claude_subagents( + root_uuid, + process_table, + arg_value=_arg_value, + ) -def _is_native_codex(process: Mapping[str, Any]) -> bool: - argv = process.get("cmdline") or [] - if not argv: - return False - executable = Path(argv[0]).name - return executable == "codex" and process.get("comm") != "node" +def _is_codex_app_server(process: Mapping[str, Any]) -> bool: + """Recognize the native Codex process that owns remote TUI threads.""" + return _providers_codex._is_codex_app_server( + process, + is_native_codex=_is_native_codex, + ) -ROLLOUT_TAIL_BYTES = _providers.ROLLOUT_TAIL_BYTES -ROLLOUT_LIFECYCLE_SEARCH_BYTES = _providers.ROLLOUT_LIFECYCLE_SEARCH_BYTES +def codex_refresh_target( + process_table: Mapping[int, Mapping[str, Any]], + shell_pid: int, + shell_generation: int, + provider_pid: int, + provider_generation: int, +) -> tuple[int, int]: + """Prove the exact Codex process a title refresh may restart.""" + return _providers_codex.codex_refresh_target( + process_table, + shell_pid, + shell_generation, + provider_pid, + provider_generation, + is_native_codex=_is_native_codex, + is_codex_app_server=_is_codex_app_server, + children_index=_children_index, + descendants=descendants, + arg_value=_arg_value, + max_proc_nodes=DEFAULT_MAX_PROC_NODES, + ) + + +def _native_claude_uuid(process: Mapping[str, Any]) -> str | None: + return _providers_claude._native_claude_uuid( + process, + is_native_claude=_is_native_claude, + arg_value=_arg_value, + ) def _rollout_turn_state(descriptor: int) -> str: """Compatibility facade for the provider-specific structured parser.""" - return _providers.rollout_turn_state(descriptor) + return _providers_codex.rollout_turn_state(descriptor) def _rollout_meta_fd(descriptor: int) -> dict[str, Any] | None: """Parse bounded rollout metadata from an already-open descriptor.""" - try: - raw = os.pread(descriptor, 1024 * 1024, 0) - except OSError: - return None - for line in raw.splitlines()[:64]: - try: - event = json.loads(line) - except (UnicodeDecodeError, ValueError): - continue - if event.get("type") == "session_meta" and isinstance( - event.get("payload"), Mapping - ): - meta = dict(event["payload"]) - meta["_turn_state"] = _rollout_turn_state(descriptor) - return meta - return None + return _providers_codex._rollout_meta_fd( + descriptor, + rollout_turn_state=_rollout_turn_state, + ) def _expected_proc_identity( proc_root: Path, pid: int, expected_process: Mapping[str, Any] ) -> tuple[int, int, int] | None: - try: - identity = _proc_stat(proc_root / str(pid) / "stat") - except (OSError, ValueError): - return None - expected = ( + return _processes._expected_proc_identity( + proc_root, pid, - expected_process.get("ppid"), - expected_process.get("start_ticks"), + expected_process, + proc_stat=_proc_stat, ) - return identity if identity == expected else None def codex_open_rollouts( @@ -915,248 +778,96 @@ def codex_open_rollouts( expected_process: Mapping[str, Any], ) -> list[dict[str, Any]]: """Read metadata through stable, native Codex-owned proc descriptors.""" - initial_identity = _expected_proc_identity( - proc_root, pid, expected_process + return _providers_codex.codex_open_rollouts( + pid, + proc_root, + codex_home, + expected_process, + expected_proc_identity=_expected_proc_identity, + rollout_meta_fd=_rollout_meta_fd, + ) + + +def codex_rollout_by_uuid( + codex_home: Path, + uuid: str, + *, + max_files: int = 10_000, +) -> list[dict[str, Any]]: + """Read one exact Darwin Codex rollout selected only by its process UUID.""" + return _providers_codex.codex_rollout_by_uuid( + codex_home, + uuid, + max_files=max_files, + rollout_meta_fd=_rollout_meta_fd, + ) + + +def codex_rollout_state_by_path(codex_home: Path, rollout_path: object) -> str: + """Read one child thread's state from an owner-controlled Codex rollout.""" + return _providers_codex.codex_rollout_state_by_path( + codex_home, + rollout_path, + rollout_turn_state=_rollout_turn_state, ) - if initial_identity is None: - return [] - fd_dir = proc_root / str(pid) / "fd" - try: - descriptors = sorted(fd_dir.iterdir(), key=lambda item: int(item.name)) - except (OSError, ValueError): - return [] - home_resolved = codex_home.resolve(strict=False) - result: list[dict[str, Any]] = [] - seen: set[tuple[int, int]] = set() - for descriptor in descriptors: - before_identity = _expected_proc_identity( - proc_root, pid, expected_process - ) - if before_identity != initial_identity: - return [] - try: - opened = os.open( - descriptor, - os.O_RDONLY | getattr(os, "O_CLOEXEC", 0), - ) - except OSError: - continue - try: - opened_stat = os.fstat(opened) - if not statmod.S_ISREG(opened_stat.st_mode): - continue - actual_link = Path(os.readlink(f"/proc/self/fd/{opened}")) - actual_text = str(actual_link) - if actual_text.endswith(" (deleted)"): - actual_link = Path(actual_text[: -len(" (deleted)")]) - if ( - "rollout-" not in actual_link.name - or actual_link.suffix != ".jsonl" - ): - continue - resolved = actual_link.resolve(strict=False) - resolved.relative_to(home_resolved) - file_identity = (opened_stat.st_dev, opened_stat.st_ino) - if file_identity in seen: - continue - meta = _rollout_meta_fd(opened) - after_stat = os.fstat(opened) - if ( - opened_stat.st_dev, - opened_stat.st_ino, - opened_stat.st_mode, - ) != ( - after_stat.st_dev, - after_stat.st_ino, - after_stat.st_mode, - ): - continue - except (OSError, ValueError): - continue - finally: - os.close(opened) - after_identity = _expected_proc_identity( - proc_root, pid, expected_process - ) - if after_identity != before_identity: - return [] - seen.add(file_identity) - if meta is not None: - result.append(meta) - if _expected_proc_identity(proc_root, pid, expected_process) != initial_identity: - return [] - return result def index_codex_processes( process_table: Mapping[int, Mapping[str, Any]], proc_root: Path, codex_home: Path ) -> dict[int, list[dict[str, Any]]]: - if _runtime_platform() == DARWIN_PLATFORM: - result: dict[int, list[dict[str, Any]]] = {} - for pid, process in process_table.items(): - if not _is_native_codex(process): - continue - uuid = valid_uuid(process.get("codex_thread_id")) - result[pid] = ( - [ - { - "session_id": uuid, - "id": uuid, - "source": "cli", - "_turn_state": "state unavailable", - } - ] - if uuid - else [] - ) - return result - return { - pid: codex_open_rollouts( - pid, proc_root, codex_home, process - ) - for pid, process in process_table.items() - if _is_native_codex(process) - } + return _providers_codex.index_codex_processes( + process_table, + proc_root, + codex_home, + runtime_platform=_runtime_platform, + darwin_platform=DARWIN_PLATFORM, + is_native_codex=_is_native_codex, + rollout_by_uuid=codex_rollout_by_uuid, + open_rollouts=codex_open_rollouts, + ) -def _root_codex_uuid(metadata: Sequence[Mapping[str, Any]]) -> list[str]: - identities: set[str] = set() - for meta in metadata: - uuid = valid_uuid(meta.get("session_id")) - event_id = valid_uuid(meta.get("id")) - if meta.get("source") == "cli" and uuid and event_id == uuid: - identities.add(uuid) - return sorted(identities) +def _root_codex_uuid( + process: Mapping[str, Any], metadata: Sequence[Mapping[str, Any]] +) -> list[str]: + """Return exact root threads owned by one native Codex process.""" + return _providers_codex._root_codex_uuid( + process, + metadata, + is_codex_app_server=_is_codex_app_server, + ) -def _codex_turn_state( - metadata: Sequence[Mapping[str, Any]], wanted_uuid: str -) -> str: - """Turn state for one exact conversation, from its own rollout metadata.""" - for meta in metadata: - uuid = valid_uuid(meta.get("session_id")) - event_id = valid_uuid(meta.get("id")) - if meta.get("source") == "cli" and uuid == wanted_uuid and event_id == uuid: - state = meta.get("_turn_state") - if state in { - "needs your reply", - "reply optional", - "working", - "idle", - "state unavailable", - }: - return str(state) - return "state unavailable" +def _codex_home( + environ: Mapping[str, str] | None = None, *, default_home: Path | None = None +) -> Path: + """Resolve one Codex home consistently for reads and writes.""" + return _providers_codex._codex_home( + environ, + default_home=default_home, + process_environ=os.environ, + home=_home, + ) def _codex_paths() -> tuple[Path, Path]: - codex_home = Path( - os.environ.get("SESSION_KIT_CODEX_HOME", str(_home() / ".codex")) - ).expanduser() - db_override = os.environ.get("SESSION_KIT_CODEX_DB") - if db_override: - return codex_home, Path(db_override).expanduser() - databases = _codex_state_databases(codex_home) - if databases: - return codex_home, databases[-1] - return codex_home, codex_home / "state_5.sqlite" - - -def _read_bounded_owner_file( - path: Path, - *, - label: str, - max_bytes: int, - exact_mode: int | None = None, - allow_missing: bool = False, -) -> bytes | None: - flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) - try: - descriptor = os.open(path, flags) - except FileNotFoundError: - if allow_missing: - return None - raise CollectionError(f"{label} does not exist: {path}") - except OSError as exc: - raise CollectionError(f"cannot open {label}: {path}") from exc - try: - before = os.fstat(descriptor) - try: - pathname = path.lstat() - except OSError as exc: - raise CollectionError(f"cannot inspect {label}: {path}") from exc - mode = statmod.S_IMODE(before.st_mode) - if ( - not statmod.S_ISREG(before.st_mode) - or before.st_uid != os.geteuid() - or before.st_nlink != 1 - or before.st_size < 0 - or before.st_size > max_bytes - or (exact_mode is not None and mode != exact_mode) - or (exact_mode is None and mode & 0o022) - or (before.st_dev, before.st_ino) != (pathname.st_dev, pathname.st_ino) - ): - raise CollectionError(f"unsafe {label}: {path}") - chunks: list[bytes] = [] - remaining = before.st_size - while remaining: - block = os.read(descriptor, min(remaining, 65536)) - if not block: - raise CollectionError(f"short read from {label}: {path}") - chunks.append(block) - remaining -= len(block) - if os.read(descriptor, 1): - raise CollectionError(f"{label} grew while reading: {path}") - after = os.fstat(descriptor) - stable = ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns") - if any(getattr(before, field) != getattr(after, field) for field in stable): - raise CollectionError(f"{label} changed while reading: {path}") - return b"".join(chunks) - finally: - os.close(descriptor) + return _providers_codex._codex_paths( + environ=os.environ, + codex_home=_codex_home, + codex_state_databases=_codex_state_databases, + ) def read_codex_session_index( path: Path, warning_sink: list[str] | None = None ) -> dict[str, str]: """Read exact UUID-bound Codex names from the append-only local index.""" - payload = _read_bounded_owner_file( + return _providers_codex.read_codex_session_index( path, - label="Codex session index", + warning_sink, + read_bounded_owner_file=_read_bounded_owner_file, max_bytes=MAX_CODEX_SESSION_INDEX_BYTES, - allow_missing=True, ) - if payload is None: - return {} - try: - text = payload.decode("utf-8", "strict") - except UnicodeDecodeError as exc: - raise CollectionError("Codex session index is not valid UTF-8") from exc - names: dict[str, str] = {} - skipped = 0 - for number, line in enumerate(text.splitlines(), start=1): - if not line: - continue - try: - item = json.loads(line) - except ValueError: - skipped += 1 - continue - if not isinstance(item, Mapping): - skipped += 1 - continue - uuid = valid_uuid(item.get("id")) - title = clean_text(item.get("thread_name"), 120) - if not uuid or not title: - skipped += 1 - continue - names[uuid] = title - if skipped and warning_sink is not None: - warning_sink.append( - f"Codex session index ignored {skipped} malformed or unnamed " - f"entr{'y' if skipped == 1 else 'ies'}" - ) - return names def read_codex_db( @@ -1164,143 +875,12 @@ def read_codex_db( session_index_names: Mapping[str, str] | None = None, ) -> tuple[dict[str, dict[str, Any]], dict[str, list[dict[str, Any]]]]: """Read titles and spawn edges from Codex state without creating journal files.""" - index_names = ( - dict(session_index_names) - if session_index_names is not None - else read_codex_session_index(db_path.parent / "session_index.jsonl") - ) - threads = { - uuid: { - "id": uuid, - "title": "", - "cwd": "", - "session_index_name": clean_text(title, 120), - } - for raw_uuid, title in index_names.items() - if (uuid := valid_uuid(raw_uuid)) and clean_text(title, 120) - } - if not db_path.is_file(): - return threads, {} - uri = f"file:{db_path.resolve()}?mode=ro" - connection = sqlite3.connect(uri, uri=True, timeout=1.0) - connection.row_factory = sqlite3.Row - try: - connection.execute("PRAGMA query_only=ON") - columns = { - row["name"] for row in connection.execute("PRAGMA table_info(threads)").fetchall() - } - required = {"id", "title", "cwd"} - if not required.issubset(columns): - raise CollectionError("Codex threads table is missing required columns") - optional = [ - name - for name in ( - "name", - "agent_nickname", - "agent_path", - "thread_source", - "first_user_message", - ) - if name in columns - ] - select = ", ".join(["id", "title", "cwd", *optional]) - database_threads = { - row["id"]: dict(row) - for row in connection.execute(f"SELECT {select} FROM threads").fetchall() - if valid_uuid(row["id"]) - } - for uuid, thread in database_threads.items(): - index_title = threads.get(uuid, {}).get("session_index_name") - threads[uuid] = thread - if index_title: - threads[uuid]["session_index_name"] = index_title - edges: dict[str, list[dict[str, Any]]] = {} - edge_tables = { - row["name"] - for row in connection.execute( - "SELECT name FROM sqlite_master WHERE type='table'" - ).fetchall() - } - if "thread_spawn_edges" in edge_tables: - for row in connection.execute( - "SELECT parent_thread_id, child_thread_id, status FROM thread_spawn_edges" - ).fetchall(): - child = threads.get(row["child_thread_id"], {}) - edges.setdefault(row["parent_thread_id"], []).append( - { - "provider": "codex", - "uuid": valid_uuid(row["child_thread_id"]), - "pid": None, - "title": clean_text( - child.get("session_index_name") - or child.get("name") - or child.get("agent_nickname") - or child.get("agent_path") - or child.get("title"), - 80, - ) - or str(row["child_thread_id"])[:8], - "status": clean_text(row["status"], 40) or "unknown", - } - ) - for items in edges.values(): - items.sort(key=lambda item: (item["title"].casefold(), item["uuid"] or "")) - return threads, edges - finally: - connection.close() - - -def recovery_spec(provider: str, uuid: str, cwd: str | None = None) -> dict[str, Any]: - exact_uuid = valid_uuid(uuid) - if provider not in PROVIDERS or not exact_uuid: - raise ValueError("recovery requires provider claude|codex and an exact UUID") - argv = ( - ["claude", "--resume", exact_uuid] - if provider == "claude" - else ["codex", "--no-alt-screen", "resume", exact_uuid] - ) - clean_cwd = clean_text(cwd, 4096) if cwd else None - command = shlex.join(argv) - if clean_cwd: - command = f"cd -- {shlex.quote(clean_cwd)} && {command}" - return { - "available": True, - "provider": provider, - "uuid": exact_uuid, - "cwd": clean_cwd, - "argv": argv, - "command": command, - } - - -def _empty_recovery() -> dict[str, Any]: - return { - "available": False, - "provider": None, - "uuid": None, - "cwd": None, - "argv": [], - "command": None, - } - - -def _agent_identity( - *, - provider: str, - uuid: str | None, - pid: int | None, - process_table: Mapping[int, Mapping[str, Any]], - provenance: str, - confidence: str, -) -> dict[str, Any]: - process = process_table.get(pid or -1, {}) - return { - "uuid": uuid, - "pid": pid, - "process_start_ticks": process.get("start_ticks"), - "provenance": provenance, - "confidence": confidence, - } + return _providers_codex.read_codex_db( + db_path, + session_index_names, + session_index_reader=read_codex_session_index, + rollout_state_by_path=codex_rollout_state_by_path, + ) def _provider_title( @@ -1313,8 +893,13 @@ def _provider_title( automatic_titles: Mapping[str, str] | None = None, *, provider_title_is_explicit: bool = True, + native_name_source: str = "", + native_name_since: Any = None, + pushed_titles: Mapping[str, str] | None = None, + pending_native_titles: Mapping[str, Mapping[str, Any]] | None = None, + name_ownership: Mapping[str, Mapping[str, str]] | None = None, ) -> str: - return _provider_title_info( + return _model._provider_title( provider, uuid, native_title, @@ -1323,7 +908,13 @@ def _provider_title( started_at_unix_ms, automatic_titles, provider_title_is_explicit=provider_title_is_explicit, - )[0] + native_name_source=native_name_source, + native_name_since=native_name_since, + provider_title_info=_provider_title_info, + pushed_titles=pushed_titles, + pending_native_titles=pending_native_titles, + name_ownership=name_ownership, + ) def _provider_title_info( @@ -1336,178 +927,43 @@ def _provider_title_info( automatic_titles: Mapping[str, str] | None = None, *, provider_title_is_explicit: bool = True, + native_name_source: str = "", + native_name_since: Any = None, + pushed_titles: Mapping[str, str] | None = None, + pending_native_titles: Mapping[str, Mapping[str, Any]] | None = None, + name_ownership: Mapping[str, Mapping[str, str]] | None = None, ) -> tuple[str, str]: - key = f"{provider}:{uuid}" if uuid else "" - if uuid: - alias = aliases.get(key) - if alias: - return alias, "alias" - if native_title and provider_title_is_explicit: - return native_title, "native" - if uuid and automatic_naming_enabled(): - automatic = (automatic_titles or {}).get(key) - if automatic: - return automatic, "automatic" - context = _context_title(provider, cwd, started_at_unix_ms) - if context: - return context, "context" - if uuid: - return f"{provider.title()} {uuid[:8]}", "uuid" - return provider.title(), "provider" - - -def _context_title( - provider: str, cwd: str, started_at_unix_ms: int | None -) -> str: - """Return a deterministic project hint without exposing a full path.""" - if ( - isinstance(started_at_unix_ms, bool) - or not isinstance(started_at_unix_ms, int) - or started_at_unix_ms <= 0 - ): - return "" - try: - local_start = dt.datetime.fromtimestamp( - started_at_unix_ms / 1000, tz=dt.timezone.utc - ).astimezone() - except (OverflowError, OSError, ValueError): - return "" - timestamp = local_start.strftime("%b %d %H:%M") - clean_cwd = clean_text(cwd, 4096) - if not clean_cwd or not clean_cwd.startswith("/"): - return f"{provider.title()} started {timestamp}" - parts = [ - clean_text(part, 40) - for part in Path(clean_cwd).parts - if part not in {"", os.sep} - ] - generic = { - "app", - "apps", - "code", - "current", - "home", - "repo", - "repos", - "src", - "srv", - "v2", - "var", - "workspace", - "workspaces", - } - home_name = clean_text(_home().name, 40).casefold() - for part in reversed(parts): - folded = part.casefold() - if ( - part - and not part.startswith(".") - and folded not in generic - and folded != home_name - ): - return f"{provider.title()} in {part} at {timestamp}" - return f"{provider.title()} started {timestamp}" - - -def _shell_title( - tree: Sequence[int], root_pid: int, process_table: Mapping[int, Mapping[str, Any]] -) -> tuple[str, str, int | None]: - for pid in tree: - if pid == root_pid: - continue - process = process_table.get(pid, {}) - comm = clean_text(process.get("comm"), 80) - if comm and comm not in {"bash", "zsh", "fish", "script"}: - return comm, clean_text(process.get("cwd"), 4096), pid - root = process_table.get(root_pid, {}) - return "Idle shell", clean_text(root.get("cwd"), 4096), root_pid - - -def _base_agent( - *, - row: int | None, - shpool_id: str | None, - shpool_status: str | None, - availability: str | None, - provider: str, - identity: dict[str, Any], - title: str, - title_source: str, - native_title: str, - cwd: str, - started_at_unix_ms: int | None, - process_age_seconds: int | None, - agent_status: str, - needs_you: bool, - subagents: list[dict[str, Any]], - recovery: dict[str, Any], - diagnostics: list[str], - shpool_shell: dict[str, Any] | None, -) -> dict[str, Any]: - mutation_allowed, mutation_reason = ( - shpool_id_mutation_policy(shpool_id) - if shpool_id is not None - else (False, "outside-shpool") + return _model._provider_title_info( + provider, + uuid, + native_title, + aliases, + cwd, + started_at_unix_ms, + automatic_titles, + provider_title_is_explicit=provider_title_is_explicit, + native_name_source=native_name_source, + native_name_since=native_name_since, + context_title=_context_title, + pushed_titles=pushed_titles, + pending_native_titles=pending_native_titles, + name_ownership=name_ownership, ) - return { - "row": row, - "terminal_number": None, - "shpool_id": shpool_id, - "shpool_id_raw": shpool_id, - "display_shpool_id": ( - display_shpool_id(shpool_id) if shpool_id is not None else None - ), - "mutation_allowed": mutation_allowed, - "mutation_rejection_reason": mutation_reason, - "shpool_status": shpool_status, - "availability": availability, - "provider": provider, - "display_provider": provider, - "setup_incomplete": False, - "identity": identity, - "title": clean_text(title, 120), - "title_source": clean_text(title_source, 20), - "display_title": clean_text(title, 120), - "native_title": clean_text(native_title, 120), - "cwd": clean_text(cwd, 4096), - "started_at_unix_ms": started_at_unix_ms, - "process_age_seconds": process_age_seconds, - "agent_status": clean_text(agent_status, 60) or "unknown", - "needs_you": bool(needs_you), - "subagents": subagents, - "recovery": recovery, - "diagnostics": diagnostics, - "shpool_shell": shpool_shell, - } -def _process_age(pid: int | None, process_table: Mapping[int, Mapping[str, Any]], now: float) -> int | None: - if not pid: - return None - ticks = process_table.get(pid, {}).get("start_ticks") - if not isinstance(ticks, int): - return None - if process_table.get(pid, {}).get("generation_kind") == "darwin-start-usec": - age = now - (ticks / 1_000_000) - return max(0, int(age)) if age >= 0 else None - try: - hz = os.sysconf(os.sysconf_names["SC_CLK_TCK"]) - uptime = float(Path("/proc/uptime").read_text().split()[0]) - except (OSError, ValueError, KeyError): - return None - age = uptime - (ticks / hz) - return max(0, int(age)) if age >= 0 else None +def _context_title(provider: str, cwd: str, started_at_unix_ms: int | None) -> str: + """Return a deterministic project hint without exposing a full path.""" + return _model._context_title( + provider, + cwd, + started_at_unix_ms, + home=_home, + ) def _regular_file_mtime_ms(path: Path) -> int | None: """Return an exact regular file's mtime without following a symlink.""" - try: - metadata = path.lstat() - except OSError: - return None - if not statmod.S_ISREG(metadata.st_mode): - return None - return metadata.st_mtime_ns // 1_000_000 + return _collector.regular_file_mtime_ms(path) def recent_output_times( @@ -1516,130 +972,15 @@ def recent_output_times( journal_dir: Path | None = None, recovery_dir: Path | None = None, ) -> dict[str, int]: - """Map operational shpool IDs to their exact active journal mtime. - - Legacy sessions may still write through a recovery-map path after their - original inode was moved. A valid exact map entry takes precedence, just - as ``sp history`` does. Otherwise the collector checks the raw-ID legacy - file or every regular segment in the raw-ID directory. Unmanaged IDs, - symlinks, missing files, and ambiguous map entries have no activity time. - """ - wanted = { - value - for value in shpool_ids - if shpool_id_mutation_policy(value) == (True, None) - } - if not wanted: - return {} - active_root = journal_dir or default_journal_dir() - recovered_root = recovery_dir or default_journal_recovery_dir() - mapped: dict[str, Path | None] = {} - ambiguous_mappings: set[str] = set() - map_path = recovered_root / "current-map.tsv" - try: - map_metadata = map_path.lstat() - if not statmod.S_ISREG(map_metadata.st_mode): - raise OSError("recovery map is not a regular file") - with map_path.open("r", encoding="utf-8", errors="replace") as handle: - for raw_line in handle: - raw_id, separator, raw_path = raw_line.rstrip("\r\n").partition("\t") - if not separator or raw_id not in wanted or not raw_path.startswith("/"): - continue - candidate = Path(raw_path) - previous = mapped.get(raw_id) - if raw_id in mapped and previous != candidate: - mapped[raw_id] = None - ambiguous_mappings.add(raw_id) - else: - mapped[raw_id] = candidate - except OSError: - pass - - result: dict[str, int] = {} - for raw_id in wanted: - if raw_id in ambiguous_mappings: - continue - mapped_path = mapped.get(raw_id) - if mapped_path is not None: - mapped_mtime = _regular_file_mtime_ms(mapped_path) - if mapped_mtime is not None: - result[raw_id] = mapped_mtime - continue - - legacy_mtime = _regular_file_mtime_ms(active_root / f"{raw_id}.raw") - if legacy_mtime is not None: - result[raw_id] = legacy_mtime - continue - - segment_dir = active_root / raw_id - try: - directory_metadata = segment_dir.lstat() - if not statmod.S_ISDIR(directory_metadata.st_mode): - continue - segment_mtimes = [ - timestamp - for path in segment_dir.iterdir() - if re.fullmatch(r"segment-[0-9]+\.raw", path.name) - for timestamp in [_regular_file_mtime_ms(path)] - if timestamp is not None - ] - except OSError: - continue - if segment_mtimes: - result[raw_id] = max(segment_mtimes) - return result - - -def _read_private_launch_file(directory_fd: int, name: str) -> bytes: - flags = os.O_RDONLY - if hasattr(os, "O_CLOEXEC"): - flags |= os.O_CLOEXEC - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - descriptor = os.open(name, flags, dir_fd=directory_fd) - try: - before = os.fstat(descriptor) - if ( - not statmod.S_ISREG(before.st_mode) - or statmod.S_IMODE(before.st_mode) != 0o600 - or before.st_uid != os.geteuid() - or before.st_nlink != 1 - or before.st_size <= 0 - or before.st_size > 8192 - ): - raise CollectionError("unsafe retained launch record") - chunks: list[bytes] = [] - remaining = before.st_size - while remaining: - block = os.read(descriptor, min(remaining, 4096)) - if not block: - raise CollectionError("short retained launch record") - chunks.append(block) - remaining -= len(block) - if os.read(descriptor, 1): - raise CollectionError("retained launch record grew while reading") - after = os.fstat(descriptor) - stable = ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns") - if any(getattr(before, field) != getattr(after, field) for field in stable): - raise CollectionError("retained launch record changed while reading") - return b"".join(chunks) - finally: - os.close(descriptor) - - -def _launch_fields(payload: bytes, count: int) -> list[str] | None: - try: - text = payload.decode("utf-8", "strict") - except UnicodeDecodeError: - return None - if ( - not text.endswith("\n") - or text.count("\n") != 1 - or "\r" in text - ): - return None - fields = text[:-1].split("\t") - return fields if len(fields) == count else None + """Map operational shpool IDs to their exact active journal mtime.""" + return _collector.recent_output_times( + shpool_ids, + journal_dir=journal_dir, + recovery_dir=recovery_dir, + journal_dir_factory=default_journal_dir, + journal_recovery_dir_factory=default_journal_recovery_dir, + regular_file_mtime_ms=_regular_file_mtime_ms, + ) def apply_retained_setup_attributions( @@ -1649,154 +990,30 @@ def apply_retained_setup_attributions( boot_id: str | None = None, ) -> dict[str, Any]: """Add display-only provider hints from exact retained startup proofs.""" - root = start_dir or default_start_dir() - current_boot_id = boot_id if boot_id is not None else _boot_id() - if not current_boot_id or "\t" in current_boot_id or "\n" in current_boot_id: - return inventory - flags = os.O_RDONLY - if hasattr(os, "O_CLOEXEC"): - flags |= os.O_CLOEXEC - if hasattr(os, "O_DIRECTORY"): - flags |= os.O_DIRECTORY - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - try: - directory_fd = os.open(root, flags) - except OSError: - return inventory - try: - directory = os.fstat(directory_fd) - if ( - not statmod.S_ISDIR(directory.st_mode) - or statmod.S_IMODE(directory.st_mode) != 0o700 - or directory.st_uid != os.geteuid() - ): - return inventory - generation = inventory.get("daemon_generation") - if not isinstance(generation, Mapping): - return inventory - daemon_pid = generation.get("pid") - daemon_start = generation.get("process_start_ticks") - if ( - isinstance(daemon_pid, bool) - or not isinstance(daemon_pid, int) - or daemon_pid <= 0 - or isinstance(daemon_start, bool) - or not isinstance(daemon_start, int) - or daemon_start <= 0 - ): - return inventory + return _collector.apply_retained_setup_attributions( + inventory, + start_dir=start_dir, + boot_id=boot_id, + start_dir_factory=default_start_dir, + boot_id_reader=_boot_id, + launch_fields=_launch_fields, + read_private_launch_file=_read_private_launch_file, + ) - for item in inventory.get("sessions", ()): - if not isinstance(item, dict) or item.get("provider") != "unknown": - continue - raw_id = item.get("shpool_id_raw") - if shpool_id_mutation_policy(raw_id) != (True, None): - continue - shell = item.get("shpool_shell") - identity = item.get("identity") - started = item.get("started_at_unix_ms") - if ( - not isinstance(shell, Mapping) - or not isinstance(identity, Mapping) - or identity.get("confidence") != "unknown" - or identity.get("uuid") is not None - or identity.get("pid") != shell.get("pid") - or identity.get("process_start_ticks") - != shell.get("process_start_ticks") - or isinstance(started, bool) - or not isinstance(started, int) - or started <= 0 - or isinstance(shell.get("pid"), bool) - or not isinstance(shell.get("pid"), int) - or shell.get("pid", 0) <= 0 - or isinstance(shell.get("process_start_ticks"), bool) - or not isinstance(shell.get("process_start_ticks"), int) - or shell.get("process_start_ticks", 0) <= 0 - ): - continue - try: - start_payload = _read_private_launch_file(directory_fd, raw_id) - expected_payload = _read_private_launch_file( - directory_fd, f"{raw_id}.expected" - ) - except (CollectionError, OSError): - continue - start = _launch_fields(start_payload, 4) - if start is None: - legacy_start = _launch_fields(start_payload, 3) - if legacy_start is not None: - legacy_uuid = legacy_start[2] - start = [ - *legacy_start, - "resume" if legacy_uuid else "new", - ] - expected = _launch_fields(expected_payload, 10) - if expected is None: - legacy_expected = _launch_fields(expected_payload, 9) - if legacy_expected is not None: - legacy_uuid = legacy_expected[8] - expected = [ - *legacy_expected, - "resume" if legacy_uuid else "new", - ] - if start is None or expected is None: - continue - provider, cwd, uuid, launch_mode = start - ( - side_provider, - side_cwd, - side_boot_id, - side_started, - side_shell_pid, - side_shell_start, - side_daemon_pid, - side_daemon_start, - side_uuid, - side_launch_mode, - ) = expected - numeric = ( - side_started, - side_shell_pid, - side_shell_start, - side_daemon_pid, - side_daemon_start, - ) - if any(re.fullmatch(r"[1-9][0-9]*", value) is None for value in numeric): - continue - if ( - provider not in {"claude", "codex"} - or launch_mode not in {"new", "resume", "fork"} - or side_launch_mode != launch_mode - or (launch_mode == "new" and uuid != "") - or ( - launch_mode in {"resume", "fork"} - and valid_uuid(uuid) != uuid - ) - or side_provider != provider - or side_cwd != cwd - or item.get("cwd") != cwd - or side_boot_id != current_boot_id - or int(side_started) != started - or int(side_shell_pid) != shell.get("pid") - or int(side_shell_start) != shell.get("process_start_ticks") - or int(side_daemon_pid) != daemon_pid - or int(side_daemon_start) != daemon_start - or side_uuid != uuid - or (uuid and valid_uuid(uuid) != uuid) - ): - continue - item["display_provider"] = provider - item["setup_incomplete"] = True - item["display_title"] = f"{provider.title()} setup incomplete" - if uuid and launch_mode == "resume": - item["_terminal_identity_hint"] = { - "provider": provider, - "uuid": uuid, - } - finally: - os.close(directory_fd) - return inventory + +# One repeatable launch, one key. The pattern is the exact shape a launcher +# stamps onto a process, so a stray or truncated value can never bind a +# dispatch to an inventory row. +_LAUNCH_KEY_RE = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\Z") +_DAEMON_BINDING_UNSET = object() + + +def _exact_launch_key(value: object) -> str: + """Return the exact launch idempotency key, or "" when the value is not one.""" + if not isinstance(value, str): + return "" + candidate = value.strip() + return candidate if _LAUNCH_KEY_RE.match(candidate) else "" def build_inventory( @@ -1810,706 +1027,483 @@ def build_inventory( config: Mapping[str, Any], now: float | None = None, recent_output_by_shpool_id: Mapping[str, int] | None = None, + *, + daemon_binding: Mapping[str, Any] | None | object = _DAEMON_BINDING_UNSET, ) -> dict[str, Any]: """Pure inventory composition for fixture tests and the live collector.""" - current_time = time.time() if now is None else now - shpool_sessions = _parse_shpool_payload(shpool_payload) - claude_agents = _parse_claude_payload(claude_payload) - claude_by_pid = {item["pid"]: item for item in claude_agents} - aliases = _valid_aliases(config.get("aliases")) - automatic_titles = _valid_automatic_titles(config.get("automatic_titles")) - automatic_failures = _valid_automatic_title_failures( - config.get("automatic_title_failures") - ) - codex_threads, codex_edges = codex_db_rows - roots, root_diagnostics = shpool_roots( - (item["name"] for item in shpool_sessions), process_table - ) - child_index = _children_index(process_table) - recent_outputs = recent_output_by_shpool_id or {} - mapped_claude: set[int] = set() - mapped_codex: set[int] = set() - sessions: list[dict[str, Any]] = [] - - for shpool in shpool_sessions: - name = shpool["name"] - status = shpool["status"] - availability = "ready" if status == "Disconnected" else "attached" - diagnostics = list(root_diagnostics.get(name, ())) - root_pid = roots.get(name) - tree: list[int] = [] - if root_pid: - try: - tree = descendants( - root_pid, - child_index, - max_nodes=int( - config.get("max_proc_nodes", DEFAULT_MAX_PROC_NODES) - ), - max_depth=int(config.get("max_proc_depth", 32)), - ) - except CollectionError as exc: - diagnostics.append(str(exc)) - claude_candidates = [ + # A live binding is the daemon identity observed immediately before and + # after the list payload, then revalidated before this composition. Never + # choose a different daemon after those names have already been supplied. + # Pure one-daemon fixture callers retain their historical fast path; a + # multi-daemon fixture without payload provenance is deliberately refused. + binding_refused = False + if daemon_binding is _DAEMON_BINDING_UNSET: + observed = tuple(_processes._kit_daemons(process_table, _is_shpool_daemon)) + definite = tuple( + pid for pid, process in process_table.items() if _is_shpool_daemon(process) + ) + unknown = tuple( pid - for pid in tree - if pid in claude_by_pid - and claude_by_pid[pid].get("kind") in {"", "interactive"} - ] - codex_candidates: list[tuple[int, str]] = [] - for pid in tree: - for uuid in _root_codex_uuid(codex_index.get(pid, ())): - codex_candidates.append((pid, uuid)) - codex_candidates = sorted(set(codex_candidates)) - - starting_provider = None - provider_title_is_explicit = True - if len(claude_candidates) == 1 and not codex_candidates: - pid = claude_candidates[0] - agent = claude_by_pid[pid] - uuid = agent["uuid"] - native_title = agent["title"] - # Claude persists its conversation auto-title as a transcript - # ai-title record while the session record keeps a derived window - # label. The visible conversation title outranks the derived - # label; any explicit rename keeps outranking both. - if agent.get("ai_title") and agent.get("name_source") == "derived": - native_title = agent["ai_title"] - provider = "claude" - cwd = agent["cwd"] or clean_text(process_table.get(pid, {}).get("cwd"), 4096) - identity = _agent_identity( - provider=provider, - uuid=uuid, - pid=pid, - process_table=process_table, - provenance="claude agents --json", - confidence="exact", - ) - subagents = claude_subagents(uuid, process_table) - recovery = recovery_spec(provider, uuid, cwd or None) - mapped_claude.add(pid) - agent_status = agent["status"] - needs_you = agent["needs_you"] - elif len(codex_candidates) == 1 and not claude_candidates: - pid, uuid = codex_candidates[0] - thread = codex_threads.get(uuid, {}) - native_title = clean_text( - thread.get("session_index_name") - or thread.get("name") - or thread.get("title"), - 120, - ) - # The session-index name is exact rename evidence. A database - # title also counts as a real name on current Codex, which - # auto-titles threads there — recognizable by the schema carrying - # first_user_message separately. Older stores kept the raw prompt - # in the title, and a title that still echoes the first prompt is - # that same behavior on any schema. - db_title = clean_text(thread.get("title"), 120) - first_message = clean_text(thread.get("first_user_message"), 200) - has_split_prompt_schema = "first_user_message" in thread - title_echoes_prompt = _codex_title_echoes_prompt( - db_title, first_message - ) - provider_title_is_explicit = bool( - thread.get("session_index_name") - ) or ( - has_split_prompt_schema - and bool(db_title) - and not title_echoes_prompt - ) - provider = "codex" - cwd = clean_text( - thread.get("cwd") or process_table.get(pid, {}).get("cwd"), 4096 - ) - identity = _agent_identity( - provider=provider, - uuid=uuid, - pid=pid, - process_table=process_table, - provenance="native codex PID open source=cli rollout", - confidence="exact", - ) - subagents = list(codex_edges.get(uuid, ())) - recovery = recovery_spec(provider, uuid, cwd or None) - mapped_codex.add(pid) - turn_state = _codex_turn_state(codex_index.get(pid, ()), uuid) - agent_status = turn_state - needs_you = turn_state == "needs your reply" - else: - known_provider_process = any( - clean_text(process_table.get(pid, {}).get("comm"), 128) in {"claude", "codex"} - or _is_native_codex(process_table.get(pid, {})) - for pid in tree - ) - ambiguous = len(claude_candidates) > 1 or len(codex_candidates) > 1 or ( - bool(claude_candidates) and bool(codex_candidates) - ) - if ambiguous or known_provider_process or not root_pid: - provider = "unknown" - pid = root_pid - native_title = "Unresolved provider session" - cwd = clean_text(process_table.get(root_pid or -1, {}).get("cwd"), 4096) - diagnostics.append( - f"identity candidates: Claude={len(claude_candidates)}, Codex={len(codex_candidates)}" - ) - agent_status = "unknown" - # A new Codex session has no rollout until its first message, so - # it cannot be identified — but calling it "unresolved" reads as - # broken when it is simply unused. Name what is actually running - # so the row is recognisable. Identity stays unknown: this is a - # label, and it grants nothing. - if not ambiguous: - running = { - "codex" - if _is_native_codex(process_table.get(candidate, {})) - or clean_text( - process_table.get(candidate, {}).get("comm"), 128 - ) - == "codex" - else clean_text( - process_table.get(candidate, {}).get("comm"), 128 - ) - for candidate in tree - } - for candidate_provider in ("codex", "claude"): - if candidate_provider in running: - starting_provider = candidate_provider - native_title = ( - f"{candidate_provider.title()} started, no messages yet" - ) - break - else: - provider = "shell" - native_title, cwd, pid = _shell_title(tree, root_pid, process_table) - agent_status = "idle" if native_title == "Idle shell" else "running" - uuid = None - identity = _agent_identity( - provider=provider, - uuid=None, - pid=pid, - process_table=process_table, - provenance="process tree" if root_pid else "none", - confidence="unknown" if provider == "unknown" else "exact", - ) - subagents = [] - recovery = _empty_recovery() - needs_you = False - - title, title_source = _provider_title_info( - provider, - uuid, - native_title, - aliases, - cwd, - shpool["started_at_unix_ms"], - automatic_titles, - provider_title_is_explicit=provider_title_is_explicit, + for pid, process in process_table.items() + if _processes._is_unknown_shpool_daemon(process) ) - recent_output_at = recent_outputs.get(name) + daemon_pids = observed if len(definite) == 1 and not unknown else () + binding_refused = len(daemon_pids) != 1 + elif isinstance(daemon_binding, Mapping): + pid = daemon_binding.get("pid") + start_ticks = daemon_binding.get("process_start_ticks") + process = process_table.get(pid) if isinstance(pid, int) else None if ( - isinstance(recent_output_at, bool) - or not isinstance(recent_output_at, int) - or recent_output_at < 0 + isinstance(pid, int) + and not isinstance(pid, bool) + and isinstance(start_ticks, int) + and not isinstance(start_ticks, bool) + and isinstance(process, Mapping) + and process.get("start_ticks") == start_ticks + and _is_shpool_daemon(process) ): - recent_output_at = None - sessions.append( - _base_agent( - row=None, - shpool_id=name, - shpool_status=status, - availability=availability, - provider=provider, - identity=identity, - title=title, - title_source=title_source, - native_title=native_title, - cwd=cwd, - started_at_unix_ms=shpool["started_at_unix_ms"], - process_age_seconds=_process_age(identity.get("pid"), process_table, current_time), - agent_status=agent_status, - needs_you=needs_you, - subagents=subagents, - recovery=recovery, - diagnostics=diagnostics, - shpool_shell=( - { - "pid": root_pid, - "process_start_ticks": process_table.get(root_pid, {}).get( - "start_ticks" - ), - } - if root_pid - else None - ), - ) + daemon_pids = (pid,) + else: + daemon_pids = () + binding_refused = True + else: + daemon_pids = () + binding_refused = True + + def roots_from_selection( + session_names: Iterable[str], + table: Mapping[int, Mapping[str, Any]], + ) -> tuple[dict[str, int], dict[str, list[str]]]: + return _processes.shpool_roots( + session_names, + table, + is_shpool_daemon=_is_shpool_daemon, + daemon_pids=daemon_pids, ) - if provider == "unknown" and starting_provider: - # Display only. `provider` stays "unknown", so identity, mutation - # and recovery decisions are completely unaffected. - sessions[-1]["display_provider"] = starting_provider - sessions[-1]["recent_output_at_unix_ms"] = recent_output_at - sessions[-1]["recent_output_age_seconds"] = ( - max(0, int(current_time - (recent_output_at / 1000))) - if recent_output_at is not None - else None + + def generation_from_selection( + table: Mapping[int, Mapping[str, Any]], + ) -> dict[str, int] | None: + return _processes.daemon_generation( + table, + is_shpool_daemon=_is_shpool_daemon, + daemon_pids=daemon_pids, ) - title_key = f"{provider}:{uuid}" if uuid else "" - attempts = automatic_failures.get(title_key, 0) - if provider not in PROVIDERS or not uuid: - automatic_state = "not-applicable" - elif not automatic_naming_enabled(): - automatic_state = "disabled" - elif title_source in {"alias", "native"}: - automatic_state = "not-needed" - elif title_source == "automatic": - automatic_state = "ready" - elif attempts >= 2: - automatic_state = "failed" - else: - automatic_state = "pending" - sessions[-1]["automatic_name_state"] = automatic_state - sessions[-1]["automatic_name_attempts"] = attempts - outside_agents: list[dict[str, Any]] = [] - for agent in claude_agents: - if agent["pid"] in mapped_claude or agent["kind"] not in {"", "interactive"}: + inventory = _collector.build_inventory( + shpool_payload, + claude_payload, + process_table, + codex_index, + codex_db_rows, + config, + now, + recent_output_by_shpool_id, + descendants=descendants, + shpool_roots=roots_from_selection, + daemon_generation=generation_from_selection, + parse_claude_payload=_parse_claude_payload, + native_claude_uuid=_native_claude_uuid, + claude_subagents=claude_subagents, + root_codex_uuid=_root_codex_uuid, + provider_title_info=_provider_title_info, + session_color=session_color, + valid_colors=_valid_colors, + adopt_launch_colors=_adopt_launch_colors, + codex_title_echoes_prompt=_codex_title_echoes_prompt, + schema_version=SCHEMA_VERSION, + claude_palette=CLAUDE_SESSION_COLORS, + default_max_proc_nodes=DEFAULT_MAX_PROC_NODES, + ) + if binding_refused: + for item in inventory.get("sessions", ()): + if not isinstance(item, dict): + continue + item["mutation_allowed"] = False + item["mutation_rejection_reason"] = "daemon-identity-unresolved" + children = _children_index(process_table) + # Where the per-conversation model cache lives. Without a state directory + # (fixture composition, `--no-write` inspection) every model read is a + # bounded scan instead: slower, never wrong, and it writes nothing. + raw_state_dir = config.get("state_dir") + model_cache_dir = Path(str(raw_state_dir)) if raw_state_dir else None + for item in inventory.get("sessions", ()): + if not isinstance(item, dict): continue - uuid = agent["uuid"] - recovery = recovery_spec("claude", uuid, agent["cwd"] or None) - outside_title, outside_title_source = _provider_title_info( - "claude", - uuid, - agent["title"], - aliases, - agent["cwd"], - agent["started_at_unix_ms"], - automatic_titles, - ) - outside_agents.append( - _base_agent( - row=None, - shpool_id=None, - shpool_status=None, - availability=None, - provider="claude", - identity=_agent_identity( - provider="claude", - uuid=uuid, - pid=agent["pid"], - process_table=process_table, - provenance="claude agents --json", - confidence="exact", - ), - title=outside_title, - title_source=outside_title_source, - native_title=agent["title"], - cwd=agent["cwd"], - started_at_unix_ms=agent["started_at_unix_ms"], - process_age_seconds=_process_age(agent["pid"], process_table, current_time), - agent_status=agent["status"], - needs_you=agent["needs_you"], - subagents=claude_subagents(uuid, process_table), - recovery=recovery, - diagnostics=["active provider root is outside shpool"], - shpool_shell=None, + shell = item.get("shpool_shell") + shell_pid = shell.get("pid") if isinstance(shell, Mapping) else None + candidate_pids = ( + descendants( + shell_pid, + children, + max_nodes=DEFAULT_MAX_PROC_NODES, + max_depth=8, ) + if isinstance(shell_pid, int) + else () ) - for pid, metadata in codex_index.items(): - if pid in mapped_codex: - continue - identities = _root_codex_uuid(metadata) - if len(identities) != 1: - continue - uuid = identities[0] - thread = codex_threads.get(uuid, {}) - native_title = clean_text( - thread.get("session_index_name") - or thread.get("name") - or thread.get("title"), - 120, - ) - cwd = clean_text(thread.get("cwd") or process_table.get(pid, {}).get("cwd"), 4096) - outside_title, outside_title_source = _provider_title_info( - "codex", - uuid, - native_title, - aliases, - cwd, - None, - automatic_titles, - provider_title_is_explicit=bool(thread.get("session_index_name")), - ) - turn_state = _codex_turn_state(metadata, uuid) - outside_agents.append( - _base_agent( - row=None, - shpool_id=None, - shpool_status=None, - availability=None, - provider="codex", - identity=_agent_identity( - provider="codex", - uuid=uuid, - pid=pid, - process_table=process_table, - provenance="native codex PID open source=cli rollout", - confidence="exact", - ), - title=outside_title, - title_source=outside_title_source, - native_title=native_title, - cwd=cwd, - started_at_unix_ms=None, - process_age_seconds=_process_age(pid, process_table, current_time), - agent_status=turn_state, - needs_you=turn_state == "needs your reply", - subagents=list(codex_edges.get(uuid, ())), - recovery=recovery_spec("codex", uuid, cwd or None), - diagnostics=["active provider root is outside shpool"], - shpool_shell=None, + evidence: set[tuple[str, str]] = set() + for candidate_pid in candidate_pids: + candidate = process_table.get(candidate_pid, {}) + model = candidate.get("requested_model") + launch_key = _exact_launch_key(candidate.get("launch_idempotency_key")) + argv = candidate.get("cmdline") + command_model = ( + _arg_value(argv, "--model") if isinstance(argv, list) else "" ) + command_names = { + Path(str(value)).name + for value in (argv[:2] if isinstance(argv, list) else ()) + } + if ( + item.get("provider") in command_names + and isinstance(model, str) + and model + and command_model == model + and launch_key + ): + evidence.add((model, launch_key)) + if len(evidence) == 1: + model, launch_key = next(iter(evidence)) + item["actual_model"] = model + item["launch_idempotency_key"] = launch_key + # What model this session is on RIGHT NOW, for every row rather than + # only the keyed launches above. The conversation's own record is the + # source (picker-rows ruling): a launch argument is not what a session + # runs. Handoff capability still comes from the process evidence. + item.update(_live_model_fields(item, process_table, model_cache_dir)) + item["model_handoff_capable"] = _model_handoff_capable(item, process_table) + return inventory + + +def _live_model_fields( + item: Mapping[str, Any], + process_table: Mapping[int, Mapping[str, Any]], + cache_dir: Path | None, +) -> dict[str, str]: + """The model a session runs now, its product name, and where that came from. + + The command line is the fallback, never the answer. `--model` records what + a session was STARTED with; a person who types `/model` inside it changes + what it actually runs and never touches argv again, so a row built from the + process table shows the wrong model, confidently, for the rest of that + session's life. The conversation's own record moves when the person moves + the model, so that is what is read, and the command line answers only for + a session whose record this machine cannot reach. + """ + provider = _common.clean_text(item.get("provider"), 20).casefold() + if provider not in PROVIDERS: + return { + "model": "", + "display_model": "", + "model_source": "", + "model_state": _labels.MODEL_STATE_NOT_APPLICABLE, + } + identity = item.get("identity") + raw_uuid = identity.get("uuid") if isinstance(identity, Mapping) else None + uuid = _common.valid_uuid(raw_uuid) if isinstance(raw_uuid, str) else "" + live, found = ( + _session_model_reader.current_model( + provider, + key=uuid, + locate=lambda: _transcripts.locate_transcript(provider, uuid), + cache_dir=cache_dir, ) - sessions.sort( - key=lambda item: ( - AVAILABILITY_ORDER.get(item["availability"], 9), - PROVIDER_ORDER.get(item["provider"], 9), - not bool(item.get("needs_you")), - item.get("recent_output_at_unix_ms") is None, - -(item.get("recent_output_at_unix_ms") or 0), - natural_name_key(item["shpool_id"]), - ) - ) - for row, item in enumerate(sessions, start=1): - item["row"] = row - outside_agents.sort( - key=lambda item: ( - PROVIDER_ORDER.get(item["provider"], 9), - item["title"].casefold(), - item["identity"].get("uuid") or "", - ) + if uuid + else ("", False) ) - color_overrides = _valid_colors(config.get("colors")) - color_overrides = _adopt_launch_colors(config, sessions, color_overrides) - for item in sessions + outside_agents: - item["display_color"] = session_color( - item.get("provider"), - (item.get("identity") or {}).get("uuid"), - color_overrides, - ) + if live: + return { + "model": live, + "display_model": _session_model_reader.human_model_name(provider, live), + "model_source": _session_model_reader.SOURCE_TRANSCRIPT, + "launch_model": "", + "model_state": "", + } + # The launch argument is recorded as evidence and is NEVER shown as the live + # model. `--model` says what the session was STARTED with; a `/model` typed + # inside it does not touch argv, and a conversation resumed from another + # machine has no local record at all, no record is proof of nothing, not + # proof that the launch value is still current. Showing it would be a + # confident wrong answer, and the operator asked for this column filled in + # order to trust it. What the kit cannot prove, it does not say. + # + # The row still says WHICH kind of no: a conversation this machine can read + # that has simply not been answered yet is a different fact from one whose + # record is gone. return { - "schema_version": SCHEMA_VERSION, - "generated_at": _utc_now(current_time), - "source": "live", - "stale": False, - "warnings": [], - "daemon_generation": daemon_generation(process_table), - "sessions": sessions, - "outside_agents": outside_agents, + "model": "", + "display_model": "", + "model_source": "", + "launch_model": _session_model(item, process_table), + "model_state": ( + _labels.MODEL_STATE_NO_REPLY_YET + if found + else _labels.MODEL_STATE_UNREADABLE + ), } -def _shpool_executable() -> str: - """Resolve shpool for contexts (systemd user services) whose PATH omits it.""" - found = shutil.which("shpool") - if found: - return found - fallback = Path.home() / ".cargo" / "bin" / "shpool" - return str(fallback) if fallback.is_file() else "shpool" +def _session_model( + item: Mapping[str, Any], process_table: Mapping[int, Mapping[str, Any]] +) -> str: + """The model a session's live provider was started with, or "".""" + identity = item.get("identity") + pid = identity.get("pid") if isinstance(identity, Mapping) else None + if not isinstance(pid, int) or isinstance(pid, bool): + return "" + process = process_table.get(pid) + if not isinstance(process, Mapping): + return "" + argv = process.get("cmdline") + model = _arg_value(argv, "--model") if isinstance(argv, list) else "" + if not model: + model = process.get("requested_model") or "" + return _common.clean_text(model, 80) -def collect_live( - config: Mapping[str, Any], - *, - runner: Runner | None = None, - proc_root: Path | None = None, -) -> dict[str, Any]: - """Collect a live inventory using one call per external list command.""" - invoke = runner or default_runner - timeout = float(config.get("command_timeout_seconds", 6.0)) - root = proc_root or Path(os.environ.get("SESSION_KIT_PROC_ROOT", "/proc")) - try: - shpool_payload = _command_json( - fixture_env="SESSION_KIT_SHPOOL_JSON_FILE", - command_env="SESSION_KIT_SHPOOL_CMD", - default_command=(_shpool_executable(), "list", "--json"), - runner=invoke, - timeout=timeout, - ) - except (OSError, ValueError, subprocess.SubprocessError, CollectionError) as exc: - raise CollectionError(f"cannot collect shpool snapshot: {exc}") from exc - _parse_shpool_payload(shpool_payload) - process_table = platform_process_table( - root, int(config.get("max_proc_nodes", DEFAULT_MAX_PROC_NODES)) - ) - warnings: list[str] = [] - claude_failed = False - try: - claude_payload = _command_json( - fixture_env="SESSION_KIT_CLAUDE_JSON_FILE", - command_env="SESSION_KIT_CLAUDE_CMD", - default_command=("claude", "agents", "--json"), - runner=invoke, - timeout=timeout, - ) - claude_payload = _enrich_claude_payload(claude_payload) - _parse_claude_payload(claude_payload) - except (OSError, ValueError, subprocess.SubprocessError, CollectionError) as exc: - claude_payload = [] - claude_failed = True - warnings.append(f"Claude inventory unavailable: {clean_text(str(exc), 240)}") - codex_home, db_path = _codex_paths() - codex_index = index_codex_processes(process_table, root, codex_home) - codex_visible = bool(codex_index) - session_index_names: dict[str, str] = {} - naming_warnings: list[str] = [] - try: - session_index_names = read_codex_session_index( - codex_home / "session_index.jsonl", naming_warnings - ) - except CollectionError as exc: - naming_warnings.append( - f"Codex session names unavailable: {clean_text(str(exc), 240)}" - ) - try: - codex_rows = read_codex_db(db_path, session_index_names) - except (OSError, sqlite3.Error, CollectionError) as exc: - codex_rows = ( - { - uuid: { - "id": uuid, - "title": "", - "cwd": "", - "session_index_name": title, - } - for uuid, title in session_index_names.items() - }, - {}, - ) - codex_failed = True - if codex_visible: - warnings.append( - f"Codex metadata unavailable: {clean_text(str(exc), 240)}" - ) - else: - codex_failed = not db_path.is_file() - if codex_failed and codex_visible: - warnings.append( - "Codex metadata unavailable: database does not exist: " - f"{clean_text(str(db_path), 240)}" - ) - recent_outputs = recent_output_times( - item["name"] for item in _parse_shpool_payload(shpool_payload) - ) - inventory = build_inventory( - shpool_payload, - claude_payload, - process_table, - codex_index, - codex_rows, - config, - recent_output_by_shpool_id=recent_outputs, - ) - apply_retained_setup_attributions(inventory) - inventory["warnings"].extend(warnings) - inventory["naming_warnings"] = naming_warnings - # Optional providers may be absent on a general-purpose installation. - # A query failure is material only when that provider is visibly running. - claude_visible = any( - clean_text(process.get("comm"), 128) == "claude" - for process in process_table.values() - ) - inventory["_complete"] = not ( - (claude_failed and claude_visible) or (codex_failed and codex_visible) - ) - return inventory +def _model_handoff_capable( + item: Mapping[str, Any], process_table: Mapping[int, Mapping[str, Any]] +) -> bool: + """Whether the managed shell can prove and rebuild this model request.""" + model = _common.clean_text(item.get("model"), 80) + if not model: + return True + identity = item.get("identity") + pid = identity.get("pid") if isinstance(identity, Mapping) else None + process = process_table.get(pid) if isinstance(pid, int) else None + if not isinstance(process, Mapping): + return False + argv = process.get("cmdline") + command_model = _arg_value(argv, "--model") if isinstance(argv, list) else "" + requested_model = _common.clean_text(process.get("requested_model"), 80) + return command_model == model and requested_model == model -def _state_paths(config: Mapping[str, Any]) -> dict[str, Path]: - root = Path(config["state_dir"]) - return { - "root": root, - "inventory": root / "inventory.json", - "manifest": root / "recovery-manifest.json", - "pending": root / "recovery-pending.json", - "aliases": root / "aliases.json", - "aliases_archive": root / "aliases.json.migrated-v1", - "aliases_source_backup": root / "aliases.json.pre-migration-v1", - "terminal_numbers": root / "terminal-numbers.json", - "terminal_numbers_epoch": root / "terminal-numbers.initialized.json", - "config_lock": root / "config.lock", - "lock": root / "inventory.lock", - } +def apply_provider_title_states( + inventory: dict[str, Any], config: Mapping[str, Any] +) -> None: + """Expose a safe, display-only state for Codex bars awaiting a restart.""" + state_dir = config.get("state_dir") + if not state_dir: + return + marker_root = Path(str(state_dir)) / "provider-untitled" + for item in inventory.get("sessions", []): + if not isinstance(item, dict) or item.get("provider") != "codex": + continue + shpool_id = clean_text(item.get("shpool_id_raw"), 128) + if not shpool_id or "/" in shpool_id or shpool_id.startswith("."): + continue + marker = marker_root / shpool_id + try: + marker_stat = marker.lstat() + except OSError: + item["provider_title_state"] = "ready" + continue + pending = statmod.S_ISREG(marker_stat.st_mode) and not marker.is_symlink() + busy_or_attached = str( + item.get("availability") or "" + ).casefold() == "attached" or str( + item.get("agent_status") or "" + ).casefold() in {"running", "working", "needs your reply", "reply optional"} + if pending and busy_or_attached: + item["provider_title_state"] = "deferred" + elif pending: + item["provider_title_state"] = "pending" + else: + item["provider_title_state"] = "ready" + +_PROVIDER_MARKER_GENERATION = re.compile(r"[A-Za-z0-9:._-]{1,256}") + + +def _provider_marker_generation(path: Path, name: str) -> tuple[str, str] | None: + try: + value = path.read_text(encoding="utf-8", errors="strict").strip() + except OSError: + return None + if not _PROVIDER_MARKER_GENERATION.fullmatch(value): + return None + return (name, value) -class StateLock: - def __init__(self, root: Path, lock_path: Path): - self.root = root - self.lock_path = lock_path - self.fd: int | None = None - def __enter__(self) -> "StateLock": - self.root.mkdir(mode=0o700, parents=True, exist_ok=True) +def capture_provider_untitled_generations( + state_dir: Path, +) -> frozenset[tuple[str, str]]: + """Exact untitled-marker generations present before process collection.""" + marker_root = Path(state_dir) / "provider-untitled" + try: + markers = list(marker_root.iterdir()) + except OSError: + return frozenset() + captured: set[tuple[str, str]] = set() + for marker in markers: try: - root_stat = self.root.lstat() - except OSError as exc: - raise CollectionError(f"cannot inspect state directory {self.root}") from exc + if marker.is_symlink() or not marker.is_file(): + continue + generation = _provider_marker_generation(marker, marker.name) + if generation is not None: + captured.add(generation) + except OSError: + continue + return frozenset(captured) + + +def _quarantine_orphaned_provider_untitled_markers( + config: Mapping[str, Any], + inventory: Mapping[str, Any], + *, + now: float | None = None, + retire_generations: Iterable[tuple[str, str]] = (), +) -> list[dict[str, str]]: + """Quarantine old markers only after fresh inventory proves absence.""" + if inventory.get("source") != "live" or inventory.get("stale") is not False: + return [] + active_ids = { + str(item.get("shpool_id_raw") or "") + for item in inventory.get("sessions", ()) + if isinstance(item, Mapping) + } + allowed = frozenset(retire_generations) + if not allowed: + return [] + paths = _state_paths(config) + marker_root = paths["root"] / "provider-untitled" + try: + root_metadata = marker_root.lstat() if ( - not statmod.S_ISDIR(root_stat.st_mode) - or statmod.S_IMODE(root_stat.st_mode) != 0o700 - or root_stat.st_uid != os.geteuid() + not statmod.S_ISDIR(root_metadata.st_mode) + or marker_root.is_symlink() + or root_metadata.st_uid != os.geteuid() + or statmod.S_IMODE(root_metadata.st_mode) != 0o700 ): - raise CollectionError( - f"state directory must be a mode-0700 current-owner real directory: {self.root}" - ) - flags = os.O_RDWR | os.O_CREAT - if hasattr(os, "O_CLOEXEC"): - flags |= os.O_CLOEXEC - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW + return [] + markers = sorted(marker_root.iterdir(), key=lambda item: item.name) + except OSError: + return [] + current = time.time() if now is None else now + moved: list[dict[str, str]] = [] + for marker in markers: try: - descriptor = os.open(self.lock_path, flags, 0o600) - opened = os.fstat(descriptor) - before = self.lock_path.lstat() + metadata = marker.lstat() if ( - not statmod.S_ISREG(opened.st_mode) - or statmod.S_IMODE(opened.st_mode) != 0o600 - or opened.st_uid != os.geteuid() - or (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino) + marker.name in active_ids + or current - metadata.st_mtime <= 7 * 86400 + or not statmod.S_ISREG(metadata.st_mode) + or marker.is_symlink() + or metadata.st_uid != os.geteuid() + or metadata.st_nlink != 1 ): - raise CollectionError( - f"state lock must be a mode-0600 current-owner regular file: {self.lock_path}" - ) - fcntl.flock(descriptor, fcntl.LOCK_EX) - after = self.lock_path.lstat() - if (opened.st_dev, opened.st_ino) != (after.st_dev, after.st_ino): - raise CollectionError(f"state lock changed while locking: {self.lock_path}") - except BaseException: - if "descriptor" in locals(): - with contextlib.suppress(OSError): - fcntl.flock(descriptor, fcntl.LOCK_UN) - os.close(descriptor) - raise - self.fd = descriptor - return self - - def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: - if self.fd is not None: - fcntl.flock(self.fd, fcntl.LOCK_UN) - os.close(self.fd) - self.fd = None - - -def atomic_write_json(path: Path, payload: Any) -> None: - """Write JSON with mode 0600, fsync, and same-directory atomic replace.""" - descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) - try: - os.fchmod(descriptor, 0o600) - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - descriptor = -1 - json.dump(payload, handle, indent=2, sort_keys=True) - handle.write("\n") - handle.flush() - os.fsync(handle.fileno()) - os.replace(temporary, path) - os.chmod(path, 0o600) - directory_fd = os.open(path.parent, os.O_RDONLY) - try: - os.fsync(directory_fd) - finally: - os.close(directory_fd) - finally: - if descriptor >= 0: - os.close(descriptor) - try: - os.unlink(temporary) - except FileNotFoundError: - pass + continue + quarantine = paths["provider_untitled_quarantine"] + quarantine.mkdir(mode=0o700, parents=True, exist_ok=True) + qstat = quarantine.lstat() + if ( + not statmod.S_ISDIR(qstat.st_mode) + or statmod.S_IMODE(qstat.st_mode) != 0o700 + or qstat.st_uid != os.geteuid() + ): + continue + fingerprint = hashlib.sha256( + f"{marker.name}:{metadata.st_ino}:{metadata.st_mtime_ns}".encode() + ).hexdigest()[:12] + destination = quarantine / f"{int(current)}-{fingerprint}-{marker.name}" + if destination.exists() or destination.is_symlink(): + continue + os.replace(marker, destination) + actual = _provider_marker_generation(destination, marker.name) + if actual not in allowed: + try: + marker.hardlink_to(destination) + except FileExistsError: + # A still-newer marker already owns the live name. + pass + destination.unlink(missing_ok=True) + continue + moved.append( + { + "marker": marker.name, + "quarantine": os.fspath(destination), + "reason": "fresh inventory proved session absent after 7 days", + } + ) + except OSError: + continue + return moved + + +def _shpool_executable() -> str: + """Resolve shpool for contexts (systemd user services) whose PATH omits it.""" + return _providers._shpool_executable(home_factory=Path.home) + + +def collect_live( + config: Mapping[str, Any], + *, + runner: Runner | None = None, + proc_root: Path | None = None, +) -> dict[str, Any]: + """Collect a live inventory using one call per external list command.""" + inventory = _collector.collect_live( + config, + runner=runner, + proc_root=proc_root, + environ=os.environ, + default_runner=default_runner, + command_json=_command_json, + shpool_executable=_shpool_executable, + platform_process_table=platform_process_table, + default_max_proc_nodes=DEFAULT_MAX_PROC_NODES, + enrich_claude_payload=_enrich_claude_payload, + parse_claude_payload=_parse_claude_payload, + observe_claude_name=adopt_native_rename, + codex_paths=_codex_paths, + index_codex_processes=index_codex_processes, + read_codex_session_index=read_codex_session_index, + read_codex_db=read_codex_db, + recent_output_times=recent_output_times, + build_inventory=build_inventory, + daemon_identity=_payload_daemon_identity, + apply_provider_title_states=apply_provider_title_states, + apply_retained_setup_attributions=apply_retained_setup_attributions, + ) + return _apply_worktree_labels(_apply_account_bindings(inventory, config), config) def _read_state_json(path: Path) -> Any: - try: - value = _load_json_file(path) - except (OSError, ValueError): - return None - return value + return _state_io._read_state_json(path, load_json_file=_load_json_file) def _alias_document_from_bytes(payload: bytes, *, label: str) -> dict[str, Any]: - try: - raw = json.loads(payload.decode("utf-8", "strict")) - except (UnicodeDecodeError, ValueError) as exc: - raise CollectionError(f"{label} is invalid JSON") from exc - if ( - not isinstance(raw, dict) - or raw.get("schema_version", SCHEMA_VERSION) != SCHEMA_VERSION - or ("aliases" in raw and not isinstance(raw["aliases"], Mapping)) - or ( - "automatic_titles" in raw - and not isinstance(raw["automatic_titles"], Mapping) - ) - or ( - "automatic_title_failures" in raw - and not isinstance(raw["automatic_title_failures"], Mapping) - ) - ): - raise CollectionError(f"{label} has an invalid schema") - aliases = raw.get("aliases", {}) - normalized = _valid_aliases(aliases) - if len(normalized) != len(aliases): - raise CollectionError(f"{label} contains an invalid alias") - raw["schema_version"] = SCHEMA_VERSION - raw["aliases"] = dict(sorted(normalized.items())) - if "automatic_titles" in raw: - titles = _valid_automatic_titles(raw["automatic_titles"]) - if len(titles) != len(raw["automatic_titles"]): - raise CollectionError(f"{label} contains an invalid automatic title") - raw["automatic_titles"] = dict(sorted(titles.items())) - if "automatic_title_failures" in raw: - failures = _valid_automatic_title_failures(raw["automatic_title_failures"]) - if len(failures) != len(raw["automatic_title_failures"]): - raise CollectionError( - f"{label} contains an invalid automatic title failure" - ) - raw["automatic_title_failures"] = dict(sorted(failures.items())) - return raw + return _names._alias_document_from_bytes( + payload, + label=label, + schema_version=SCHEMA_VERSION, + ) def _private_alias_document(path: Path, *, allow_missing: bool) -> dict[str, Any]: - payload = _read_bounded_owner_file( + return _names._private_alias_document( path, - label="canonical alias config", - max_bytes=MAX_PRIVATE_JSON_BYTES, - exact_mode=0o600, allow_missing=allow_missing, + max_private_json_bytes=MAX_PRIVATE_JSON_BYTES, + schema_version=SCHEMA_VERSION, + alias_document_from_bytes=_alias_document_from_bytes, ) - if payload is None: - return {"schema_version": SCHEMA_VERSION, "aliases": {}} - return _alias_document_from_bytes(payload, label="canonical alias config") - - -def _atomic_json_bytes(payload: Any) -> bytes: - return ( - json.dumps(payload, indent=2, sort_keys=True) + "\n" - ).encode("utf-8") def _private_alias_parent(path: Path) -> None: - path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - metadata = path.parent.lstat() - if ( - not statmod.S_ISDIR(metadata.st_mode) - or statmod.S_ISLNK(metadata.st_mode) - or metadata.st_uid != os.geteuid() - or statmod.S_IMODE(metadata.st_mode) != 0o700 - ): - raise CollectionError( - f"alias config directory must be mode-0700 current-owner: {path.parent}" - ) + return _names._private_alias_parent( + path, + ) def canonical_aliases(config: Mapping[str, Any]) -> dict[str, str]: - return dict(_private_alias_document(config_path(), allow_missing=True)["aliases"]) + return _names.canonical_aliases( + config, + config_path=config_path, + private_alias_document=_private_alias_document, + ) def mutate_canonical_alias( @@ -2518,35 +1512,35 @@ def mutate_canonical_alias( uuid: str, title: str | None, ) -> dict[str, str]: - exact_uuid = valid_uuid(uuid) - if provider not in PROVIDERS or not exact_uuid: - raise CollectionError("alias requires provider claude|codex and an exact UUID") - path = config_path() - paths = _state_paths(config) - # config.lock is the alias-write lock used by pinned schema-v1 releases. - # Keep it outermost so an old picker and this core cannot lose each - # other's config update during a mixed-release rollback window. - with StateLock(paths["root"], paths["config_lock"]): - with StateLock(paths["root"], paths["lock"]): - _private_alias_parent(path) - document = _private_alias_document(path, allow_missing=True) - aliases = dict(document["aliases"]) - key = f"{provider}:{exact_uuid}" - if title is None: - aliases.pop(key, None) - else: - clean_title = clean_text(title, 100) - if not clean_title: - raise CollectionError("alias title must contain visible text") - aliases[key] = clean_title - document["aliases"] = dict(sorted(aliases.items())) - atomic_write_json(path, document) - return dict(document["aliases"]) + return _names.mutate_canonical_alias( + config, + provider, + uuid, + title, + atomic_write_json=atomic_write_json, + config_path=config_path, + private_alias_document=_private_alias_document, + private_alias_parent=_private_alias_parent, + ) def canonical_automatic_titles(config: Mapping[str, Any]) -> dict[str, str]: - document = _private_alias_document(config_path(), allow_missing=True) - return _valid_automatic_titles(document.get("automatic_titles")) + return _names.canonical_automatic_titles( + config, + config_path=config_path, + private_alias_document=_private_alias_document, + ) + + +def canonical_name_ownership( + config: Mapping[str, Any], +) -> dict[str, dict[str, str]]: + """Read who owns every named session, human renames included.""" + return _names.canonical_name_ownership( + config, + config_path=config_path, + private_alias_document=_private_alias_document, + ) def mutate_canonical_automatic_title( @@ -2559,64 +1553,42 @@ def mutate_canonical_automatic_title( revalidate: Callable[[], None] | None = None, ) -> dict[str, Any]: """Atomically set/reset one automatic title without crossing alias provenance.""" - exact_uuid = valid_uuid(uuid) - if provider not in PROVIDERS or not exact_uuid: - raise CollectionError( - "automatic title requires provider claude|codex and an exact UUID" - ) - if title is not None and not automatic_naming_enabled(): - raise CollectionError("automatic naming is disabled") - clean_title = normalize_automatic_title(title) if title is not None else None - path = config_path() - paths = _state_paths(config) - key = f"{provider}:{exact_uuid}" - with StateLock(paths["root"], paths["config_lock"]): - with StateLock(paths["root"], paths["lock"]): - if revalidate is not None: - revalidate() - _private_alias_parent(path) - document = _private_alias_document(path, allow_missing=True) - aliases = dict(document["aliases"]) - if clean_title is not None and key in aliases: - raise CollectionError("explicit local alias already owns this title") - titles = _valid_automatic_titles(document.get("automatic_titles")) - failures = _valid_automatic_title_failures( - document.get("automatic_title_failures") - ) - existing = titles.get(key) - if clean_title is None: - titles.pop(key, None) - elif existing and existing != clean_title and not overwrite: - raise CollectionError("automatic title is already set") - else: - titles[key] = clean_title - failures.pop(key, None) - document["automatic_titles"] = dict(sorted(titles.items())) - if failures: - document["automatic_title_failures"] = dict( - sorted(failures.items()) - ) - else: - document.pop("automatic_title_failures", None) - atomic_write_json(path, document) - verified = _private_alias_document(path, allow_missing=False) - verified_titles = _valid_automatic_titles( - verified.get("automatic_titles") - ) - if verified_titles.get(key) != clean_title: - if not (clean_title is None and key not in verified_titles): - raise CollectionError("automatic title atomic verification failed") - if clean_title is not None and key in verified["aliases"]: - raise CollectionError( - "explicit alias appeared during automatic title write" - ) - return { - "schema_version": SCHEMA_VERSION, - "provider": provider, - "uuid": exact_uuid, - "title": clean_title, - "automatic_titles": verified_titles, - } + return _names.mutate_canonical_automatic_title( + config, + provider, + uuid, + title, + overwrite=overwrite, + revalidate=revalidate, + schema_version=SCHEMA_VERSION, + atomic_write_json=atomic_write_json, + config_path=config_path, + private_alias_document=_private_alias_document, + private_alias_parent=_private_alias_parent, + ) + + +def mutate_canonical_self_name( + config: Mapping[str, Any], + provider: str, + uuid: str, + title: str, + *, + revalidate: Callable[[], None] | None = None, +) -> dict[str, Any]: + """Atomically set the paired alias and automatic title for a proved caller.""" + return _names.mutate_canonical_self_name( + config, + provider, + uuid, + title, + revalidate=revalidate, + schema_version=SCHEMA_VERSION, + atomic_write_json=atomic_write_json, + config_path=config_path, + private_alias_document=_private_alias_document, + private_alias_parent=_private_alias_parent, + ) def record_automatic_title_failure( @@ -2627,569 +1599,504 @@ def record_automatic_title_failure( revalidate: Callable[[], None] | None = None, ) -> int: """Record at most two proved root-turn naming failures.""" - exact_uuid = valid_uuid(uuid) - if provider not in PROVIDERS or not exact_uuid: - raise CollectionError("automatic title failure requires an exact identity") - path = config_path() - paths = _state_paths(config) - key = f"{provider}:{exact_uuid}" - with StateLock(paths["root"], paths["config_lock"]): - with StateLock(paths["root"], paths["lock"]): - if revalidate is not None: - revalidate() - _private_alias_parent(path) - document = _private_alias_document(path, allow_missing=True) - if key in document["aliases"]: - return 0 - titles = _valid_automatic_titles(document.get("automatic_titles")) - if key in titles: - return 0 - failures = _valid_automatic_title_failures( - document.get("automatic_title_failures") - ) - failures[key] = min(2, failures.get(key, 0) + 1) - document["automatic_title_failures"] = dict(sorted(failures.items())) - atomic_write_json(path, document) - verified = _private_alias_document(path, allow_missing=False) - attempts = _valid_automatic_title_failures( - verified.get("automatic_title_failures") - ).get(key) - if attempts != failures[key]: - raise CollectionError( - "automatic title failure atomic verification failed" - ) - return attempts + return _names.record_automatic_title_failure( + config, + provider, + uuid, + revalidate=revalidate, + atomic_write_json=atomic_write_json, + config_path=config_path, + private_alias_document=_private_alias_document, + private_alias_parent=_private_alias_parent, + ) def _exact_active_title_keys(inventory: Mapping[str, Any]) -> set[str]: - keys: set[str] = set() - for item in [*inventory.get("sessions", ()), *inventory.get("outside_agents", ())]: - if not isinstance(item, Mapping): - continue - provider = item.get("provider") - identity = item.get("identity") - uuid = identity.get("uuid") if isinstance(identity, Mapping) else None - if ( - provider in PROVIDERS - and isinstance(identity, Mapping) - and identity.get("confidence") == "exact" - and valid_uuid(uuid) - ): - keys.add(f"{provider}:{valid_uuid(uuid)}") - return keys + return _names._exact_active_title_keys( + inventory, + ) def _automatic_title_prune_plan( titles: Mapping[str, str], active_keys: set[str] ) -> tuple[list[str], str]: - orphans = sorted(set(titles) - active_keys) - evidence = json.dumps( - { - "schema_version": SCHEMA_VERSION, - "automatic_titles": dict(sorted(titles.items())), - "active_keys": sorted(active_keys), - "orphans": orphans, - }, - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8") - return orphans, hashlib.sha256(evidence).hexdigest() + return _names._automatic_title_prune_plan( + titles, + active_keys, + schema_version=SCHEMA_VERSION, + ) def audit_automatic_titles( config: Mapping[str, Any], inventory: Mapping[str, Any] ) -> dict[str, Any]: - titles = canonical_automatic_titles(config) - orphans, token = _automatic_title_prune_plan( - titles, _exact_active_title_keys(inventory) + return _names.audit_automatic_titles( + config, + inventory, + schema_version=SCHEMA_VERSION, + automatic_title_prune_plan=_automatic_title_prune_plan, + exact_active_title_keys=_exact_active_title_keys, + canonical_automatic_titles_reader=canonical_automatic_titles, ) - return { - "schema_version": SCHEMA_VERSION, - "automatic_title_count": len(titles), - "orphan_count": len(orphans), - "orphans": orphans, - "prune_token": token, - "dry_run": True, - } def prune_automatic_titles( config: Mapping[str, Any], inventory: Mapping[str, Any], prune_token: str, + *, + revalidate_inventory: Callable[[], Mapping[str, Any]] | None = None, ) -> dict[str, Any]: """Apply only the exact orphan set previously exposed by a dry-run token.""" - path = config_path() - paths = _state_paths(config) - with StateLock(paths["root"], paths["config_lock"]): - with StateLock(paths["root"], paths["lock"]): - _private_alias_parent(path) - document = _private_alias_document(path, allow_missing=True) - titles = _valid_automatic_titles(document.get("automatic_titles")) - active_keys = _exact_active_title_keys(inventory) - orphans, expected = _automatic_title_prune_plan(titles, active_keys) - if not re.fullmatch(r"[0-9a-f]{64}", prune_token) or prune_token != expected: - raise CollectionError( - "automatic title prune token is stale or does not match dry run" - ) - for key in orphans: - titles.pop(key, None) - document["automatic_titles"] = dict(sorted(titles.items())) - failures = _valid_automatic_title_failures( - document.get("automatic_title_failures") - ) - for key in orphans: - failures.pop(key, None) - if failures: - document["automatic_title_failures"] = dict( - sorted(failures.items()) - ) - else: - document.pop("automatic_title_failures", None) - atomic_write_json(path, document) - return { - "schema_version": SCHEMA_VERSION, - "pruned": orphans, - "automatic_title_count": len(titles), - } + return _names.prune_automatic_titles( + config, + inventory, + prune_token, + schema_version=SCHEMA_VERSION, + atomic_write_json=atomic_write_json, + config_path=config_path, + automatic_title_prune_plan=_automatic_title_prune_plan, + exact_active_title_keys=_exact_active_title_keys, + private_alias_document=_private_alias_document, + private_alias_parent=_private_alias_parent, + revalidate_inventory=revalidate_inventory, + ) -MAX_CLAUDE_SESSION_RECORDS = 512 +def propagate_provider_color( + provider: str, + uuid: str, + color: str, + *, + environ: Mapping[str, str] | None = None, +) -> dict[str, Any]: + """One-shot push of a session color into provider-native storage.""" + return _names.propagate_provider_color( + provider, + uuid, + color, + environ=environ, + palette=palette_for_provider(provider), + push_claude_color=_push_claude_color, + ) -def _push_claude_title(home: Path, uuid: str, title: str) -> tuple[list[str], list[str]]: - pushed: list[str] = [] - warnings: list[str] = [] - sessions = home / ".claude" / "sessions" - if not sessions.is_dir(): - return pushed, ["Claude sessions directory unavailable; title not pushed"] - intent = sessions / f"{uuid}.nameintent" - try: - if intent.is_symlink(): - raise OSError("refusing symlinked name intent") - descriptor, temporary = tempfile.mkstemp( - prefix=f".{intent.name}.", dir=sessions - ) - try: - os.fchmod(descriptor, 0o600) - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - descriptor = -1 - handle.write(title + "\n") - handle.flush() - os.fsync(handle.fileno()) - os.replace(temporary, intent) - finally: - if descriptor >= 0: - os.close(descriptor) - with contextlib.suppress(FileNotFoundError): - os.unlink(temporary) - pushed.append("claude-nameintent") - except OSError as exc: - warnings.append(f"Claude name intent not written: {exc}") - # The prompt bar's bottom-right name is a transcript agent-name record — - # the exact store /rename persists, hydrated at session start/resume, - # rendered beside the agent-color. Same append discipline as colors. - projects = home / ".claude" / "projects" - try: - transcripts = sorted(projects.glob(f"*/{uuid}.jsonl")) - except OSError: - transcripts = [] - name_entry = json.dumps( - {"type": "agent-name", "agentName": title, "sessionId": uuid}, - separators=(",", ":"), +def propagate_provider_title( + provider: str, + uuid: str, + title: str, + *, + environ: Mapping[str, str] | None = None, +) -> dict[str, Any]: + """One-shot push of an assigned name into the provider's own surfaces.""" + return _names.propagate_provider_title( + provider, + uuid, + title, + environ=environ, + codex_home=_codex_home, + claude_pre_push_title=_claude_pre_push_title, + push_claude_title=_push_claude_title, + push_codex_title=_push_codex_title, + session_kit_state_dir=_session_kit_state_dir, + record_pushed=record_pushed_title, ) - for transcript in transcripts[:4]: - if transcript.is_symlink(): - continue - try: - with open(transcript, "a", encoding="utf-8") as handle: - handle.write(name_entry + "\n") - handle.flush() - os.fsync(handle.fileno()) - pushed.append("claude-transcript-name") - except OSError as exc: - warnings.append(f"Claude transcript name not appended: {exc}") - # The per-PID session record names the thread in Claude's own session - # picker. Records carry the exact sessionId, so match by content. - try: - records = sorted(sessions.glob("*.json"))[:MAX_CLAUDE_SESSION_RECORDS] - except OSError as exc: - return pushed, warnings + [f"Claude session records unreadable: {exc}"] - for record in records: - if record.is_symlink() or not re.fullmatch(r"\d+\.json", record.name): - continue - try: - data = json.loads(record.read_text(encoding="utf-8")) - except (OSError, ValueError): - continue - if not isinstance(data, dict) or data.get("sessionId") != uuid: - continue - try: - data["name"] = title - atomic_write_json(record, data) - pushed.append("claude-session-record") - except OSError as exc: - warnings.append(f"Claude session record not updated: {exc}") - return pushed, warnings -def _codex_state_databases(codex_root: Path) -> list[Path]: - """Codex state stores ordered oldest-to-newest by schema number. +def _record_provider_title_retry( + config: Mapping[str, Any], provider: str, uuid: str, title: str +) -> None: + return _names._record_provider_title_retry( + config, + provider, + uuid, + title, + schema_version=SCHEMA_VERSION, + read_state_json=_read_state_json, + atomic_write_json=atomic_write_json, + ) + - Lexical order breaks at two digits (state_10 sorts before state_5), so - the numeric suffix is the key. Empty files are placeholder artifacts, - never the live store. - """ +def _provider_title_retry_disposition( + provider: str, uuid: str, title: str, environ: Mapping[str, str] +) -> str: + """Return retry, satisfied, superseded, or defer from native evidence.""" + return _names._provider_title_retry_disposition( + provider, + uuid, + title, + environ, + codex_home=_codex_home, + codex_title_echoes_prompt=_codex_title_echoes_prompt, + home_resolver=_home, + transcript_signals=read_claude_transcript_signals, + read_session_index=read_codex_session_index, + ) - def version(path: Path) -> tuple[int, str]: - suffix = path.stem.rsplit("_", 1)[-1] - return (int(suffix) if suffix.isdigit() else -1, path.name) - try: - candidates = [ - path - for path in codex_root.glob("state_*.sqlite") - if path.stat().st_size > 0 - ] - except OSError: - return [] - return sorted(candidates, key=version) +def _reconcile_pending_provider_titles( + config: Mapping[str, Any], provider: str, environ: Mapping[str, str], limit: int = 4 +) -> list[dict[str, Any]]: + """Retry a bounded number of exact failed self-name provider pushes.""" + return _names._reconcile_pending_provider_titles( + config, + provider, + environ, + limit, + schema_version=SCHEMA_VERSION, + read_state_json=_read_state_json, + atomic_write_json=atomic_write_json, + retry_disposition=_provider_title_retry_disposition, + propagate_title=propagate_provider_title, + human_named=human_named_keys, + ) + + +def claude_pending_native_hydrations( + config: dict[str, Any] | None = None, + environ: Mapping[str, str] | None = None, +) -> list[dict[str, Any]]: + """Fill absent visible records for exact managed Claude sessions.""" + return _names.claude_pending_native_hydrations( + config, + environ, + canonical_colors=canonical_colors, + guard_inventory=guard_live_inventory, + load_config=load_config, + transcript_signals=read_claude_transcript_signals, + session_color=session_color, + snapshot_inventory=snapshot, + propagate_color=propagate_provider_color, + propagate_title=propagate_provider_title, + reconcile_pending_titles=_reconcile_pending_provider_titles, + human_named=human_named_keys, + adopt_native=adopt_native_rename, + ) + + +def _claude_pre_push_title( + home: Path, + uuid: str, + *, + environ: Mapping[str, str] | None = None, +) -> Mapping[str, Any] | None: + return _names_push._claude_pre_push_title( + home, + uuid, + environ=environ, + max_session_records=MAX_CLAUDE_SESSION_RECORDS, + ) + + +def _push_claude_title( + home: Path, + uuid: str, + title: str, + *, + environ: Mapping[str, str] | None = None, +) -> tuple[list[str], list[str]]: + return _names_push._push_claude_title( + home, + uuid, + title, + environ=environ, + atomic_write_json=atomic_write_json, + max_session_records=MAX_CLAUDE_SESSION_RECORDS, + ) def _codex_title_echoes_prompt(title: str, first_message: str) -> bool: """True when a stored title is just the first prompt (or a prefix cut).""" - return bool(title) and bool(first_message) and ( - first_message.casefold().startswith(title.casefold()) - or title.casefold().startswith(first_message.casefold()) + return _names_push._codex_title_echoes_prompt( + title, + first_message, ) def codex_bounce_prepare( - uuid: str, codex_root: Path | None = None, now: float | None = None + uuid: str, + codex_root: Path | None = None, + now: float | None = None, + *, + mirror_index: bool = True, ) -> str: - """Resolve the real name a bounced Codex process should boot under. - - Session-index rename evidence wins; otherwise a database title that does - not merely echo the first prompt. Codex seeds threads.title with the raw - prompt and replaces it within about two minutes of the first answer, so - an echo-shaped title only counts once the thread has been quiet long - enough that it must be the final title (a terse prompt can BE the real - title). The chosen name is mirrored into the session index when its - latest entry differs, because the relaunched status bar reads only the - index. Returns "" when the thread has no real name yet — the caller must - then defer the bounce and keep its one-shot marker so a later reopen - retries after the title exists. - """ - import sqlite3 - - if codex_root is None: - codex_root = Path( - os.environ.get("CODEX_HOME") - or os.environ.get("SESSION_KIT_CODEX_HOME") - or (_home() / ".codex") - ).expanduser() - title = "" - first_message = "" - has_split_prompt_schema = False - settled = False - databases = _codex_state_databases(codex_root) - if databases: - fetched = None - selected_columns: list[str] = [] - try: - connection = sqlite3.connect( - f"file:{databases[-1]}?mode=ro", uri=True - ) - try: - columns = { - row[1] - for row in connection.execute( - "PRAGMA table_info(threads)" - ) - } - has_split_prompt_schema = "first_user_message" in columns - selected_columns = ["title"] - if has_split_prompt_schema: - selected_columns.append("first_user_message") - if "updated_at" in columns: - selected_columns.append("updated_at") - fetched = connection.execute( - "SELECT " - + ", ".join(selected_columns) - + " FROM threads WHERE id = ?", - (uuid,), - ).fetchone() - finally: - connection.close() - except sqlite3.Error: - fetched = None - if fetched: - values = dict(zip(selected_columns, fetched)) - title = str(values.get("title") or "").strip() - if has_split_prompt_schema: - first_message = str( - values.get("first_user_message") or "" - ).strip() - updated_at = values.get("updated_at") - if isinstance(updated_at, (int, float)) and not isinstance( - updated_at, bool - ): - current = time.time() if now is None else now - settled = current - float(updated_at) > 300 - index = codex_root / "session_index.jsonl" - index_name = "" - if index.is_file() and not index.is_symlink(): - try: - if index.stat().st_size <= MAX_CODEX_SESSION_INDEX_BYTES: - for line in index.read_text(encoding="utf-8").splitlines(): - try: - entry = json.loads(line) - except ValueError: - continue - if isinstance(entry, dict) and entry.get("id") == uuid: - candidate = str( - entry.get("thread_name") or "" - ).strip() - if candidate: - index_name = candidate - except OSError: - index_name = "" - # Codex never writes the session index itself: every entry is a - # deliberate push (kit title, auto-titler, rename mirror), so any entry - # counts as a real name even when it is echo-shaped. Only the database - # title needs the seed-vs-final test. - index_is_explicit = bool(index_name) - if has_split_prompt_schema: - title_is_explicit = bool(title) and ( - settled or not _codex_title_echoes_prompt(title, first_message) - ) - else: - title_is_explicit = bool(title) - if index_is_explicit: - chosen = index_name - elif title_is_explicit: - chosen = title - else: - return "" - if index_name != chosen: - # A failed mirror would relaunch under the stale bar name and burn - # the one-shot bounce on nothing, so it defers instead. - if index.is_symlink(): - return "" - try: - _append_codex_index_entry(index, uuid, chosen[:100]) - except OSError: - return "" - return chosen + """Resolve the real name a bounced Codex process should boot under.""" + return _names_push.codex_bounce_prepare( + uuid, + codex_root, + now, + codex_home=_codex_home, + max_session_index_bytes=MAX_CODEX_SESSION_INDEX_BYTES, + mirror_index=mirror_index, + ) + + +def claude_bounce_prepare( + uuid: str, home: Path | None = None, now: float | None = None +) -> tuple[str, bool]: + """Decide whether a Claude window can only be named by a restart.""" + return _names_push.claude_bounce_prepare( + uuid, + home, + now, + ) def codex_pending_auto_titles( environ: Mapping[str, str] | None = None, ) -> list[dict[str, str]]: - """Kit-side auto-titler for Codex threads nobody has named. - - Codex seeds threads.title with the raw first prompt and has no title - hook, and agents only self-name work they judge substantive — so a plain - question thread stays unnamed on every surface. Recent split-schema - threads whose title is empty or still echoes the first prompt, and which - have no session-index entry (the deliberate-name evidence), get the same - seven-word heuristic title Claude sessions get, pushed into both Codex - stores. Self-terminating: the push writes the index entry that excludes - the thread from every later pass, and a later agent self-name simply - overwrites it (last-writer-wins). - """ - import sqlite3 - - env = environ if environ is not None else os.environ - if not automatic_naming_enabled(env): - return [] - if str(env.get("SESSION_KIT_CODEX_AUTOTITLE", "")).strip().casefold() in { - "0", - "false", - "no", - "off", - }: - return [] - codex_root, database = _codex_paths() - if not database.is_file(): - return [] - try: - connection = sqlite3.connect(f"file:{database}?mode=ro", uri=True) - try: - columns = { - row[1] - for row in connection.execute("PRAGMA table_info(threads)") - } - if not {"id", "title", "first_user_message", "updated_at"} <= columns: - return [] - rows = connection.execute( - "SELECT id, title, first_user_message, updated_at" - " FROM threads" - " WHERE first_user_message IS NOT NULL" - " AND first_user_message != ''" - " ORDER BY updated_at DESC LIMIT 200" - ).fetchall() - finally: - connection.close() - except sqlite3.Error: - return [] - index = codex_root / "session_index.jsonl" - indexed: set[str] = set() - if index.is_file() and not index.is_symlink(): - try: - if index.stat().st_size > MAX_CODEX_SESSION_INDEX_BYTES: - return [] - for line in index.read_text(encoding="utf-8").splitlines(): - try: - entry = json.loads(line) - except ValueError: - continue - if isinstance(entry, Mapping) and entry.get("id"): - indexed.add(str(entry["id"])) - except OSError: - # Unable to prove which threads already carry deliberate names; - # titling anyway could overwrite one, so do nothing. - return [] - cutoff = time.time() - 7 * 86400 - results: list[dict[str, str]] = [] - for uuid, raw_title, first_message, updated_at in rows: - exact = valid_uuid(uuid) - if not exact or exact in indexed: - continue - if exact == "00000000-0000-0000-0000-000000000000": - continue - if ( - not isinstance(updated_at, (int, float)) - or isinstance(updated_at, bool) - or float(updated_at) < cutoff - ): - continue - title = str(raw_title or "").strip() - first = str(first_message or "").strip() - if title and not _codex_title_echoes_prompt(title, first): - # A real name someone wrote; never replace it. - continue - derived = derive_prompt_title(first) - if not derived: - continue - # Both writes target the exact store tree the candidates were read - # from — pushing anywhere else would split reads and writes across - # different Codex homes. - try: - _append_codex_index_entry(index, exact, derived[:100]) - except OSError: - continue - _push_codex_thread_title(codex_root, exact, derived) - results.append({"uuid": exact, "title": derived}) - if len(results) >= 20: - break - return results + """Kit-side auto-titler for Codex threads nobody has named.""" + return _names_push.codex_pending_auto_titles( + environ, + codex_paths=_codex_paths, + reconcile_pending_titles=_reconcile_pending_provider_titles, + derive_title=derive_prompt_title, + load_config=load_config, + max_session_index_bytes=MAX_CODEX_SESSION_INDEX_BYTES, + push_live_rename=_push_codex_live_rename, + human_named=human_named_keys, + name_owner=name_owner, + claim_name=claim_automatic_name, + release_claim=release_automatic_name_claim, + record_pushed=record_pushed_title, + adopt_native=adopt_native_rename, + ) def _append_codex_index_entry(index: Path, uuid: str, title: str) -> None: - entry = json.dumps( - { - "id": uuid, - "thread_name": title, - "updated_at": dt.datetime.now(dt.timezone.utc) - .isoformat() - .replace("+00:00", "Z"), - }, - separators=(",", ":"), - ) - descriptor = os.open(index, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600) - with os.fdopen(descriptor, "a", encoding="utf-8") as handle: - handle.write(entry + "\n") - handle.flush() - os.fsync(handle.fileno()) + return _names_push._append_codex_index_entry( + index, + uuid, + title, + ) def _push_codex_thread_title( - codex_root: Path, uuid: str, title: str + codex_root: Path, + uuid: str, + title: str, + *, + timeout_seconds: float = 1.0, + still_automatic: Callable[[], bool] | None = None, ) -> tuple[list[str], list[str]]: - """Set threads.title in Codex's own state database. + """Set threads.title in Codex's own state database.""" + return _names_push._push_codex_thread_title( + codex_root, + uuid, + title, + timeout_seconds=timeout_seconds, + still_automatic=still_automatic, + ) - The Codex TUI's thread-title status item and its rename flow read and - write this column (the session index alone never reaches the status - bar). Update-only — a missing row is reported, never created — and every - failure is fail-open. - """ - import sqlite3 - candidates = _codex_state_databases(codex_root) - if not candidates: - # Older Codex builds have no thread store; nothing to report. - return [], [] - database = candidates[-1] - try: - connection = sqlite3.connect(database, timeout=1.0) - try: - cursor = connection.execute( - "UPDATE threads SET title = ? WHERE id = ?", (title, uuid) - ) - connection.commit() - if cursor.rowcount > 0: - return ["codex-thread-title"], [] - return [], ["Codex thread row not found; thread title not set"] - finally: - connection.close() - except sqlite3.Error as exc: - return [], [f"Codex thread title not set: {exc}"] - - -def _push_codex_title(home: Path, uuid: str, title: str) -> tuple[list[str], list[str]]: - codex_root = home / ".codex" - if not codex_root.is_dir(): - return [], ["Codex home unavailable; title not pushed"] - index = codex_root / "session_index.jsonl" - if index.is_symlink(): - return [], ["Codex session index is a symlink; title not pushed"] - try: - if index.exists() and index.stat().st_size > MAX_CODEX_SESSION_INDEX_BYTES: - return [], ["Codex session index exceeds the bounded size; title not pushed"] - _append_codex_index_entry(index, uuid, title) - except OSError as exc: - return [], [f"Codex session index not appended: {exc}"] - thread_pushes, thread_warnings = _push_codex_thread_title( - codex_root, uuid, title - ) - return ["codex-session-index", *thread_pushes], thread_warnings - - -SESSION_COLORS = ( - "red", - "blue", - "green", - "yellow", - "purple", - "orange", - "pink", - "cyan", -) +def _session_kit_state_dir(env: Mapping[str, str], home: Path) -> Path: + """The session-kit state directory under the caller's exact sandbox.""" + return _names_push._session_kit_state_dir( + env, + home, + ) -def _valid_colors(raw: Any) -> dict[str, str]: - colors: dict[str, str] = {} - if not isinstance(raw, Mapping): - return colors - for key, value in raw.items(): - if not isinstance(key, str) or not isinstance(value, str): +def _ws_send_frame(connection: Any, payload: bytes, opcode: int = 1) -> None: + return _names_push._ws_send_frame( + connection, + payload, + opcode, + ) + + +def _ws_recv_frame(connection: Any) -> tuple[int, bytes]: + return _names_push._ws_recv_frame( + connection, + max_frame=MAX_CODEX_LIVE_RENAME_FRAME, + ) + + +def _ws_request( + connection: Any, request_id: int, method: str, params: dict[str, Any] +) -> None: + return _names_push._ws_request( + connection, + request_id, + method, + params, + max_frame=MAX_CODEX_LIVE_RENAME_FRAME, + ) + + +def _push_codex_live_rename( + kit_state_dir: Path, + uuid: str, + title: str, + *, + timeout_seconds: float | None = None, + still_automatic: Callable[[], bool] | None = None, +) -> tuple[list[str], list[str]]: + """Repaint live app-server-backed Codex windows with the new name.""" + return _names_push._push_codex_live_rename( + kit_state_dir, + uuid, + title, + max_sockets=MAX_CODEX_LIVE_RENAME_SOCKETS, + max_frame=MAX_CODEX_LIVE_RENAME_FRAME, + timeout_seconds=timeout_seconds, + still_automatic=still_automatic, + ) + + +DEFAULT_CODEX_TITLE_ITEMS = '["activity", "thread"]' + + +def _title_template_value(raw: str): + """One quoted string or one flat array of them; anything else refuses.""" + raw = raw.strip() + if raw.startswith("[") and raw.endswith("]"): + inner = raw[1:-1].strip() + if not inner: + return [] + items = [] + for part in inner.split(","): + part = part.strip() + if len(part) >= 2 and part[0] == part[-1] and part[0] in "\"'": + items.append(part[1:-1]) + else: + raise ValueError("shape") + return items + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'": + return raw[1:-1] + raise ValueError("shape") + + +def _parse_title_template(text: str): + """tomllib when it exists; else a strict reader of the template's own tiny + dialect (one [table] level, JSON-compatible string and string-array + values). Python 3.10 ships no tomllib, and the deployed template is + kit-authored, so the dialect is the contract, not a guess.""" + try: + import tomllib + except ImportError: + tomllib = None # type: ignore[assignment] + if tomllib is not None: + try: + return tomllib.loads(text) + except Exception: + return None + parsed: dict = {} + current = None + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): continue - provider, separator, uuid = key.partition(":") - if ( - separator - and provider in PROVIDERS - and UUID_RE.fullmatch(uuid) - and value in SESSION_COLORS - ): - colors[f"{provider}:{uuid.lower()}"] = value - return colors + header = re.fullmatch(r"\[([A-Za-z0-9_-]+)\]", line) + if header: + current = parsed.setdefault(header.group(1), {}) + if not isinstance(current, dict): + return None + continue + pair = re.fullmatch(r"([A-Za-z0-9_-]+)\s*=\s*(.+)", line) + if not pair: + return None + try: + value = _title_template_value(pair.group(2)) + except ValueError: + return None + (current if current is not None else parsed)[pair.group(1)] = value + return parsed + + +def _codex_title_items(environ: Mapping[str, str]) -> str: + """The tab-title items a kit Codex launch passes, or "" when it passes none. + + Same answer as the launcher's, from the same deployed template: the kit + owns the tab name on both providers (K3), and every path that starts a + Codex process for a kit session has to agree about what goes on it. + """ + switch = str(environ.get("SESSION_KIT_TAB_TITLE", "")).strip().casefold() + if switch == "off": + return "" + template = _codex_home(environ) / "session-kit" / "terminal-title.toml" + try: + if template.is_symlink(): + raise OSError("refusing a symlinked template") + parsed = _parse_title_template(template.read_text(encoding="utf-8")) + value = (parsed or {}).get("tui", {}).get("terminal_title") + except (OSError, ValueError, AttributeError): + return DEFAULT_CODEX_TITLE_ITEMS + if not isinstance(value, list) or not value or len(value) > 12: + return DEFAULT_CODEX_TITLE_ITEMS + items = [] + for item in value: + if not isinstance(item, str) or not re.fullmatch(r"[a-z][a-z-]{0,31}", item): + return DEFAULT_CODEX_TITLE_ITEMS + items.append(f'"{item}"') + return "[" + ", ".join(items) + "]" + + +def codex_live_capabilities(kit_state_dir: Path) -> dict[str, bool]: + """What a live Codex app-server in this installation can be asked to do.""" + return _names_push.codex_live_capabilities(kit_state_dir) + + +def push_codex_live_color( + kit_state_dir: Path, uuid: str, color: str, send=None +) -> tuple[list[str], list[str]]: + """The one place the recolour path asks about repainting a live Codex window. + + Live rename already works through the app-server; a live recolour has no + control in the installed build, so this answers with the real reason and + never a false success. WS-F flips it by writing the capability file and + passing its client as ``send``. + """ + return _names_push.push_codex_live_color(kit_state_dir, uuid, color, send=send) + + +def _push_codex_title( + codex_root: Path, + uuid: str, + title: str, + kit_state_dir: Path | None = None, +) -> tuple[list[str], list[str]]: + return _names_push._push_codex_title( + codex_root, + uuid, + title, + kit_state_dir, + max_session_index_bytes=MAX_CODEX_SESSION_INDEX_BYTES, + push_live_rename=_push_codex_live_rename, + ) + + +def _push_claude_color( + home: Path, uuid: str, color: str +) -> tuple[list[str], list[str]]: + """Append the exact agent-color record /color itself writes.""" + return _names_push._push_claude_color( + home, + uuid, + color, + ) + + +def _valid_colors(raw: Any) -> dict[str, str]: + return _common._valid_colors(raw, palette_for=palette_for_provider) def canonical_colors(config: Mapping[str, Any]) -> dict[str, str]: - document = _private_alias_document(config_path(), allow_missing=True) - return _valid_colors(document.get("colors")) + return _colors.canonical_colors( + config, + config_path=config_path, + private_alias_document=_private_alias_document, + valid_colors=_valid_colors, + ) + + +def palette_for_provider(provider: str) -> tuple[str, ...]: + """The colours one provider can actually display, empty for anything else.""" + return _colors.palette_for_provider( + provider, + claude_palette=CLAUDE_SESSION_COLORS, + codex_palette=CODEX_SESSION_COLORS, + ) def session_color( @@ -3198,66 +2105,123 @@ def session_color( overrides: Mapping[str, str] | None = None, ) -> str | None: """Stable per-conversation color: explicit override, else identity hash.""" - if provider not in PROVIDERS or not uuid: - return None - exact_uuid = valid_uuid(uuid) - if not exact_uuid: - return None - override = (overrides or {}).get(f"{provider}:{exact_uuid}") - if override in SESSION_COLORS: - return override - digest = hashlib.sha256(f"{provider}:{exact_uuid}".encode("utf-8")).digest() - return SESSION_COLORS[digest[0] % len(SESSION_COLORS)] + return _colors.session_color( + provider, + uuid, + overrides, + palette=palette_for_provider(provider), + ) -# A brand-new Codex session has no conversation ID until Codex boots, so its -# launch theme cannot come from the identity hash. The launch color is picked -# deterministically from the shpool session name, recorded as a marker, and -# adopted as that conversation's explicit override the first time the live -# collector sees the session's real ID — from then on the window theme, the -# picker row, and every future resume agree. -LAUNCH_COLOR_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 +def launch_color_for(shpool_id: str, occupied_colors: Iterable[str] = ()) -> str: + return _colors.launch_color_for( + shpool_id, + occupied_colors, + palette=CODEX_SESSION_COLORS, + ) -def launch_color_for(shpool_id: str) -> str: - digest = hashlib.sha256(f"launch:{shpool_id}".encode("utf-8")).digest() - return SESSION_COLORS[digest[0] % len(SESSION_COLORS)] +def _launch_color_dir(config: Mapping[str, Any]) -> Path | None: + return _colors._launch_color_dir(config) -def _launch_color_dir(config: Mapping[str, Any]) -> Path | None: - state_dir = config.get("state_dir") - if not state_dir: - return None - return Path(state_dir) / "launch-color" +def _active_color_reservations(path: Path, now: float) -> dict[str, dict[str, Any]]: + return _colors._active_color_reservations( + path, + now, + read_state_json=_read_state_json, + palette=SESSION_COLORS, + reservation_max_age=COLOR_RESERVATION_MAX_AGE_SECONDS, + ) def record_launch_color( - config: Mapping[str, Any], shpool_id: str + config: Mapping[str, Any], shpool_id: str, occupied_colors: Iterable[str] = () ) -> str | None: """Pick and persist a launch color for a session with no conversation ID.""" - if ( - not shpool_id - or "/" in shpool_id - or shpool_id.startswith(".") - or len(shpool_id) > 128 - ): - return None - color = launch_color_for(shpool_id) - directory = _launch_color_dir(config) - if directory is None: - return color - try: - directory.mkdir(mode=0o700, parents=True, exist_ok=True) - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - descriptor = os.open(directory / shpool_id, flags, 0o600) - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - handle.write(color + "\n") - except OSError: - # The theme still applies this boot; only later adoption is lost. - pass - return color + return _colors.record_launch_color( + config, + shpool_id, + occupied_colors, + launch_color_dir=_launch_color_dir, + launch_color=launch_color_for, + active_color_reservations=_active_color_reservations, + state_paths=_state_paths, + state_lock=StateLock, + atomic_write_json=atomic_write_json, + schema_version=SCHEMA_VERSION, + ) + + +def _reserve_conversation_color( + config: Mapping[str, Any], + provider: str, + uuid: str, + occupied_colors: Iterable[str], +) -> str: + return _colors._reserve_conversation_color( + config, + provider, + uuid, + occupied_colors, + state_paths=_state_paths, + config_path=config_path, + state_lock=StateLock, + active_color_reservations=_active_color_reservations, + color_for_session=session_color, + free_color=first_free_color, + private_alias_parent=_private_alias_parent, + private_alias_document=_private_alias_document, + valid_colors=_valid_colors, + atomic_write_json=atomic_write_json, + schema_version=SCHEMA_VERSION, + palette=palette_for_provider(provider), + ) + + +def reconcile_session_colors( + config: Mapping[str, Any], + sessions: Sequence[Mapping[str, Any]], + *, + revalidate_sessions: Callable[[], Sequence[Mapping[str, Any]]] | None = None, +) -> dict[str, Any]: + """Settle every live same-provider colour collision in one idempotent pass.""" + return _colors.reconcile_conversation_colors( + config, + sessions, + config_path=config_path, + state_paths=_state_paths, + state_lock=StateLock, + private_alias_parent=_private_alias_parent, + private_alias_document=_private_alias_document, + valid_colors=_valid_colors, + atomic_write_json=atomic_write_json, + color_for_session=session_color, + free_color=first_free_color, + palette_for=palette_for_provider, + revalidate_sessions=revalidate_sessions, + ) + + +def _release_conversation_color( + config: Mapping[str, Any], provider: str, uuid: str, color: str +) -> bool: + """Roll back one failed pre-bake only when reservation and override match.""" + return _colors._release_conversation_color( + config, + provider, + uuid, + color, + state_paths=_state_paths, + config_path=config_path, + state_lock=StateLock, + active_color_reservations=_active_color_reservations, + private_alias_document=_private_alias_document, + valid_colors=_valid_colors, + atomic_write_json=atomic_write_json, + schema_version=SCHEMA_VERSION, + palette=palette_for_provider(provider), + ) def _adopt_launch_colors( @@ -3266,45 +2230,15 @@ def _adopt_launch_colors( overrides: Mapping[str, str], ) -> dict[str, str]: """Turn launch-color markers into explicit overrides once IDs are known.""" - current = dict(overrides) - directory = _launch_color_dir(config) - if directory is None or not directory.is_dir(): - return current - now = time.time() - by_shpool_id: dict[str, Mapping[str, Any]] = { - str(item.get("shpool_id") or ""): item - for item in sessions - if item.get("provider") == "codex" - } - try: - markers = list(directory.iterdir()) - except OSError: - return current - for marker in markers: - try: - if marker.is_symlink() or not marker.is_file(): - continue - stat_result = marker.stat() - if now - stat_result.st_mtime > LAUNCH_COLOR_MAX_AGE_SECONDS: - marker.unlink(missing_ok=True) - continue - item = by_shpool_id.get(marker.name) - if item is None: - continue - uuid = valid_uuid(str((item.get("identity") or {}).get("uuid") or "")) - if not uuid: - continue - color = marker.read_text(encoding="utf-8", errors="strict").strip() - if color not in SESSION_COLORS: - marker.unlink(missing_ok=True) - continue - key = f"codex:{uuid}" - if key not in current: - current = mutate_canonical_color(config, "codex", uuid, color) - marker.unlink(missing_ok=True) - except (OSError, ValueError, CollectionError): - continue - return current + return _colors._adopt_launch_colors( + config, + sessions, + overrides, + launch_color_dir=_launch_color_dir, + mutate_color=mutate_canonical_color, + palette=CODEX_SESSION_COLORS, + launch_color_max_age=LAUNCH_COLOR_MAX_AGE_SECONDS, + ) def mutate_canonical_color( @@ -3312,828 +2246,441 @@ def mutate_canonical_color( provider: str, uuid: str, color: str | None, + *, + only_if_absent: bool = False, ) -> dict[str, str]: - exact_uuid = valid_uuid(uuid) - if provider not in PROVIDERS or not exact_uuid: - raise CollectionError("color requires provider claude|codex and an exact UUID") - if color is not None and color not in SESSION_COLORS: - raise CollectionError( - "color must be one of: " + ", ".join(SESSION_COLORS) - ) - path = config_path() - paths = _state_paths(config) - with StateLock(paths["root"], paths["config_lock"]): - with StateLock(paths["root"], paths["lock"]): - _private_alias_parent(path) - document = _private_alias_document(path, allow_missing=True) - colors = _valid_colors(document.get("colors")) - key = f"{provider}:{exact_uuid}" - if color is None: - colors.pop(key, None) - else: - colors[key] = color - if colors: - document["colors"] = dict(sorted(colors.items())) - else: - document.pop("colors", None) - atomic_write_json(path, document) - return dict(colors) + return _colors.mutate_canonical_color( + config, + provider, + uuid, + color, + config_path=config_path, + state_paths=_state_paths, + state_lock=StateLock, + private_alias_parent=_private_alias_parent, + private_alias_document=_private_alias_document, + valid_colors=_valid_colors, + atomic_write_json=atomic_write_json, + palette=palette_for_provider(provider), + only_if_absent=only_if_absent, + ) -def _push_claude_color( - home: Path, uuid: str, color: str -) -> tuple[list[str], list[str]]: - """Append the exact agent-color record /color itself writes. +def derive_prompt_title(prompt: Any) -> str | None: + """Derive a short display title from a first user prompt.""" + return _self_name.derive_prompt_title( + prompt, + stopwords=_TITLE_TRAILING_STOPWORDS, + ) - Claude Code reads it at session start/resume; nothing is ever typed into - a live terminal. Missing transcripts fail open. - """ - projects = home / ".claude" / "projects" - if not projects.is_dir(): - return [], ["Claude projects directory unavailable; color not pushed"] - try: - transcripts = sorted(projects.glob(f"*/{uuid}.jsonl")) - except OSError as exc: - return [], [f"Claude transcripts unreadable: {exc}"] - if not transcripts: - return [], ["no Claude transcript for this conversation; color not pushed"] - entry = json.dumps( - {"type": "agent-color", "agentColor": color, "sessionId": uuid}, - separators=(",", ":"), - ) - pushed: list[str] = [] - warnings: list[str] = [] - for transcript in transcripts[:4]: - if transcript.is_symlink(): - warnings.append(f"refusing symlinked transcript: {transcript.name}") - continue - try: - with open(transcript, "a", encoding="utf-8") as handle: - handle.write(entry + "\n") - handle.flush() - os.fsync(handle.fileno()) - pushed.append("claude-transcript-color") - except OSError as exc: - warnings.append(f"Claude transcript not appended: {exc}") - return pushed, warnings +def _first_text_block(content: Any) -> str: + return _self_name._first_text_block( + content, + ) -def propagate_provider_color( - provider: str, - uuid: str, - color: str, - *, - environ: Mapping[str, str] | None = None, + +def auto_title_from_hook( + raw: Any, *, environ: Mapping[str, str] | None = None ) -> dict[str, Any]: - """One-shot push of a session color into provider-native storage. + """Title a pre-baked Claude conversation from its first real prompt.""" + return _self_name.auto_title_from_hook( + raw, + environ=environ, + derive_title=derive_prompt_title, + propagate_title=propagate_provider_title, + max_transcript_bytes=MAX_AUTO_TITLE_TRANSCRIPT_BYTES, + resume_continuation_text=RESUME_CONTINUATION_TEXT, + name_owner=name_owner, + claim_name=claim_automatic_name, + ) - Codex has no native color surface; its rows carry the kit-side color only. - """ - exact_uuid = valid_uuid(uuid) - if provider not in PROVIDERS or not exact_uuid or color not in SESSION_COLORS: - return { - "provider_color_pushes": [], - "provider_color_warnings": ["invalid provider color push request"], - } - if provider != "claude": - return {"provider_color_pushes": [], "provider_color_warnings": []} - env = environ if environ is not None else os.environ - # An explicit environment IS the caller's sandbox; falling back to the - # real home from inside one would leak pushes into live provider stores. - home_raw = env.get("HOME") or ( - os.fspath(Path.home()) if environ is None else "" - ) - if not home_raw: - return { - "provider_color_pushes": [], - "provider_color_warnings": [ - "caller environment has no HOME; provider color not pushed" - ], - } - home = Path(home_raw) - pushed, warnings = _push_claude_color(home, exact_uuid, color) - for warning in warnings: - print(f"session inventory: {warning}", file=sys.stderr) - return { - "provider_color_pushes": pushed, - "provider_color_warnings": warnings, - } +def prove_self_name_caller( + inventory: Mapping[str, Any], + process_table: Mapping[int, Mapping[str, Any]], + environ: Mapping[str, str], + current_pid: int, +) -> dict[str, Any]: + """Prove one exact managed root conversation and its current shell generation.""" + return _self_name.prove_self_name_caller( + inventory, + process_table, + environ, + current_pid, + ) -def propagate_provider_title( - provider: str, - uuid: str, + +def self_name_automatic_title( + config: Mapping[str, Any], title: str, *, + inventory: Mapping[str, Any] | None = None, + process_table: Mapping[int, Mapping[str, Any]] | None = None, environ: Mapping[str, str] | None = None, + current_pid: int | None = None, ) -> dict[str, Any]: - """One-shot push of an assigned name into the provider's own surfaces. - - Assignment semantics are last-writer-wins: the push happens once at - assignment time and later provider-side renames stand until the next - assignment. Every failure is fail-open — naming never breaks because a - provider surface is unavailable — but each skipped surface is reported. - """ - exact_uuid = valid_uuid(uuid) - clean_title = clean_text(title, 100) - if ( - provider not in PROVIDERS - or not exact_uuid - or not clean_title - # A placeholder identity must never be written into provider stores. - or set(exact_uuid.replace("-", "")) <= {"0"} - ): - return { - "provider_title_pushes": [], - "provider_title_warnings": ["invalid provider title push request"], - } - env = environ if environ is not None else os.environ - # An explicit environment IS the caller's sandbox; falling back to the - # real home from inside one would leak pushes into live provider stores. - home_raw = env.get("HOME") or ( - os.fspath(Path.home()) if environ is None else "" + """Provider-neutral exact self-name entry point used by root agents.""" + return _self_name.self_name_automatic_title( + config, + title, + inventory=inventory, + process_table=process_table, + environ=environ, + current_pid=current_pid, + default_max_proc_nodes=DEFAULT_MAX_PROC_NODES, + record_retry=_record_provider_title_retry, + canonical_colors=canonical_colors, + mutate_self_name=mutate_canonical_self_name, + normalize_title=normalize_automatic_title, + process_table_reader=platform_process_table, + propagate_color=propagate_provider_color, + propagate_title=propagate_provider_title, + prove_caller=prove_self_name_caller, + record_title_failure=record_automatic_title_failure, + session_color=session_color, + snapshot_inventory=snapshot, ) - if not home_raw: - return { - "provider_title_pushes": [], - "provider_title_warnings": [ - "caller environment has no HOME; provider title not pushed" - ], - } - home = Path(home_raw) - if provider == "claude": - pushed, warnings = _push_claude_title(home, exact_uuid, clean_title) - else: - pushed, warnings = _push_codex_title(home, exact_uuid, clean_title) - for warning in warnings: - print(f"session inventory: {warning}", file=sys.stderr) - return { - "provider_title_pushes": pushed, - "provider_title_warnings": warnings, - } -# A conversation launched through the first-window color pre-bake opens as a -# resume, and Claude Code never auto-titles a resumed conversation (its -# once-only guard initializes from the loaded message count, which the -# resume-synthesized continuation pair makes non-zero). These sessions carry a -# unique signature: a synthetic isMeta user record holding the resume -# continuation text, written at load before the human's first prompt. For -# exactly those sessions the kit derives a title from the first real prompt -# and writes it through the same native records /rename persists. Sessions -# outside that signature are never touched, so Claude's own (better) auto -# titles are never masked. -RESUME_CONTINUATION_TEXT = "Continue from where you left off." -MAX_AUTO_TITLE_TRANSCRIPT_BYTES = 8 * 1024 * 1024 -_TITLE_TRAILING_STOPWORDS = frozenset( - "a an and any are at be but by each every for from how if in is it of on " - "or so that the then there this to was were what when which who why will " - "with".split() -) +def _strict_legacy_aliases(path: Path) -> tuple[bytes, dict[str, str]] | None: + return _migration._strict_legacy_aliases( + path, + max_private_json_bytes=MAX_PRIVATE_JSON_BYTES, + schema_version=SCHEMA_VERSION, + ) -def derive_prompt_title(prompt: Any) -> str | None: - """Derive a short display title from a first user prompt. +def _create_private_backup(path: Path, payload: bytes) -> None: + return _migration._create_private_backup( + path, + payload, + max_private_json_bytes=MAX_PRIVATE_JSON_BYTES, + ) - Pure heuristic — no model call: first sentence, at most seven words, - trailing function words trimmed, sentence-cased, capped at 64 characters. - """ - if not isinstance(prompt, str): - return None - text = re.sub(r"\s+", " ", prompt).strip() - if not text or text.startswith("/"): - return None - text = re.split(r"(?<=[.!?])\s", text, maxsplit=1)[0] - words = text.split(" ")[:7] - trimmed = list(words) - while trimmed and trimmed[-1].lower().strip("?,.:;!\"'") in _TITLE_TRAILING_STOPWORDS: - trimmed.pop() - if not trimmed: - trimmed = words - title = " ".join(trimmed).rstrip(" ,;:.!?\"'") - if len(title) < 4: - return None - title = title[:64].rstrip() - return title[0].upper() + title[1:] +def _alias_migration_images( + backup_bytes: bytes, runtime_aliases: Mapping[str, str] +) -> tuple[bytes | None, dict[str, Any], bytes]: + return _migration._alias_migration_images( + backup_bytes, + runtime_aliases, + absent_alias_backup=ABSENT_ALIAS_CONFIG_BACKUP, + schema_version=SCHEMA_VERSION, + alias_document_from_bytes=_alias_document_from_bytes, + ) -def _first_text_block(content: Any) -> str: - if isinstance(content, str): - return content - if isinstance(content, list): - for block in content: - if isinstance(block, Mapping) and block.get("type") == "text": - text = block.get("text") - if isinstance(text, str): - return text - return "" +def migrate_runtime_aliases(config: Mapping[str, Any]) -> dict[str, Any]: + """Explicitly preserve the old effective runtime-wins values in config.""" + return _migration.migrate_runtime_aliases( + config, + absent_alias_backup=ABSENT_ALIAS_CONFIG_BACKUP, + max_private_json_bytes=MAX_PRIVATE_JSON_BYTES, + schema_version=SCHEMA_VERSION, + private_alias_parent=_private_alias_parent, + atomic_write_json=atomic_write_json, + canonical_aliases=canonical_aliases, + config_path=config_path, + alias_migration_images_fn=_alias_migration_images, + create_private_backup_fn=_create_private_backup, + strict_legacy_aliases_fn=_strict_legacy_aliases, + ) -def auto_title_from_hook( - raw: Any, *, environ: Mapping[str, str] | None = None -) -> dict[str, Any]: - """Title a pre-baked Claude conversation from its first real prompt. - Input is the UserPromptSubmit hook payload (JSON text). Eligibility is - strict and every miss fails open with title=null: the transcript must - carry the resume-continuation signature, no real conversation beyond the - triggering prompt, and no title of any kind (ai, /rename, agent-name, - kit name intent) may already exist. - """ - out: dict[str, Any] = {"title": None, "pushes": [], "warnings": []} +def _release_sha(value: Any) -> str: + return _migration._release_sha( + value, + release_root=Path(__file__).resolve().parent.parent, + ) - def skip(reason: str) -> dict[str, Any]: - out["reason"] = reason - return out - try: - payload = json.loads(raw if isinstance(raw, str) else "") - except ValueError: - return skip("hook payload is not valid JSON") - if not isinstance(payload, Mapping): - return skip("hook payload is not an object") - # UserPromptSubmit fires before the first turn is flushed to disk (the - # pre-bake signature is not yet visible then); Stop fires right after the - # first answer with everything durable. Supporting both means the title - # lands at the end of the first exchange. - if payload.get("hook_event_name") not in ("UserPromptSubmit", "Stop"): - return skip("not a UserPromptSubmit or Stop event") - uuid = valid_uuid(str(payload.get("session_id") or "")) - if not uuid: - return skip("missing or invalid session_id") - - env = environ if environ is not None else os.environ - home = Path(env.get("HOME") or os.fspath(Path.home())) - transcript_raw = payload.get("transcript_path") - if not isinstance(transcript_raw, str) or not transcript_raw: - return skip("missing transcript_path") - transcript = Path(transcript_raw) - projects = (home / ".claude" / "projects").resolve() - try: - resolved = transcript.resolve(strict=True) - except OSError: - return skip("transcript unavailable") - if ( - transcript.is_symlink() - or resolved.name != f"{uuid}.jsonl" - or resolved.parent.parent != projects - ): - return skip("transcript path is not this session's own transcript") - if (home / ".claude" / "sessions" / f"{uuid}.nameintent").exists(): - return skip("a kit name intent already exists") - try: - if resolved.stat().st_size > MAX_AUTO_TITLE_TRANSCRIPT_BYTES: - return skip("transcript exceeds the bounded size") - lines = resolved.read_text(encoding="utf-8", errors="replace").splitlines() - except OSError: - return skip("transcript unreadable") +def _daemon_start_epoch(proc_root: Path, generation: Mapping[str, Any]) -> float: + return _migration._daemon_start_epoch( + proc_root, + generation, + ) - signature = False - real_users = 0 - first_prompt: str | None = None - for line in lines: - try: - record = json.loads(line) - except ValueError: - continue - if not isinstance(record, Mapping): - continue - kind = record.get("type") - if kind in ("ai-title", "agent-name", "custom-title"): - return skip("the session already has a title") - if kind != "user": - continue - if record.get("isSidechain"): - continue - content = ( - record.get("message", {}).get("content") - if isinstance(record.get("message"), Mapping) - else None - ) - text = _first_text_block(content) - if record.get("isMeta"): - if text.startswith(RESUME_CONTINUATION_TEXT): - signature = True - continue - # Tool results are stored as user-typed records too; only records - # carrying prompt text count as human prompts. - if isinstance(content, str) or text: - real_users += 1 - if first_prompt is None: - first_prompt = content if isinstance(content, str) else text - if not signature: - return skip("no pre-bake resume signature") - if real_users > 1: - return skip("conversation already has prior prompts") - # The conversation's own first prompt names the topic; the live payload - # prompt is the fallback for the pre-flush window. - title = derive_prompt_title(first_prompt) or derive_prompt_title( - payload.get("prompt") - ) - if not title: - return skip("prompt does not yield a title") - - entry = json.dumps( - {"type": "ai-title", "aiTitle": title, "sessionId": uuid}, - separators=(",", ":"), + +def _legacy_identities(value: Mapping[str, Any]) -> dict[str, tuple[str, str]]: + return _migration._legacy_identities( + value, ) - try: - with open(resolved, "a", encoding="utf-8") as handle: - handle.write(entry + "\n") - handle.flush() - os.fsync(handle.fileno()) - out["pushes"].append("claude-transcript-ai-title") - except OSError as exc: - return skip(f"transcript not appended: {exc}") - pushed = propagate_provider_title("claude", uuid, title, environ=env) - out["title"] = title - out["pushes"].extend(pushed.get("provider_title_pushes", [])) - out["warnings"].extend(pushed.get("provider_title_warnings", [])) - return out -def _strict_legacy_aliases(path: Path) -> tuple[bytes, dict[str, str]] | None: - payload = _read_bounded_owner_file( +def _evidence_identities(value: Mapping[str, Any]) -> dict[str, tuple[str, str]]: + return _migration._evidence_identities( + value, + ) + + +def _current_identities( + inventory: Mapping[str, Any], +) -> tuple[dict[str, tuple[str, str]], set[tuple[str, str]]]: + return _migration._current_identities( + inventory, + ) + + +def _migration_context( + config: Mapping[str, Any], + *, + legacy_bytes: bytes, + evidence_path: Path, + collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, + proc_root: Path | None = None, +) -> dict[str, Any]: + return _migration._migration_context( + config, + legacy_bytes=legacy_bytes, + evidence_path=evidence_path, + collector=collector, + proc_root=proc_root, + generation_key=_generation_key, + has_recovery_entries=_has_recovery_entries, + parse_private_json=_parse_private_json, + parse_utc_timestamp=_parse_utc_timestamp, + sha256=_sha256, + collect_live=collect_live, + recovery_manifest=recovery_manifest, + strict_live_inventory=strict_live_inventory, + ) + + +def _plan_token(plan: Mapping[str, Any]) -> str: + return _migration._plan_token( + plan, + json_bytes=_json_bytes, + sha256=_sha256, + ) + + +def plan_legacy_recovery_manifest( + config: Mapping[str, Any], + continuity_evidence: str | Path, + release_sha: str, + *, + collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, + proc_root: Path | None = None, + now: float | None = None, +) -> dict[str, Any]: + """Create a read-only, content-addressed migration plan.""" + return _migration.plan_legacy_recovery_manifest( + config, + continuity_evidence, + release_sha, + collector=collector, + proc_root=proc_root, + now=now, + schema_version=SCHEMA_VERSION, + json_bytes=_json_bytes, + read_private_regular_bytes=_read_private_regular_bytes, + sha256=_sha256, + migration_context_fn=_migration_context, + plan_token_fn=_plan_token, + verify_release_sha=_release_sha, + ) + + +def publish_legacy_migration_plan( + config: Mapping[str, Any], output: str | Path, plan: Mapping[str, Any] +) -> dict[str, Any]: + """Durably create a reviewed plan as a private, non-overwriting state file.""" + return _migration.publish_legacy_migration_plan( + config, + output, + plan, + schema_version=SCHEMA_VERSION, + json_bytes=_json_bytes, + read_private_regular_bytes=_read_private_regular_bytes, + write_manifest_backup=_write_legacy_manifest_backup, + ) + + +def _validate_migration_plan( + plan: Mapping[str, Any], paths: Mapping[str, Path], release_sha: str +) -> None: + return _migration._validate_migration_plan( + plan, + paths, + release_sha, + schema_version=SCHEMA_VERSION, + generation_key=_generation_key, + json_bytes=_json_bytes, + sha256=_sha256, + plan_token_fn=_plan_token, + verify_release_sha=_release_sha, + ) + + +def _receipt_path(paths: Mapping[str, Path]) -> Path: + return _migration._receipt_path( + paths, + ) + + +def _migration_receipt( + plan: Mapping[str, Any], phase: str, *, now: float | None = None +) -> dict[str, Any]: + return _migration._migration_receipt( + plan, + phase, + now=now, + schema_version=SCHEMA_VERSION, + ) + + +def _read_matching_receipt( + path: Path, plan: Mapping[str, Any], *, required: bool +) -> Mapping[str, Any] | None: + return _migration._read_matching_receipt( path, - label="legacy runtime alias file", - max_bytes=MAX_PRIVATE_JSON_BYTES, - exact_mode=0o600, - allow_missing=True, + plan, + required=required, + schema_version=SCHEMA_VERSION, + parse_private_json=_parse_private_json, + parse_utc_timestamp=_parse_utc_timestamp, ) - if payload is None: - return None - try: - raw = json.loads(payload.decode("utf-8", "strict")) - except (UnicodeDecodeError, ValueError) as exc: - raise CollectionError("legacy runtime alias file is invalid JSON") from exc - aliases = raw.get("aliases") if isinstance(raw, Mapping) else None - normalized = _valid_aliases(aliases) - if ( - not isinstance(raw, Mapping) - or raw.get("schema_version") != SCHEMA_VERSION - or not isinstance(aliases, Mapping) - or len(normalized) != len(aliases) - ): - raise CollectionError("legacy runtime alias file has an invalid schema") - return payload, normalized -def _create_private_backup(path: Path, payload: bytes) -> None: - flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - try: - descriptor = os.open(path, flags, 0o600) - except FileExistsError: - existing = _read_bounded_owner_file( - path, - label="alias migration backup", - max_bytes=MAX_PRIVATE_JSON_BYTES, - exact_mode=0o600, - ) - if existing != payload: - raise CollectionError("alias migration backup already exists with other bytes") - return - try: - os.fchmod(descriptor, 0o600) - view = memoryview(payload) - while view: - written = os.write(descriptor, view) - if written <= 0: - raise CollectionError("could not write alias migration backup") - view = view[written:] - os.fsync(descriptor) - finally: - os.close(descriptor) - directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) - try: - os.fsync(directory) - finally: - os.close(directory) +def _revalidate_plan_context( + config: Mapping[str, Any], + plan: Mapping[str, Any], + legacy_bytes: bytes, + *, + collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None, + proc_root: Path | None, +) -> None: + return _migration._revalidate_plan_context( + config, + plan, + legacy_bytes, + collector=collector, + proc_root=proc_root, + json_bytes=_json_bytes, + sha256=_sha256, + migration_context_fn=_migration_context, + ) -def _alias_migration_images( - backup_bytes: bytes, runtime_aliases: Mapping[str, str] -) -> tuple[bytes | None, dict[str, Any], bytes]: - if backup_bytes == ABSENT_ALIAS_CONFIG_BACKUP: - preimage = { - "schema_version": SCHEMA_VERSION, - "aliases": {}, - } - preimage_bytes = None - else: - preimage = _alias_document_from_bytes( - backup_bytes, label="alias migration backup" - ) - preimage_bytes = backup_bytes - merged = {**preimage["aliases"], **runtime_aliases} - postimage = dict(preimage) - postimage["aliases"] = dict(sorted(merged.items())) - return preimage_bytes, postimage, _atomic_json_bytes(postimage) +def apply_legacy_recovery_manifest( + config: Mapping[str, Any], + plan_path: str | Path, + release_sha: str, + *, + collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, + proc_root: Path | None = None, +) -> dict[str, Any]: + """Apply or safely resume one reviewed legacy migration plan.""" + return _migration.apply_legacy_recovery_manifest( + config, + plan_path, + release_sha, + collector=collector, + proc_root=proc_root, + schema_version=SCHEMA_VERSION, + parse_private_json=_parse_private_json, + read_private_regular_bytes=_read_private_regular_bytes, + sha256=_sha256, + write_manifest_backup=_write_legacy_manifest_backup, + atomic_write_json=atomic_write_json, + migration_receipt_fn=_migration_receipt, + read_matching_receipt_fn=_read_matching_receipt, + revalidate_plan_context_fn=_revalidate_plan_context, + validate_migration_plan_fn=_validate_migration_plan, + ) -def migrate_runtime_aliases(config: Mapping[str, Any]) -> dict[str, Any]: - """Explicitly preserve the old effective runtime-wins values in config.""" - paths = _state_paths(config) - config_file = config_path() - backup = config_file.with_name(f"{config_file.name}.pre-runtime-alias-migration-v1") - with StateLock(paths["root"], paths["config_lock"]): - with StateLock(paths["root"], paths["lock"]): - legacy = _strict_legacy_aliases(paths["aliases"]) - archived = _strict_legacy_aliases(paths["aliases_archive"]) - if legacy is None: - if archived is not None: - source_backup = _read_bounded_owner_file( - paths["aliases_source_backup"], - label="legacy runtime alias source backup", - max_bytes=MAX_PRIVATE_JSON_BYTES, - exact_mode=0o600, - ) - if source_backup != archived[0]: - raise CollectionError( - "archived runtime aliases diverge from migration evidence" - ) - backup_bytes = _read_bounded_owner_file( - backup, - label="alias migration backup", - max_bytes=MAX_PRIVATE_JSON_BYTES, - exact_mode=0o600, - ) - before = _read_bounded_owner_file( - config_file, - label="canonical alias config", - max_bytes=MAX_PRIVATE_JSON_BYTES, - exact_mode=0o600, - allow_missing=True, - ) - ( - preimage_bytes, - postimage, - postimage_bytes, - ) = _alias_migration_images( - backup_bytes, archived[1] - ) - repaired = False - if before == postimage_bytes: - pass - elif before == preimage_bytes: - _private_alias_parent(config_file) - atomic_write_json(config_file, postimage) - repaired = True - else: - raise CollectionError( - "canonical alias config diverged from the migration postimage" - ) - directory = os.open( - paths["root"], os.O_RDONLY | os.O_DIRECTORY - ) - try: - os.fsync(directory) - finally: - os.close(directory) - return { - "schema_version": SCHEMA_VERSION, - "migrated": bool(archived is not None and repaired), - "already_migrated": bool( - archived is not None and not repaired - ), - "aliases": canonical_aliases(config), - } - rollback_retry = archived is not None - if rollback_retry and archived[0] != legacy[0]: - raise CollectionError( - "active and archived runtime aliases differ" - ) - _private_alias_parent(config_file) - before = _read_bounded_owner_file( - config_file, - label="canonical alias config", - max_bytes=MAX_PRIVATE_JSON_BYTES, - exact_mode=0o600, - allow_missing=True, - ) - backup_bytes = _read_bounded_owner_file( - backup, - label="alias migration backup", - max_bytes=MAX_PRIVATE_JSON_BYTES, - exact_mode=0o600, - allow_missing=True, - ) - if backup_bytes is None: - backup_bytes = ( - before - if before is not None - else ABSENT_ALIAS_CONFIG_BACKUP - ) - _create_private_backup(backup, backup_bytes) - _create_private_backup( - paths["aliases_source_backup"], legacy[0] - ) - ( - preimage_bytes, - postimage, - postimage_bytes, - ) = _alias_migration_images(backup_bytes, legacy[1]) - if before == postimage_bytes: - pass - elif before == preimage_bytes: - atomic_write_json(config_file, postimage) - else: - raise CollectionError( - "canonical alias config diverged during migration" - ) - if rollback_retry: - os.unlink(paths["aliases"]) - else: - os.replace(paths["aliases"], paths["aliases_archive"]) - directory = os.open(paths["root"], os.O_RDONLY | os.O_DIRECTORY) - try: - os.fsync(directory) - finally: - os.close(directory) - durable_archive = _strict_legacy_aliases(paths["aliases_archive"]) - source_backup = _read_bounded_owner_file( - paths["aliases_source_backup"], - label="legacy runtime alias source backup", - max_bytes=MAX_PRIVATE_JSON_BYTES, - exact_mode=0o600, - ) - if ( - durable_archive is None - or durable_archive[0] != legacy[0] - or source_backup != legacy[0] - or paths["aliases"].exists() - ): - raise CollectionError( - "runtime alias archive did not reach its durable postcondition" - ) - return { - "schema_version": SCHEMA_VERSION, - "migrated": True, - "already_migrated": False, - "aliases": dict(postimage["aliases"]), - } +def rollback_legacy_recovery_manifest( + config: Mapping[str, Any], + plan_path: str | Path, + release_sha: str, + *, + collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, + proc_root: Path | None = None, +) -> dict[str, Any]: + """Restore exact legacy bytes while every plan and generation guard holds.""" + return _migration.rollback_legacy_recovery_manifest( + config, + plan_path, + release_sha, + collector=collector, + proc_root=proc_root, + schema_version=SCHEMA_VERSION, + atomic_write_private_bytes=_atomic_write_private_bytes, + parse_private_json=_parse_private_json, + read_private_regular_bytes=_read_private_regular_bytes, + sha256=_sha256, + atomic_write_json=atomic_write_json, + migration_receipt_fn=_migration_receipt, + read_matching_receipt_fn=_read_matching_receipt, + revalidate_plan_context_fn=_revalidate_plan_context, + validate_migration_plan_fn=_validate_migration_plan, + ) def _boot_id() -> str | None: - override = os.environ.get("SESSION_KIT_BOOT_ID_FILE") - if override: - try: - value = Path(override).read_text(encoding="utf-8").strip() - except OSError: - return None - return value or None - if _runtime_platform() == DARWIN_PLATFORM: - if not _darwin_preview_enabled(): - return None - try: - libc, _ = _darwin_libraries() - value = _DarwinTimeval() - size = ctypes.c_size_t(ctypes.sizeof(value)) - if libc.sysctlbyname( - b"kern.boottime", - ctypes.byref(value), - ctypes.byref(size), - None, - 0, - ) != 0: - return None - if value.tv_sec <= 0 or value.tv_usec < 0: - return None - return f"darwin:{value.tv_sec}:{value.tv_usec}" - except (OSError, ValueError): - return None - try: - value = Path("/proc/sys/kernel/random/boot_id").read_text( - encoding="utf-8" - ).strip() - except OSError: - return None - return value or None + return _processes._boot_id( + environ=os.environ, + runtime_platform=_runtime_platform, + darwin_libraries=_darwin_libraries, + ) def _empty_terminal_registry(boot_id: str) -> dict[str, Any]: - return { - "schema_version": SCHEMA_VERSION, - "boot_id": boot_id, - "next_number": 1, - "bindings": {}, - } + return _state_io._empty_terminal_registry( + boot_id, + schema_version=SCHEMA_VERSION, + ) def _validate_terminal_registry(raw: Any, boot_id: str) -> dict[str, Any]: - if not isinstance(raw, Mapping) or set(raw) != { - "schema_version", - "boot_id", - "next_number", - "bindings", - }: - raise CollectionError("terminal number registry has an invalid schema") - if raw.get("schema_version") != SCHEMA_VERSION: - raise CollectionError("terminal number registry has an unsupported schema") - stored_boot = raw.get("boot_id") - next_number = raw.get("next_number") - bindings = raw.get("bindings") - if ( - not isinstance(stored_boot, str) - or not stored_boot - or isinstance(next_number, bool) - or not isinstance(next_number, int) - or next_number <= 0 - or not isinstance(bindings, Mapping) - ): - raise CollectionError("terminal number registry has invalid fields") - checked: dict[str, int] = {} - ai_by_number: dict[int, str] = {} - generation_by_number: dict[int, str] = {} - for key, value in bindings.items(): - if ( - not isinstance(key, str) - or isinstance(value, bool) - or not isinstance(value, int) - or value <= 0 - ): - raise CollectionError("terminal number registry has an invalid binding") - if key.startswith("ai:"): - _, separator, remainder = key.partition(":") - provider, separator, uuid = remainder.partition(":") - if ( - not separator - or provider not in PROVIDERS - or valid_uuid(uuid) != uuid - or (value in ai_by_number and ai_by_number[value] != key) - ): - raise CollectionError( - "terminal number registry has a conflicting AI binding" - ) - ai_by_number[value] = key - elif re.fullmatch(r"generation:[0-9a-f]{64}", key): - if ( - value in generation_by_number - and generation_by_number[value] != key - ): - raise CollectionError( - "terminal number registry has duplicate generation bindings" - ) - generation_by_number[value] = key - else: - raise CollectionError("terminal number registry has an invalid key") - checked[key] = value - if checked and next_number <= max(checked.values()): - raise CollectionError("terminal number registry would reuse a number") - if stored_boot != boot_id: - return _empty_terminal_registry(boot_id) - return { - "schema_version": SCHEMA_VERSION, - "boot_id": stored_boot, - "next_number": next_number, - "bindings": checked, - } + return _state_io._validate_terminal_registry( + raw, + boot_id, + schema_version=SCHEMA_VERSION, + empty_registry=_empty_terminal_registry, + ) def _read_terminal_registry( path: Path, boot_id: str, epoch_path: Path | None = None ) -> dict[str, Any]: - payload = _read_bounded_owner_file( - path, - label="terminal number registry", - max_bytes=MAX_PRIVATE_JSON_BYTES, - exact_mode=0o600, - allow_missing=True, - ) - if payload is None: - if epoch_path is not None: - epoch_payload = _read_bounded_owner_file( - epoch_path, - label="terminal number initialization receipt", - max_bytes=4096, - exact_mode=0o600, - allow_missing=True, - ) - if epoch_payload is not None: - try: - epoch = json.loads(epoch_payload.decode("utf-8", "strict")) - except (UnicodeDecodeError, ValueError) as exc: - raise CollectionError( - "terminal number initialization receipt is invalid" - ) from exc - if ( - not isinstance(epoch, Mapping) - or set(epoch) != {"schema_version", "boot_id"} - or epoch.get("schema_version") != SCHEMA_VERSION - or not isinstance(epoch.get("boot_id"), str) - or not epoch["boot_id"] - ): - raise CollectionError( - "terminal number initialization receipt has an invalid schema" - ) - if epoch["boot_id"] == boot_id: - raise CollectionError( - "same-boot terminal number registry disappeared" - ) - return _empty_terminal_registry(boot_id) - try: - raw = json.loads(payload.decode("utf-8", "strict")) - except (UnicodeDecodeError, ValueError) as exc: - raise CollectionError("terminal number registry is invalid JSON") from exc - return _validate_terminal_registry(raw, boot_id) + return _terminal._with_supervisor_identities( + _state_io._read_terminal_registry( + path, + boot_id, + epoch_path, + schema_version=SCHEMA_VERSION, + max_bytes=MAX_PRIVATE_JSON_BYTES, + read_bounded_owner_file=_read_bounded_owner_file, + empty_registry=_empty_terminal_registry, + validate_registry=_validate_terminal_registry, + ), + path.parent / "supervisor", + ) -def _terminal_generation_key( - inventory: Mapping[str, Any], item: Mapping[str, Any], boot_id: str -) -> str | None: - shell = item.get("shpool_shell") - values = ( - item.get("started_at_unix_ms"), - shell.get("pid") if isinstance(shell, Mapping) else None, - shell.get("process_start_ticks") if isinstance(shell, Mapping) else None, - ) - if any( - isinstance(value, bool) or not isinstance(value, int) or value <= 0 - for value in values - ): - return None - raw_id = item.get("shpool_id_raw") - if not isinstance(raw_id, str) or not raw_id: - return None - payload = json.dumps( - [boot_id, raw_id, values[0], values[1], values[2]], - ensure_ascii=False, - separators=(",", ":"), - ).encode("utf-8") - return f"generation:{hashlib.sha256(payload).hexdigest()}" +def _terminal_ai_key(item: Mapping[str, Any]) -> str | None: + return _terminal._terminal_ai_key(item) -def _terminal_ai_key(item: Mapping[str, Any]) -> str | None: - identity = item.get("identity") - provider = item.get("provider") - if ( - provider in PROVIDERS - and isinstance(identity, Mapping) - and identity.get("confidence") == "exact" - ): - uuid = valid_uuid(identity.get("uuid")) - if uuid: - return f"ai:{provider}:{uuid}" - hint = item.get("_terminal_identity_hint") - if isinstance(hint, Mapping) and hint.get("provider") in PROVIDERS: - uuid = valid_uuid(hint.get("uuid")) - if uuid: - return f"ai:{hint['provider']}:{uuid}" - return None - - -def _missing_shell_generation_is_quarantinable( - item: Mapping[str, Any], -) -> bool: - """Recognize only an inert disconnected shpool row with no live shell.""" - identity = item.get("identity") - recovery = item.get("recovery") - raw_id = item.get("shpool_id_raw") - started = item.get("started_at_unix_ms") - diagnostics = item.get("diagnostics") - expected_diagnostic = ( - f"expected one daemon child for {raw_id!r}, found 0" - if isinstance(raw_id, str) - else "" +def _read_terminal_retirements(path: Path, boot_id: str) -> dict[int, float]: + """Retirement ledger: number -> unix time its last session disappeared.""" + return _terminal._read_terminal_retirements( + path, + boot_id, + max_bytes=MAX_PRIVATE_JSON_BYTES, ) - return ( - item.get("provider") == "unknown" - and item.get("display_provider") == "unknown" - and item.get("availability") == "ready" - and clean_text(item.get("shpool_status"), 32).casefold() == "disconnected" - and isinstance(raw_id, str) - and bool(raw_id) - and raw_id == item.get("shpool_id") - and shpool_id_mutation_policy(raw_id) == (True, None) - and isinstance(started, int) - and not isinstance(started, bool) - and started > 0 - and item.get("shpool_shell") is None - and isinstance(identity, Mapping) - and identity.get("confidence") == "unknown" - and identity.get("uuid") is None - and identity.get("pid") is None - and identity.get("process_start_ticks") is None - and identity.get("provenance") == "none" - and isinstance(recovery, Mapping) - and recovery.get("available") is False - and recovery.get("provider") is None - and recovery.get("uuid") is None - and not isinstance(item.get("_terminal_identity_hint"), Mapping) - and isinstance(diagnostics, list) - and expected_diagnostic in diagnostics - ) - - -def _missing_shell_generation_is_quarantined( - item: Mapping[str, Any], -) -> bool: - return ( - _missing_shell_generation_is_quarantinable(item) - and item.get("terminal_number") is None - and item.get("mutation_allowed") is False - and item.get("mutation_rejection_reason") == "missing-shell-generation" + + +def _terminal_retirement_payload( + retired: Mapping[int, float], boot_id: str +) -> dict[str, Any]: + return _terminal._terminal_retirement_payload( + retired, + boot_id, + schema_version=SCHEMA_VERSION, ) @@ -4143,170 +2690,54 @@ def apply_terminal_numbers( *, boot_id: str, allocate: bool, + retired: dict[int, float] | None = None, + current_time: float | None = None, + supervisor_key: str | None = None, + previous_supervisor_key: str | None = None, ) -> dict[str, Any]: """Apply boot-stable selectors without changing contiguous internal rows.""" - checked = _validate_terminal_registry(registry, boot_id) - bindings: dict[str, int] = dict(checked["bindings"]) - next_number = checked["next_number"] - active_numbers: set[int] = set() - for item in inventory.get("sessions", ()): - if not isinstance(item, dict): - raise CollectionError("cannot number a non-object managed session") - generation_key = _terminal_generation_key(inventory, item, boot_id) - ai_key = _terminal_ai_key(item) - if generation_key is None: - if _missing_shell_generation_is_quarantinable(item): - item["terminal_number"] = None - item["mutation_allowed"] = False - item["mutation_rejection_reason"] = "missing-shell-generation" - item.pop("_terminal_identity_hint", None) - continue - if allocate: - raise CollectionError( - "managed session lacks an exact generation for numbering" - ) - ai_number = bindings.get(ai_key) if ai_key else None - generation_number = ( - bindings.get(generation_key) if generation_key else None - ) - generation_ai_key = ( - next( - ( - key - for key, value in bindings.items() - if key.startswith("ai:") and value == generation_number - ), - None, - ) - if generation_number is not None - else None - ) - if ai_number is not None: - # Exact AI identity is the continuity proof when a recovered - # conversation appears in a newly created shpool generation. The - # provisional number remains consumed, but the new generation is - # rebound to the conversation's established terminal number. - number = ai_number - elif generation_number is not None and ( - ai_key is None or generation_ai_key is None - ): - number = generation_number - elif generation_number is not None and not allocate: - # The generation was already promoted to another exact - # conversation. A read-only guard must not expose that number as - # belonging to this unrelated identity. - number = None - elif allocate: - number = next_number - next_number += 1 - else: - number = None - if number is not None: - if number in active_numbers: - raise CollectionError( - "terminal number registry maps two active sessions to one number" - ) - active_numbers.add(number) - if allocate: - assert generation_key is not None - for key, value in tuple(bindings.items()): - if ( - key.startswith("generation:") - and value == number - and key != generation_key - ): - del bindings[key] - bindings[generation_key] = number - if ai_key: - bindings[ai_key] = number - item["terminal_number"] = number - item.pop("_terminal_identity_hint", None) - return { - "schema_version": SCHEMA_VERSION, - "boot_id": boot_id, - "next_number": next_number, - "bindings": dict(sorted(bindings.items())), - } + return _terminal.apply_terminal_numbers( + inventory, + registry, + boot_id=boot_id, + allocate=allocate, + retired=retired, + current_time=current_time, + supervisor_key=( + supervisor_key + if supervisor_key is not None + else getattr(registry, "supervisor_key", None) + ), + previous_supervisor_key=( + previous_supervisor_key + if previous_supervisor_key is not None + else getattr(registry, "previous_supervisor_key", None) + ), + validate_registry=_validate_terminal_registry, + schema_version=SCHEMA_VERSION, + quarantine_seconds=TERMINAL_NUMBER_QUARANTINE_SECONDS, + ) def recovery_manifest(inventory: Mapping[str, Any]) -> dict[str, Any]: - sessions: dict[str, dict[str, Any]] = {} - for item in inventory.get("sessions", ()): - recovery = item.get("recovery", {}) - shpool_id = item.get("shpool_id_raw") or item.get("shpool_id") - if not shpool_id or not recovery.get("available"): - continue - sessions[shpool_id] = { - "scope": "shpool", - "provider": recovery["provider"], - "uuid": recovery["uuid"], - "title": item.get("title", ""), - "cwd": recovery.get("cwd"), - "argv": recovery.get("argv", []), - "command": recovery.get("command"), - } - outside_agents: dict[str, dict[str, Any]] = {} - for item in inventory.get("outside_agents", ()): - recovery = item.get("recovery", {}) - provider = recovery.get("provider") - uuid = valid_uuid(recovery.get("uuid")) - if not recovery.get("available") or provider not in PROVIDERS or not uuid: - continue - key = f"outside:{provider}:{uuid}" - outside_agents[key] = { - "scope": "outside", - "provider": provider, - "uuid": uuid, - "title": item.get("title", ""), - "cwd": recovery.get("cwd"), - "argv": recovery.get("argv", []), - "command": recovery.get("command"), - } - return { - "schema_version": SCHEMA_VERSION, - "generated_at": inventory.get("generated_at"), - "boot_id": _boot_id(), - "daemon_generation": inventory.get("daemon_generation"), - "sessions": sessions, - "outside_agents": outside_agents, - } - - -def _generation_key(value: Mapping[str, Any]) -> tuple[str, int, int] | None: - generation = value.get("daemon_generation") - if not isinstance(generation, Mapping): - return None - boot_id = value.get("boot_id") - pid = generation.get("pid") - start_ticks = generation.get("process_start_ticks") - if ( - not isinstance(boot_id, str) - or not boot_id - or not isinstance(pid, int) - or pid <= 0 - or not isinstance(start_ticks, int) - or start_ticks <= 0 - ): - return None - return boot_id, pid, start_ticks + return _recovery.recovery_manifest( + inventory, + schema_version=SCHEMA_VERSION, + boot_id=_boot_id, + ) def _valid_recovery_state(value: Any) -> bool: - return ( - isinstance(value, Mapping) - and value.get("schema_version") == SCHEMA_VERSION - and isinstance(value.get("sessions"), Mapping) - and ( - "outside_agents" not in value - or isinstance(value.get("outside_agents"), Mapping) - ) + return _recovery._valid_recovery_state( + value, + schema_version=SCHEMA_VERSION, ) def _has_recovery_entries(value: Any) -> bool: - return bool( - _valid_recovery_state(value) - and (value.get("sessions") or value.get("outside_agents")) + return _recovery._has_recovery_entries( + value, + valid_recovery_state=_valid_recovery_state, ) @@ -4410,8 +2841,7 @@ def _atomic_write_private_bytes(path: Path, content: bytes) -> None: def _json_bytes(value: Any) -> bytes: return ( - json.dumps(value, indent=2, sort_keys=True, separators=(",", ": ")) - + "\n" + json.dumps(value, indent=2, sort_keys=True, separators=(",", ": ")) + "\n" ).encode("utf-8") @@ -4442,1916 +2872,1430 @@ def _parse_utc_timestamp(value: Any, label: str) -> dt.datetime: return parsed.astimezone(dt.timezone.utc) -def _release_sha(value: Any) -> str: - if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{40}", value): - raise CollectionError("release SHA must be one full lowercase 40-character commit") - release_root = Path(__file__).resolve().parent.parent - release_metadata = release_root / "RELEASE.json" - configured_root = os.environ.get("SESSION_KIT_RELEASE_DIR") - if configured_root and Path(configured_root).resolve() != release_root: - raise CollectionError("executing release does not match SESSION_KIT_RELEASE_DIR") - if release_metadata.exists() or configured_root: - flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) - try: - descriptor = os.open(release_metadata, flags) - metadata = os.fstat(descriptor) - content = b"" - while True: - block = os.read(descriptor, 64 * 1024) - if not block: - break - content += block - except OSError as exc: - raise CollectionError("cannot verify executing immutable release") from exc - finally: - if "descriptor" in locals(): - os.close(descriptor) - try: - document = json.loads(content) - except (UnicodeDecodeError, ValueError) as exc: - raise CollectionError("executing RELEASE.json is invalid") from exc - if ( - not statmod.S_ISREG(metadata.st_mode) - or metadata.st_uid != os.geteuid() - or statmod.S_IMODE(metadata.st_mode) & 0o222 - or metadata.st_size > 64 * 1024 - or release_root.name != value - or not isinstance(document, Mapping) - or document.get("commit") != value - ): - raise CollectionError("release SHA is not bound to the executing immutable release") - return value +def update_recovery_state( + paths: Mapping[str, Path], + inventory: Mapping[str, Any], + *, + collection_start: int | None = None, +) -> None: + """Preserve exact pre-generation recovery data until explicitly resolved. + ``recovery-manifest.json`` is the latest nonempty generation. When a new + boot or daemon generation appears, the prior manifest is copied to + ``recovery-pending.json`` before the current manifest can advance. Empty + and partial post-reboot inventories therefore cannot erase the queue. + """ -def _daemon_start_epoch( - proc_root: Path, generation: Mapping[str, Any] -) -> float: - pid = generation.get("pid") - expected_ticks = generation.get("process_start_ticks") - if ( - not isinstance(pid, int) - or pid <= 0 - or not isinstance(expected_ticks, int) - or expected_ticks <= 0 - ): - raise CollectionError("current daemon generation is not exact") - try: - before = _proc_stat(proc_root / str(pid) / "stat") - lines = (proc_root / "stat").read_text( - encoding="utf-8", errors="replace" - ).splitlines() - boot_values = [ - int(line.split()[1]) - for line in lines - if len(line.split()) == 2 and line.split()[0] == "btime" - ] - ticks_per_second = int(os.sysconf("SC_CLK_TCK")) - after = _proc_stat(proc_root / str(pid) / "stat") - except (OSError, ValueError) as exc: - raise CollectionError("cannot prove current daemon wall-clock start") from exc - if ( - len(boot_values) != 1 - or boot_values[0] <= 0 - or ticks_per_second <= 0 - or before != after - or before[0] != pid - or before[2] != expected_ticks - ): - raise CollectionError("current daemon changed while proving continuity") - return boot_values[0] + (expected_ticks / ticks_per_second) + def locked_atomic_write(path: Path, payload: Any) -> None: + if path == paths["pending"]: + _state_io._require_pending_publication_lock(path) + atomic_write_json(path, payload) + + def locked_collection_write( + current_paths: Mapping[str, Path], + path: Path, + payload: Any, + *, + collection_start: int | None, + ) -> None: + if path == paths["pending"]: + _state_io._require_pending_publication_lock(path) + _state_io.write_collection_json( + current_paths, + path, + payload, + collection_start=collection_start, + ) + with _state_io.publication_lock(paths): + return _recovery.update_recovery_state( + paths, + inventory, + schema_version=SCHEMA_VERSION, + recovery_manifest=recovery_manifest, + read_state_json=_read_state_json, + has_recovery_entries=_has_recovery_entries, + generation_key=_generation_key, + atomic_write_json=locked_atomic_write, + collection_start=collection_start, + write_collection_json=( + locked_collection_write if collection_start is not None else None + ), + ) -def _legacy_identities(value: Mapping[str, Any]) -> dict[str, tuple[str, str]]: - sessions = value.get("sessions") - outside = value.get("outside_agents", {}) - if not isinstance(sessions, Mapping) or not sessions or outside: - raise CollectionError( - "legacy migration requires nonempty shpool sessions and no outside roots" + +def enqueue_lost_conversations( + paths: Mapping[str, Path], + inventory: Mapping[str, Any], + previous_inventory: Mapping[str, Any] | None, + *, + boot_id: str, + collection_start: int | None = None, + config: Mapping[str, Any] | None = None, +) -> list[str]: + """Queue conversations whose session vanished with the provider still in it.""" + diagnostics: list[str] = [] + + def locked_atomic_write(path: Path, payload: Any) -> None: + if path == paths["pending"]: + _state_io._require_pending_publication_lock(path) + atomic_write_json(path, payload) + + def locked_collection_write( + current_paths: Mapping[str, Path], + path: Path, + payload: Any, + *, + collection_start: int | None, + ) -> None: + if path == paths["pending"]: + _state_io._require_pending_publication_lock(path) + _state_io.write_collection_json( + current_paths, + path, + payload, + collection_start=collection_start, ) - result: dict[str, tuple[str, str]] = {} - uuids: set[str] = set() - for shpool_id, item in sessions.items(): - if not isinstance(shpool_id, str) or not shpool_id or not isinstance(item, Mapping): - raise CollectionError("legacy recovery manifest contains an invalid session") - provider = item.get("provider") - uuid = valid_uuid(item.get("uuid")) - if provider not in PROVIDERS or not uuid or uuid in uuids: - raise CollectionError("legacy recovery identities must be exact and unique") - result[shpool_id] = (provider, uuid) - uuids.add(uuid) - return result + publication_paths = ( + _state_paths(config) + if isinstance(config, Mapping) and config.get("state_dir") + else _state_paths({"state_dir": paths["pending"].parent}) + ) + with _state_io.publication_lock(publication_paths): + queued = _recovery.enqueue_lost_conversations( + inventory, + previous_inventory, + paths=paths, + schema_version=SCHEMA_VERSION, + boot_id=boot_id, + read_state_json=_read_state_json, + atomic_write_json=locked_atomic_write, + valid_recovery_state=_valid_recovery_state, + closed_on_purpose=_closed_on_purpose_reader( + config, diagnostic_sink=diagnostics + ), + now_unix_ms=int(time.time() * 1000), + collection_start=collection_start, + write_collection_json=( + locked_collection_write if collection_start is not None else None + ), + ) + for diagnostic in diagnostics: + print(f"session inventory: {diagnostic}", file=sys.stderr) + return queued -def _evidence_identities(value: Mapping[str, Any]) -> dict[str, tuple[str, str]]: - rows = value.get("sessions") - if not isinstance(rows, list) or not rows: - raise CollectionError("continuity evidence must contain a nonempty session list") - result: dict[str, tuple[str, str]] = {} - uuids: set[str] = set() - for item in rows: - if not isinstance(item, Mapping): - raise CollectionError("continuity evidence contains a non-object session") - shpool_id = item.get("shpool_name") - provider = item.get("provider") - uuid = valid_uuid(item.get("provider_uuid")) - if ( - not isinstance(shpool_id, str) - or not shpool_id - or provider not in PROVIDERS - or not uuid - or shpool_id in result - or uuid in uuids - ): - raise CollectionError("continuity evidence identities must be exact and unique") - result[shpool_id] = (provider, uuid) - uuids.add(uuid) - return result +def source_generation_key(boot_id: Any, generation: Any) -> str | None: + return _recovery.source_generation_key( + boot_id, + generation, + generation_key=_generation_key, + ) -def _current_identities( - inventory: Mapping[str, Any], -) -> tuple[dict[str, tuple[str, str]], set[tuple[str, str]]]: - sessions: dict[str, tuple[str, str]] = {} - all_roots: set[tuple[str, str]] = set() - seen_uuids: set[str] = set() - for item in inventory.get("sessions", ()): - identity = item.get("identity") - recovery = item.get("recovery") - shpool_id = item.get("shpool_id_raw") - provider = item.get("provider") - uuid = valid_uuid(identity.get("uuid")) if isinstance(identity, Mapping) else None - recovery_uuid = ( - valid_uuid(recovery.get("uuid")) if isinstance(recovery, Mapping) else None + +_ACTION_ENUM = re.compile(r"[a-z][a-z_]{0,31}") +_ACTION_SESSION = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,63}") + + +def _append_action_record( + path: Path, action: str, outcome: str, session: str | None = None +) -> dict[str, Any]: + """One line on the shared action log, in the shape every reader expects. + + Same file, same lock, and the same four keys the shell logger writes, plus + the session when the caller can name one. Records that fail the shape are + dropped by whichever writer next rewrites the file, so the shape is the + contract rather than a convention. + """ + if not _ACTION_ENUM.fullmatch(action) or not _ACTION_ENUM.fullmatch(outcome): + raise CollectionError("action log entries take lower-case words") + record: dict[str, Any] = { + "action": action, + "at_unix_ms": time.time_ns() // 1_000_000, + "outcome": outcome, + "schema_version": 1, + } + if session is not None: + if not _ACTION_SESSION.fullmatch(session): + raise CollectionError("action log session name is invalid") + record["session"] = session + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + lock_path = path.with_suffix(path.suffix + ".lock") + descriptor = os.open( + lock_path, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600 + ) + try: + os.fchmod(descriptor, 0o600) + fcntl.flock(descriptor, fcntl.LOCK_EX) + with open(path, "a", encoding="utf-8") as handle: + handle.write( + json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n" + ) + os.chmod(path, 0o600) + finally: + os.close(descriptor) + return record + + +def _closed_on_purpose_reader( + config: Mapping[str, Any] | None = None, + *, + diagnostic_sink: list[str] | None = None, + closed_ledger_keys: set[str] | None = None, +) -> Callable[[Any, Any], bool]: + """Honour a tombstone only while its closed-ledger row is findable. + + A close intent is a suppression record: it says crash recovery should not + offer the conversation. The closed-sessions row is the record that makes + that suppression safe. The two files can be replaced independently, so + the tombstone alone is never enough evidence. Validate the complete + ledger through the same streaming snapshot used by the lists, lazily on + the first tombstoned candidate, and fail open on every disagreement. + """ + settings = load_config() if config is None else config + try: + state_dir = _state_paths(settings)["root"] + intents = _lifecycle.load_close_intents(state_dir) + except (CollectionError, OSError): + # A tombstone store that cannot be read must not hide crash offers: + # failing open here shows MORE recovery work, never less. + return lambda provider, uuid: False + + ledger_keys: set[str] | None = closed_ledger_keys + ledger_error: str | None = None + reported: set[str] = set() + + def diagnose(message: str) -> None: + if diagnostic_sink is not None and message not in reported: + diagnostic_sink.append(message) + reported.add(message) + + def load_ledger_keys() -> set[str]: + nonlocal ledger_keys, ledger_error + if ledger_keys is not None: + return ledger_keys + ledger_keys = set() + try: + # ``closed_snapshot`` validates the complete ledger before it + # yields any rows and keeps row memory bounded for a large file. + with _closed_sessions.closed_snapshot(limit=None) as rows: + for row in rows: + provider = row.get("provider") + uuid = valid_uuid(row.get("uuid")) + if provider in PROVIDERS and uuid: + ledger_keys.add(f"{provider}:{uuid}") + except (CollectionError, OSError) as exc: + ledger_keys.clear() + ledger_error = str(exc) + return ledger_keys + + def closed(provider: Any, uuid: Any) -> bool: + tombstoned = _lifecycle.closed_on_purpose( + state_dir, + provider=provider, + uuid=uuid, + intents=intents, ) - if ( - not isinstance(shpool_id, str) - or provider not in PROVIDERS - or not uuid - or not isinstance(recovery, Mapping) - or recovery.get("available") is not True - or recovery.get("provider") != provider - or recovery_uuid != uuid - or shpool_id in sessions - or uuid in seen_uuids - ): - raise CollectionError("current shpool identities are not exact and unique") - sessions[shpool_id] = (provider, uuid) - all_roots.add((provider, uuid)) - seen_uuids.add(uuid) - outside = inventory.get("outside_agents", ()) - if outside: - # This one-time migration has no trusted historical evidence for - # outside roots, so including or excluding them would be an assumption. - raise CollectionError("legacy migration refuses current outside provider roots") - return sessions, all_roots + if not tombstoned: + return False + exact = valid_uuid(uuid) + key = f"{provider}:{exact}" if provider in PROVIDERS and exact else "" + if key and key in load_ledger_keys(): + return True + if ledger_error: + diagnose( + f"close-intent inconsistency for {key or 'an invalid conversation'}: " + "its tombstone was ignored because the closed-sessions ledger " + f"could not be completely validated ({ledger_error}); recovery " + "will keep offering the conversation" + ) + else: + diagnose( + f"close-intent inconsistency for {key or 'an invalid conversation'}: " + "a tombstone exists but its row is missing from the completely " + "validated closed-sessions ledger; recovery will keep offering " + "the conversation" + ) + return False + return closed -def _migration_context( - config: Mapping[str, Any], + +def flatten_pending( + value: Any, *, - legacy_bytes: bytes, - evidence_path: Path, - collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, - proc_root: Path | None = None, + config: Mapping[str, Any] | None = None, + closed_ledger_keys: set[str] | None = None, ) -> dict[str, Any]: - collect = collector or collect_live - try: - legacy = json.loads(legacy_bytes) - except (UnicodeDecodeError, ValueError) as exc: - raise CollectionError("legacy recovery manifest is invalid JSON") from exc - if ( - not isinstance(legacy, Mapping) - or not _has_recovery_entries(legacy) - or legacy.get("daemon_generation") is not None - or not isinstance(legacy.get("boot_id"), str) - or not legacy.get("boot_id") - ): - raise CollectionError("source is not a null-generation legacy manifest") - legacy_generated = _parse_utc_timestamp( - legacy.get("generated_at"), "legacy generated_at" - ) - evidence_bytes, evidence = _parse_private_json( - evidence_path, "continuity evidence" + """Return one safe recovery candidate per exact provider conversation.""" + diagnostics: list[str] = [] + flattened = _recovery.flatten_pending( + value, + schema_version=SCHEMA_VERSION, + valid_recovery_state=_valid_recovery_state, + source_generation_key=source_generation_key, + pending_preferred_entry=_pending_preferred_entry, + pending_conflict_fields=_pending_conflict_fields, + pending_evidence=_pending_evidence, + closed_on_purpose=_closed_on_purpose_reader( + config, + diagnostic_sink=diagnostics, + closed_ledger_keys=closed_ledger_keys, + ), ) - evidence_captured = _parse_utc_timestamp( - evidence.get("captured_at"), "continuity evidence captured_at" + if diagnostics: + flattened["diagnostics"] = diagnostics + return flattened + + +def list_pending( + config: Mapping[str, Any], *, closed_ledger_keys: set[str] | None = None +) -> dict[str, Any]: + return _recovery.list_pending( + config, + state_paths=_state_paths, + state_lock=StateLock, + read_state_json=_read_state_json, + # THIS config, not the ambient one: the tombstones that decide what is + # still recovery work live beside the queue being read. + flatten_pending=lambda value: flatten_pending( + value, config=config, closed_ledger_keys=closed_ledger_keys + ), ) - if evidence_captured > legacy_generated: - raise CollectionError("continuity evidence was captured after the legacy manifest") - legacy_ids = _legacy_identities(legacy) - if _evidence_identities(evidence) != legacy_ids: - raise CollectionError( - "continuity evidence does not exactly match every legacy identity" - ) - settings = dict(config) - live = collect(settings) - if not isinstance(live, Mapping): - raise CollectionError("collector returned a non-object inventory") - live = dict(live) - complete = bool(live.pop("_complete", True)) - if not complete or not strict_live_inventory(live): - raise CollectionError( - "legacy recovery migration requires a complete strict live inventory" - ) - detected = recovery_manifest(live) - detected_key = _generation_key(detected) - if ( - detected_key is None - or not _has_recovery_entries(detected) - or detected.get("boot_id") != legacy.get("boot_id") - ): - raise CollectionError( - "legacy and current manifests do not have one exact same-boot generation" - ) - generation = detected["daemon_generation"] - if evidence.get("daemon_pid") != generation.get("pid"): - raise CollectionError("current daemon PID does not match continuity evidence") - root = proc_root or Path(os.environ.get("SESSION_KIT_PROC_ROOT", "/proc")) - daemon_started = _daemon_start_epoch(root, generation) - if daemon_started > evidence_captured.timestamp(): - raise CollectionError("current daemon did not predate continuity evidence") - current_ids, all_current_roots = _current_identities(live) - if not set(current_ids).issubset(legacy_ids): - raise CollectionError("current inventory contains a root absent from legacy evidence") - reconciliation: list[dict[str, Any]] = [] - for shpool_id in sorted(legacy_ids, key=natural_name_key): - provider, uuid = legacy_ids[shpool_id] - current = current_ids.get(shpool_id) - if current is not None: - if current != (provider, uuid): - raise CollectionError( - f"current identity changed under legacy shpool ID {shpool_id!r}" - ) - disposition = "carried" - else: - if (provider, uuid) in all_current_roots: - raise CollectionError( - f"legacy identity moved from shpool ID {shpool_id!r}" - ) - disposition = "ended" - reconciliation.append( - { - "shpool_id": shpool_id, - "provider": provider, - "uuid": uuid, - "disposition": disposition, - } - ) - return { - "legacy": dict(legacy), - "legacy_sha256": _sha256(legacy_bytes), - "evidence_sha256": _sha256(evidence_bytes), - "evidence_captured_at": evidence_captured.isoformat().replace("+00:00", "Z"), - "daemon_started_at": dt.datetime.fromtimestamp( - daemon_started, dt.timezone.utc - ).isoformat(timespec="microseconds").replace("+00:00", "Z"), - "generation": dict(generation), - "target_manifest": detected, - "current_roots": [ - {"shpool_id": key, "provider": value[0], "uuid": value[1]} - for key, value in sorted(current_ids.items(), key=lambda item: natural_name_key(item[0])) - ], - "reconciliation": reconciliation, - } -def _plan_token(plan: Mapping[str, Any]) -> str: - body = dict(plan) - body.pop("plan_token", None) - return _sha256(_json_bytes(body)) +def _closed_keys(rows: Iterable[Mapping[str, Any]]) -> set[str]: + keys: set[str] = set() + snapshot_keys = getattr(rows, "conversation_keys", None) + if callable(snapshot_keys): + for provider, uuid in snapshot_keys(): + exact = valid_uuid(uuid) + if provider in PROVIDERS and exact: + keys.add(f"{provider}:{exact}") + return keys + for row in rows: + provider = row.get("provider") + uuid = valid_uuid(row.get("uuid")) + if provider in PROVIDERS and uuid: + keys.add(f"{provider}:{uuid}") + return keys -def plan_legacy_recovery_manifest( +def recovery_list_payload( config: Mapping[str, Any], - continuity_evidence: str | Path, - release_sha: str, *, - collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, - proc_root: Path | None = None, - now: float | None = None, + include_closed: bool = True, + include_projection_inputs: bool = False, + closed_rows: Iterable[dict[str, Any]] | None = None, + closed_ledger_keys: set[str] | None = None, ) -> dict[str, Any]: - """Create a read-only, content-addressed migration plan.""" - paths = _state_paths(config) - if paths["pending"].exists() or paths["pending"].is_symlink(): - raise CollectionError("legacy recovery migration refuses an existing pending queue") - source_before = _read_private_regular_bytes(paths["manifest"]) - evidence_path = Path(continuity_evidence).expanduser() - if not evidence_path.is_absolute(): - evidence_path = Path.cwd() / evidence_path - context = _migration_context( - config, - legacy_bytes=source_before, - evidence_path=evidence_path, - collector=collector, - proc_root=proc_root, - ) - if _read_private_regular_bytes(paths["manifest"]) != source_before: - raise CollectionError("legacy manifest changed while planning") - release = _release_sha(release_sha) - source_hash = context["legacy_sha256"] - archive = paths["root"] / f"recovery-manifest.legacy.{source_hash}.json" - target = context["target_manifest"] - plan: dict[str, Any] = { - "schema_version": SCHEMA_VERSION, - "plan_version": 1, - "action": "legacy-recovery-manifest-migration", - "created_at": _utc_now(now), - "release_sha": release, - "source_manifest": { - "path": str(paths["manifest"].resolve()), - "sha256": source_hash, - "boot_id": context["legacy"]["boot_id"], - "generated_at": context["legacy"]["generated_at"], - }, - "continuity_evidence": { - "path": str(evidence_path.resolve()), - "sha256": context["evidence_sha256"], - "captured_at": context["evidence_captured_at"], - "daemon_pid": context["generation"]["pid"], - "daemon_started_at": context["daemon_started_at"], - }, - "daemon_generation": context["generation"], - "current_roots": context["current_roots"], - "reconciliation": context["reconciliation"], - "target_manifest": target, - "target_manifest_sha256": _sha256(_json_bytes(target)), - "archive_path": str(archive.resolve()), - } - plan["plan_token"] = _plan_token(plan) - return plan + """The one list of conversations that can be brought back. + Every recovery surface reads this. It gathers the three stores that know + about a session that is no longer open, plus the inventory that says which + conversations are open right now, and hands them to one projection. The + two surfaces used to read two different stores and agree on almost + nothing; the number of lists is the fix, not the contents of either. + """ + if include_closed and (closed_rows is None or closed_ledger_keys is None): + with _closed_sessions.closed_snapshot( + limit=None, still_readable=_conversation_is_readable + ) as snapshot: + keys = _closed_keys(snapshot) + return recovery_list_payload( + config, + include_closed=include_closed, + include_projection_inputs=include_projection_inputs, + closed_rows=snapshot, + closed_ledger_keys=keys, + ) -def publish_legacy_migration_plan( - config: Mapping[str, Any], output: str | Path, plan: Mapping[str, Any] -) -> dict[str, Any]: - """Durably create a reviewed plan as a private, non-overwriting state file.""" paths = _state_paths(config) - destination = Path(output).expanduser() - if not destination.is_absolute(): - destination = Path.cwd() / destination - with StateLock(paths["root"], paths["lock"]): - if destination.parent.resolve() != paths["root"].resolve(): - raise CollectionError( - "migration plan output must be inside the owner-only state directory" - ) - if destination.exists() or destination.is_symlink(): - raise CollectionError(f"migration plan output already exists: {destination}") - content = _json_bytes(plan) - _write_legacy_manifest_backup(destination, content) - if _read_private_regular_bytes(destination) != content: - raise CollectionError("durable migration plan verification failed") - dispositions = { - "carried": sum( - item.get("disposition") == "carried" - for item in plan.get("reconciliation", ()) - if isinstance(item, Mapping) - ), - "ended": sum( - item.get("disposition") == "ended" - for item in plan.get("reconciliation", ()) - if isinstance(item, Mapping) - ), - } - return { + pending_payload = list_pending(config, closed_ledger_keys=closed_ledger_keys) + pending = pending_payload.get("entries") or [] + manifest = _read_state_json(paths["manifest"]) + manifest_sessions = ( + list((manifest.get("sessions") or {}).values()) + if isinstance(manifest, Mapping) + else [] + ) + live = _read_state_json(paths["inventory"]) + # Open is open. A conversation running outside the kit is as live as one + # inside it, and offering it for restore invites exactly the collision + # this list exists to prevent -- the picker refused those at the last + # moment and `sp restore` did not refuse them at all. + live_sessions = ( + list(live.get("sessions") or []) + list(live.get("outside_agents") or []) + if isinstance(live, Mapping) + else [] + ) + closed = list(closed_rows or ()) if include_closed else [] + aliases = canonical_aliases(config) + automatic_titles = canonical_automatic_titles(config) + numbers = _terminal_number_bindings(paths["terminal_numbers"]) + rows = _recovery_list.recovery_rows( + manifest_sessions=manifest_sessions, + closed_rows=closed, + pending_entries=pending, + live_sessions=live_sessions, + aliases=aliases, + automatic_titles=automatic_titles, + numbers=numbers, + # One readability rule for every store. The ledger asks it above; the + # crash manifest and the pending queue never asked it at all, so a + # conversation whose transcript is gone was offered as a full restore + # by two of the three sources and hidden by the third. + still_readable=_conversation_is_readable, + ) + payload = { "schema_version": SCHEMA_VERSION, - "result": "planned", - "plan": str(destination), - "plan_token": plan.get("plan_token"), - "release_sha": plan.get("release_sha"), - "source_manifest_sha256": plan.get("source_manifest", {}).get("sha256"), - "target_manifest_sha256": plan.get("target_manifest_sha256"), - "reconciliation": dispositions, + "generated_at": _utc_now(), + "entries": rows, } + if pending_payload.get("diagnostics"): + payload["diagnostics"] = list(pending_payload["diagnostics"]) + if include_projection_inputs: + # `sp recover` streams the potentially huge closed ledger separately. + # These are the bounded inputs needed to give each streamed row the + # same name, stable number and live-conversation exclusion as the one + # projection above, without serializing the ledger into one document. + payload["projection_inputs"] = { + "aliases": aliases, + "automatic_titles": automatic_titles, + "numbers": numbers, + "live": [ + [row.get("provider"), (row.get("identity") or {}).get("uuid")] + for row in live_sessions + if isinstance(row, Mapping) and isinstance(row.get("identity"), Mapping) + ], + } + return payload -def _validate_migration_plan( - plan: Mapping[str, Any], paths: Mapping[str, Path], release_sha: str -) -> None: - expected_top_level = { - "schema_version", - "plan_version", - "action", - "created_at", - "release_sha", - "source_manifest", - "continuity_evidence", - "daemon_generation", - "current_roots", - "reconciliation", - "target_manifest", - "target_manifest_sha256", - "archive_path", - "plan_token", - } - if ( - set(plan) != expected_top_level - or plan.get("schema_version") != SCHEMA_VERSION - or plan.get("plan_version") != 1 - or plan.get("action") != "legacy-recovery-manifest-migration" - or plan.get("release_sha") != _release_sha(release_sha) - or plan.get("plan_token") != _plan_token(plan) - or not isinstance(plan.get("source_manifest"), Mapping) - or not isinstance(plan.get("continuity_evidence"), Mapping) - or not isinstance(plan.get("target_manifest"), Mapping) - or not isinstance(plan.get("reconciliation"), list) - or not isinstance(plan.get("current_roots"), list) - or not isinstance(plan.get("daemon_generation"), Mapping) - ): - raise CollectionError("migration plan is invalid, altered, or for another release") - source = plan["source_manifest"] - evidence = plan["continuity_evidence"] - target = plan["target_manifest"] - if ( - set(source) != {"path", "sha256", "boot_id", "generated_at"} - or set(evidence) - != {"path", "sha256", "captured_at", "daemon_pid", "daemon_started_at"} - or source.get("path") != str(paths["manifest"].resolve()) - or not re.fullmatch(r"[0-9a-f]{64}", str(source.get("sha256", ""))) - or not re.fullmatch(r"[0-9a-f]{64}", str(evidence.get("sha256", ""))) - or plan.get("archive_path") - != str( - ( - paths["root"] - / f"recovery-manifest.legacy.{source.get('sha256')}.json" - ).resolve() +def _write_recovery_snapshot(*, config: Mapping[str, Any], allow_large: bool) -> None: + """Stream projection inputs and Closed rows from one ledger snapshot.""" + + with _closed_sessions.closed_snapshot( + limit=None, + still_readable=_conversation_is_readable, + allow_large=allow_large, + ) as snapshot: + keys = _closed_keys(snapshot) + projection = recovery_list_payload( + config, + include_closed=False, + include_projection_inputs=True, + closed_rows=snapshot, + closed_ledger_keys=keys, ) - or plan.get("target_manifest_sha256") - != _sha256(_json_bytes(target)) - or _generation_key(target) - != ( - source.get("boot_id"), - plan.get("daemon_generation", {}).get("pid"), - plan.get("daemon_generation", {}).get("process_start_ticks"), + json.dump( + {"recovery_projection": projection}, + sys.stdout, + ensure_ascii=False, + sort_keys=True, ) - or evidence.get("daemon_pid") - != plan.get("daemon_generation", {}).get("pid") - ): - raise CollectionError("migration plan paths or hashes are invalid") + sys.stdout.write("\n") + for row in snapshot: + json.dump(row, sys.stdout, ensure_ascii=False, sort_keys=True) + sys.stdout.write("\n") -def _receipt_path(paths: Mapping[str, Path]) -> Path: - return paths["root"] / "recovery-manifest-migration-receipt.json" +def _printed_rows_from(payload: str, *, json_lines: bool = False) -> Iterable[Any]: + """The rows a surface just printed, read back from what it printed them from. + A file when the picker passes the payload it drew from, standard input when + `sp recover` pipes back the same bytes it rendered. Never a fresh + projection: the whole point is to record the list that was SHOWN, and a + rebuild here would record a list nobody has seen and call it the screen. + """ + if json_lines: -def _migration_receipt( - plan: Mapping[str, Any], phase: str, *, now: float | None = None -) -> dict[str, Any]: - return { - "schema_version": SCHEMA_VERSION, - "action": "legacy-recovery-manifest-migration", - "phase": phase, - "updated_at": _utc_now(now), - "plan_token": plan["plan_token"], - "release_sha": plan["release_sha"], - "source_manifest_sha256": plan["source_manifest"]["sha256"], - "target_manifest_sha256": plan["target_manifest_sha256"], - "continuity_evidence_sha256": plan["continuity_evidence"]["sha256"], - "daemon_generation": plan["daemon_generation"], - "reconciliation": plan["reconciliation"], - "archive_path": plan["archive_path"], - } + def rows() -> Iterator[Any]: + handle = ( + Path(payload).open(encoding="utf-8") + if payload + else contextlib.nullcontext(sys.stdin) + ) + with handle as stream: + for number, line in enumerate(stream, 1): + if not line.strip(): + continue + try: + row = json.loads(line) + except ValueError as exc: + raise CollectionError( + f"recovery screen row {number} is invalid JSON" + ) from exc + if not isinstance(row, Mapping): + raise CollectionError( + f"recovery screen row {number} must be an object" + ) + yield row + return rows() + text = Path(payload).read_text(encoding="utf-8") if payload else sys.stdin.read() + if not text.strip(): + return [] + document = json.loads(text) + if not isinstance(document, Mapping): + raise CollectionError("a recovery screen payload must be a JSON object") + entries = document.get("entries") + return list(entries) if isinstance(entries, list) else [] -def _read_matching_receipt( - path: Path, plan: Mapping[str, Any], *, required: bool -) -> Mapping[str, Any] | None: - if not path.exists() and not path.is_symlink(): - if required: - raise CollectionError("migration receipt is missing") - return None - _, receipt = _parse_private_json(path, "migration receipt") - expected_fields = { - "schema_version", - "action", - "phase", - "updated_at", - "plan_token", - "release_sha", - "source_manifest_sha256", - "target_manifest_sha256", - "continuity_evidence_sha256", - "daemon_generation", - "reconciliation", - "archive_path", - } - _parse_utc_timestamp(receipt.get("updated_at"), "migration receipt updated_at") - if ( - set(receipt) != expected_fields - or receipt.get("schema_version") != SCHEMA_VERSION - or receipt.get("action") != "legacy-recovery-manifest-migration" - or receipt.get("plan_token") != plan.get("plan_token") - or receipt.get("release_sha") != plan.get("release_sha") - or receipt.get("source_manifest_sha256") - != plan["source_manifest"]["sha256"] - or receipt.get("target_manifest_sha256") - != plan.get("target_manifest_sha256") - or receipt.get("continuity_evidence_sha256") - != plan["continuity_evidence"]["sha256"] - or receipt.get("daemon_generation") != plan.get("daemon_generation") - or receipt.get("reconciliation") != plan.get("reconciliation") - or receipt.get("archive_path") != plan.get("archive_path") - or receipt.get("phase") not in {"archive-published", "applied", "rolled-back"} - ): - raise CollectionError("migration receipt does not match the reviewed plan") - return receipt +def _terminal_number_bindings(path: Path) -> dict[str, Any]: + """The conversation-to-number bindings, read without taking a lock. -def _revalidate_plan_context( + A listing verb never mutates, and the binding it needs outlives the + session: the registry is keyed by conversation, so a number survives the + close that ends its terminal. That is the whole reason a closed session + can still be shown under the number it has everywhere else. + """ + document = _read_state_json(path) + if not isinstance(document, Mapping): + return {} + bindings = document.get("bindings") + return dict(bindings) if isinstance(bindings, Mapping) else {} + + +def acknowledge_pending( config: Mapping[str, Any], - plan: Mapping[str, Any], - legacy_bytes: bytes, + generation_key: str, + old_shpool_id: str, + uuid: str, *, - collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None, - proc_root: Path | None, -) -> None: - evidence_path = Path(str(plan["continuity_evidence"]["path"])) - context = _migration_context( + collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Compare-and-ack one restored entry under the inventory state lock.""" + return _recovery.acknowledge_pending( config, - legacy_bytes=legacy_bytes, - evidence_path=evidence_path, + generation_key, + old_shpool_id, + uuid, collector=collector, - proc_root=proc_root, + schema_version=SCHEMA_VERSION, + state_paths=_state_paths, + state_lock=StateLock, + read_state_json=_read_state_json, + valid_recovery_state=_valid_recovery_state, + flatten_pending=lambda value: flatten_pending(value, config=config), + collect_live=collect_live, + strict_live_inventory=strict_live_inventory, ) - semantic_target = dict(context["target_manifest"]) - semantic_target["generated_at"] = plan["target_manifest"].get("generated_at") - if ( - context["legacy_sha256"] != plan["source_manifest"]["sha256"] - or context["evidence_sha256"] != plan["continuity_evidence"]["sha256"] - or context["generation"] != plan["daemon_generation"] - or context["current_roots"] != plan["current_roots"] - or context["reconciliation"] != plan["reconciliation"] - or semantic_target != plan["target_manifest"] - or _sha256(_json_bytes(plan["target_manifest"])) - != plan["target_manifest_sha256"] - ): - raise CollectionError("live state no longer matches the reviewed migration plan") -def apply_legacy_recovery_manifest( - config: Mapping[str, Any], - plan_path: str | Path, - release_sha: str, - *, - collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, - proc_root: Path | None = None, +def _cold_inventory(error: str) -> dict[str, Any]: + return _snapshot._cold_inventory( + error, + schema_version=SCHEMA_VERSION, + ) + + +def _apply_account_bindings( + inventory: dict[str, Any], config: Mapping[str, Any] ) -> dict[str, Any]: - """Apply or safely resume one reviewed legacy migration plan.""" - paths = _state_paths(config) - _, plan = _parse_private_json(Path(plan_path).expanduser(), "migration plan") - _validate_migration_plan(plan, paths, release_sha) - source_hash = plan["source_manifest"]["sha256"] - target_hash = plan["target_manifest_sha256"] - archive = Path(str(plan["archive_path"])) - receipt_path = _receipt_path(paths) - with StateLock(paths["root"], paths["lock"]): - if paths["pending"].exists() or paths["pending"].is_symlink(): - raise CollectionError("legacy recovery migration refuses an existing pending queue") - canonical = _read_private_regular_bytes(paths["manifest"]) - canonical_hash = _sha256(canonical) - receipt = _read_matching_receipt( - receipt_path, plan, required=canonical_hash == target_hash - ) - if receipt and receipt.get("phase") == "rolled-back": - raise CollectionError("reviewed migration plan was already rolled back") - if canonical_hash not in {source_hash, target_hash}: - raise CollectionError("recovery manifest no longer matches plan source or target") - if archive.exists() or archive.is_symlink(): - legacy_bytes = _read_private_regular_bytes(archive) - if _sha256(legacy_bytes) != source_hash: - raise CollectionError("legacy archive does not match the reviewed source") - else: - if canonical_hash != source_hash: - raise CollectionError("legacy archive is missing after target publication") - legacy_bytes = canonical - if canonical_hash == target_hash: - if receipt is None: - raise CollectionError("target manifest has no matching migration receipt") - if receipt.get("phase") != "applied": - atomic_write_json( - receipt_path, _migration_receipt(plan, "applied") - ) - return { - "schema_version": SCHEMA_VERSION, - "result": "already-applied", - "plan_token": plan["plan_token"], - "receipt": str(receipt_path), - } - _revalidate_plan_context( - config, - plan, - legacy_bytes, - collector=collector, - proc_root=proc_root, - ) - if not archive.exists(): - _write_legacy_manifest_backup(archive, canonical) - if _sha256(_read_private_regular_bytes(archive)) != source_hash: - raise CollectionError("durable legacy archive verification failed") - atomic_write_json( - receipt_path, _migration_receipt(plan, "archive-published") + """Project verified profile bindings onto exact rows without guessing.""" + if "state_dir" not in config: + return inventory + try: + registry = _accounts.load_registry(config) + except (CollectionError, OSError, ValueError): + registry = _accounts._empty_registry() + profiles = registry.get("profiles", {}) + bindings = registry.get("bindings", {}) + for item in inventory.get("sessions", ()): + if not isinstance(item, dict): + continue + provider = item.get("provider") + identity = item.get("identity") + exact = ( + valid_uuid(identity.get("uuid")) + if provider in PROVIDERS and isinstance(identity, Mapping) + else None ) - if _sha256(_read_private_regular_bytes(paths["manifest"])) != source_hash: - raise CollectionError("legacy manifest changed before migration publication") - atomic_write_json(paths["manifest"], plan["target_manifest"]) - if _sha256(_read_private_regular_bytes(paths["manifest"])) != target_hash: - raise CollectionError("target recovery manifest verification failed") - atomic_write_json(receipt_path, _migration_receipt(plan, "applied")) - return { - "schema_version": SCHEMA_VERSION, - "result": "applied", - "plan_token": plan["plan_token"], - "archive": str(archive), - "receipt": str(receipt_path), - } + live_alias = clean_text(item.get("account_alias"), 12) + if live_alias and f"{provider}:{live_alias}" not in profiles: + item.pop("account_alias", None) + live_alias = "" + live_profile = profiles.get(f"{provider}:{live_alias}") if live_alias else None + if live_alias and not isinstance(live_profile, Mapping): + item["account_binding_mismatch"] = True + item.pop("account_alias", None) + live_alias = "" + if isinstance(live_profile, Mapping): + item["account_email"] = clean_text(live_profile.get("email"), 254) + item["account_plan"] = clean_text(live_profile.get("plan"), 80) + binding = bindings.get(f"{provider}:{exact}") if exact else None + if live_alias and isinstance(binding, Mapping): + bound_profile = profiles.get(binding.get("profile")) + if isinstance(bound_profile, Mapping): + bound_alias = clean_text(bound_profile.get("alias"), 12) + if bound_alias != live_alias: + item["account_binding_mismatch"] = True + return inventory -def rollback_legacy_recovery_manifest( - config: Mapping[str, Any], - plan_path: str | Path, - release_sha: str, +def _apply_worktree_labels( + inventory: dict[str, Any], config: Mapping[str, Any] +) -> dict[str, Any]: + """Name the branch a session is isolated on, from the worktree registry. + + Display only, and matched on the session's own directory: a row says + "worktree p2/foo" because the kit recorded that directory as that branch's + worktree, never because a path looked like one. + """ + if "state_dir" not in config: + return inventory + try: + from sessionkit_inventory import worktrees as _worktrees + + registry = _worktrees.labels(config["state_dir"], os.environ) + except (CollectionError, OSError, ValueError, ImportError): + return inventory + if not registry: + return inventory + for group in ("sessions", "outside_agents"): + for item in inventory.get(group, ()): + if not isinstance(item, dict): + continue + label = registry.get(clean_text(item.get("cwd"), 4096)) + if label: + item["worktree"] = dict(label) + return inventory + + +def _apply_session_idle_states( + current: dict[str, Any], + previous: Mapping[str, Any] | None, *, - collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, - proc_root: Path | None = None, + state_dir: Path, ) -> dict[str, Any]: - """Restore exact legacy bytes while every plan and generation guard holds.""" - paths = _state_paths(config) - _, plan = _parse_private_json(Path(plan_path).expanduser(), "migration plan") - _validate_migration_plan(plan, paths, release_sha) - source_hash = plan["source_manifest"]["sha256"] - target_hash = plan["target_manifest_sha256"] - archive = Path(str(plan["archive_path"])) - receipt_path = _receipt_path(paths) - with StateLock(paths["root"], paths["lock"]): - if paths["pending"].exists() or paths["pending"].is_symlink(): - raise CollectionError("legacy migration rollback refuses an existing pending queue") - receipt = _read_matching_receipt(receipt_path, plan, required=True) - assert receipt is not None - legacy_bytes = _read_private_regular_bytes(archive) - if _sha256(legacy_bytes) != source_hash: - raise CollectionError("legacy archive does not match the reviewed source") - canonical = _read_private_regular_bytes(paths["manifest"]) - canonical_hash = _sha256(canonical) - if canonical_hash not in {source_hash, target_hash}: - raise CollectionError("rollback refuses a changed recovery manifest") - _revalidate_plan_context( - config, - plan, - legacy_bytes, - collector=collector, - proc_root=proc_root, - ) - if canonical_hash == source_hash: - if receipt.get("phase") != "rolled-back": - atomic_write_json( - receipt_path, _migration_receipt(plan, "rolled-back") - ) - return { - "schema_version": SCHEMA_VERSION, - "result": "already-rolled-back", - "plan_token": plan["plan_token"], - "receipt": str(receipt_path), - } - _atomic_write_private_bytes(paths["manifest"], legacy_bytes) - if _sha256(_read_private_regular_bytes(paths["manifest"])) != source_hash: - raise CollectionError("exact legacy rollback verification failed") - atomic_write_json(receipt_path, _migration_receipt(plan, "rolled-back")) - return { - "schema_version": SCHEMA_VERSION, - "result": "rolled-back", - "plan_token": plan["plan_token"], - "receipt": str(receipt_path), - } + _idle_state.apply_idle_evidence( + current, + previous, + state_dir=state_dir, + transcript_snapshot=lambda provider, uuid: _transcripts.transcript_snapshot( + provider, uuid + ), + ) + sessions = current.get("sessions") + if isinstance(sessions, list): + stall_seconds = _render.stall_threshold_seconds() + for row in sessions: + if isinstance(row, dict): + # ``publish_view_fields`` ran during collection, before the + # differential transcript overlay. Refresh the published + # machine field as well as the render-time answer so every + # consumer receives the new idle verdict. + row["state"] = _labels.session_state(row, stall_seconds=stall_seconds) + sessions.sort(key=_model.canonical_session_order_key) + for index, row in enumerate(sessions, start=1): + if isinstance(row, dict): + row["row"] = index + return current -def update_recovery_state(paths: Mapping[str, Path], inventory: Mapping[str, Any]) -> None: - """Preserve exact pre-generation recovery data until explicitly resolved. +def snapshot( + *, write_state: bool = True, config: dict[str, Any] | None = None +) -> dict[str, Any]: + settings = config if config is not None else load_config() + inventory = _snapshot.snapshot( + write_state=write_state, + config=settings, + schema_version=SCHEMA_VERSION, + load_config=load_config, + state_paths=_state_paths, + state_lock=StateLock, + read_state_json=_read_state_json, + collect_live=collect_live, + boot_id_factory=_boot_id, + persist_last_exact=_lifecycle.persist_last_exact, + enqueue_lost_conversations=enqueue_lost_conversations, + apply_session_origins=_origins.apply_session_origins, + capture_bounce_receipts=_origins.capture_bounce_receipts, + capture_bounce_cleanup_generations=( + _origins.capture_bounce_cleanup_generations + ), + capture_lifecycle_generations=(_lifecycle.capture_lifecycle_generations), + capture_origin_generations=_origins.capture_origin_generations, + capture_provider_untitled_generations=(capture_provider_untitled_generations), + capture_session_color_generations=capture_session_color_generations, + prune_origins=_origins.prune_origins, + publish_session_colors=publish_session_colors, + apply_provider_exit_states=_lifecycle.apply_provider_exit_states, + prune_inactive_state=_lifecycle.prune_inactive_state, + prune_model_cache=_session_model_reader.prune_cache, + apply_session_idle_states=_apply_session_idle_states, + read_terminal_registry=_read_terminal_registry, + read_terminal_retirements=_read_terminal_retirements, + apply_terminal_numbers=apply_terminal_numbers, + terminal_retirement_payload=_terminal_retirement_payload, + atomic_write_json=atomic_write_json, + allocate_collection_start=_state_io.allocate_collection_start, + preflight_collection_documents=_state_io.preflight_collection_documents, + write_collection_json=_state_io.write_collection_json, + quarantine_orphaned_provider_untitled_markers=( + _quarantine_orphaned_provider_untitled_markers + ), + update_recovery_state=update_recovery_state, + cold_inventory=_cold_inventory, + ) + return inventory - ``recovery-manifest.json`` is the latest nonempty generation. When a new - boot or daemon generation appears, the prior manifest is copied to - ``recovery-pending.json`` before the current manifest can advance. Empty - and partial post-reboot inventories therefore cannot erase the queue. - """ - detected = recovery_manifest(inventory) - existing = _read_state_json(paths["manifest"]) - pending = _read_state_json(paths["pending"]) - existing_valid = _has_recovery_entries(existing) - pending_valid = _has_recovery_entries(pending) - detected_key = _generation_key(detected) - existing_key = _generation_key(existing) if existing_valid else None - - # An unavailable/ambiguous daemon identity is not evidence of a restart. - # Do not advance or queue anything until both sides have exact generation - # keys; a later complete refresh can make the transition safely. - if detected_key is None or (existing_valid and existing_key is None): - return - if existing_valid and existing_key == detected_key: - existing_semantic = dict(existing) - detected_semantic = dict(detected) - existing_semantic.pop("generated_at", None) - detected_semantic.pop("generated_at", None) - if existing_semantic == detected_semantic: - # Timestamp-only refreshes must not invalidate an exact migration - # postimage or create needless state churn. - return +def _transcript_reachability(config: Mapping[str, Any]) -> dict[str, Any]: + """How many recorded conversations this machine can still read. - if existing_valid and existing_key != detected_key: - boot_changed = existing_key[0] != detected_key[0] - new_pending = { - "schema_version": SCHEMA_VERSION, - "generated_at": inventory.get("generated_at"), - "source_boot_id": existing.get("boot_id"), - "source_daemon_generation": existing.get("daemon_generation"), - "detected_boot_id": detected.get("boot_id"), - "detected_daemon_generation": detected.get("daemon_generation"), - "sessions": dict(existing["sessions"]), - # Outside-provider roots survive a daemon-only restart; they are - # recovery work only when the host boot itself changed. - "outside_agents": ( - dict(existing.get("outside_agents", {})) if boot_changed else {} + Read-only: the last published snapshot is read, never built, so a doctor + run neither talks to a provider nor writes state. A snapshot that cannot be + read is a warning about the check, never a verdict about the sessions. + """ + snapshot_path = _state_paths(config)["inventory"] + try: + payload = _state_io.read_private_json( + snapshot_path, max_bytes=8 * 1024 * 1024, allow_missing=True + ) + except (CollectionError, OSError): + payload = None + if not isinstance(payload, Mapping) or not isinstance( + payload.get("sessions"), list + ): + return { + "status": "warn", + "checked": 0, + "unreadable": [], + "detail": ( + f"no readable session list at {snapshot_path}, so nothing was checked" ), } - if pending_valid: - pending_source = ( - pending.get("source_boot_id"), - json.dumps(pending.get("source_daemon_generation"), sort_keys=True), - ) - new_source = ( - new_pending.get("source_boot_id"), - json.dumps(new_pending.get("source_daemon_generation"), sort_keys=True), - ) - if pending_source == new_source: - # Never shrink an unresolved queue during a partial restore. - new_pending["sessions"] = { - **new_pending["sessions"], - **dict(pending["sessions"]), - } - new_pending["outside_agents"] = { - **new_pending.get("outside_agents", {}), - **dict(pending.get("outside_agents", {})), - } - else: - # A second generation arrived before the first was reviewed. - # Keep the oldest queue primary and retain the later one - # explicitly instead of silently overwriting either. - new_pending = dict(pending) - queued = list(new_pending.get("queued_generations", ())) - candidate = { - "source_boot_id": existing.get("boot_id"), - "source_daemon_generation": existing.get("daemon_generation"), - "sessions": dict(existing["sessions"]), - "outside_agents": ( - dict(existing.get("outside_agents", {})) - if boot_changed - else {} - ), - } - candidate_key = ( - candidate["source_boot_id"], - json.dumps(candidate["source_daemon_generation"], sort_keys=True), - ) - existing_keys = { - ( - item.get("source_boot_id"), - json.dumps(item.get("source_daemon_generation"), sort_keys=True), - ) - for item in queued - if isinstance(item, Mapping) - } - if candidate_key not in existing_keys: - queued.append(candidate) - new_pending["queued_generations"] = queued - new_pending["detected_boot_id"] = detected.get("boot_id") - new_pending["detected_daemon_generation"] = detected.get( - "daemon_generation" - ) - if _has_recovery_entries(new_pending): - atomic_write_json(paths["pending"], new_pending) + recorded = str(payload.get("generated_at") or "an unknown time") + checked = 0 + unreadable: list[str] = [] + for row in payload["sessions"]: + if not isinstance(row, Mapping) or row.get("provider") not in PROVIDERS: + continue + identity = row.get("identity") + uuid = ( + valid_uuid(identity.get("uuid")) if isinstance(identity, Mapping) else None + ) + if not uuid: + continue + checked += 1 + if _transcripts.locate_transcript(str(row["provider"]), uuid) is None: + unreadable.append(_common.clean_text(row.get("title"), 60) or "a session") + if checked == 0: + detail = f"the list recorded at {recorded} holds no provider session" + status = "ok" + elif unreadable: + detail = ( + f"{len(unreadable)} of {checked} sessions in the list recorded at " + f"{recorded} have no transcript this machine can read: " + + "; ".join(unreadable[:5]) + + ("; and more" if len(unreadable) > 5 else "") + ) + status = "warn" + else: + detail = ( + f"all {checked} provider sessions in the list recorded at {recorded}" + " have a transcript this machine can read" + ) + status = "ok" + return { + "status": status, + "checked": checked, + "unreadable": unreadable, + "detail": detail, + } - # Never replace the latest nonempty exact manifest with an empty snapshot. - if detected["sessions"] or detected["outside_agents"]: - atomic_write_json(paths["manifest"], detected) +SESSION_COLOR_DIR = "session-color" -def source_generation_key(boot_id: Any, generation: Any) -> str | None: - if not isinstance(generation, Mapping): + +def _session_color_generation(row: Mapping[str, Any]) -> str | None: + shell = row.get("shpool_shell") + if not isinstance(shell, Mapping): return None - candidate = { - "boot_id": boot_id, - "daemon_generation": generation, - } - exact = _generation_key(candidate) - if exact is None: + pid = shell.get("pid") + ticks = shell.get("process_start_ticks") + if ( + isinstance(pid, bool) + or not isinstance(pid, int) + or isinstance(ticks, bool) + or not isinstance(ticks, int) + ): return None - return f"{exact[0]}:{exact[1]}:{exact[2]}" + identity = f"{row.get('shpool_id_raw')}:{pid}:{ticks}" + return hashlib.sha256(identity.encode()).hexdigest() -def flatten_pending(value: Any) -> dict[str, Any]: - """Return all pending generations as stable, directly actionable entries.""" - entries: list[dict[str, Any]] = [] - if not _valid_recovery_state(value): - return { - "schema_version": SCHEMA_VERSION, - "generated_at": None, - "entries": [], - } - generations: list[ - tuple[ - str, - int | None, - Any, - Any, - Mapping[str, Any], - Mapping[str, Any], - ] - ] = [ - ( - "primary", - None, - value.get("source_boot_id"), - value.get("source_daemon_generation"), - value.get("sessions", {}), - value.get("outside_agents", {}), - ) - ] - for index, queued in enumerate(value.get("queued_generations", ())): - if not isinstance(queued, Mapping) or not isinstance(queued.get("sessions"), Mapping): +def _published_color_generation(path: Path) -> tuple[str, str] | None: + try: + fields = path.read_text(encoding="utf-8", errors="strict").split() + except OSError: + return None + if len(fields) != 3 or not re.fullmatch(r"[0-9a-f]{64}", fields[2]): + return None + return (path.name, fields[2]) + + +def capture_session_color_generations( + state_dir: Path, +) -> frozenset[tuple[str, str]]: + directory = Path(state_dir) / SESSION_COLOR_DIR + try: + entries = list(directory.iterdir()) + except OSError: + return frozenset() + captured: set[tuple[str, str]] = set() + for path in entries: + try: + if path.is_symlink() or not path.is_file(): + continue + generation = _published_color_generation(path) + if generation is not None: + captured.add(generation) + except OSError: + continue + return frozenset(captured) + + +def publish_session_colors( + inventory: Mapping[str, Any], + *, + state_dir: Path, + retire_generations: Iterable[tuple[str, str]] = (), +) -> int: + """Write each live session's colour where its own shell can read it. + + The in-session prompt cannot afford to parse the inventory on every line, + so it used a colour constant, which is how one session came to be green in + the picker and a different colour in its own terminal. One tiny file per + session, rewritten only when the colour actually changes, costs the prompt + a single read and ends the second owner. + """ + directory = Path(state_dir) / SESSION_COLOR_DIR + written = 0 + live: set[str] = set() + rows = inventory.get("sessions") if isinstance(inventory, Mapping) else None + if not isinstance(rows, list): + return 0 + for row in rows: + if not isinstance(row, Mapping): continue - generations.append( - ( - "queued", - index, - queued.get("source_boot_id"), - queued.get("source_daemon_generation"), - queued["sessions"], - queued.get("outside_agents", {}) - if isinstance(queued.get("outside_agents", {}), Mapping) - else {}, - ) - ) - for ( - queue, - queue_index, - boot_id, - generation, - sessions, - outside_agents, - ) in generations: - key = source_generation_key(boot_id, generation) - scoped_entries = ( - ("shpool", sessions), - ("outside", outside_agents), - ) - for scope, stored_entries in scoped_entries: - for old_shpool_id, raw_session in stored_entries.items(): - if not isinstance(old_shpool_id, str) or not isinstance( - raw_session, Mapping - ): - continue - uuid = valid_uuid(raw_session.get("uuid")) - provider = raw_session.get("provider") - entries.append( - { - "source_generation_key": key, - "source_boot_id": boot_id, - "source_daemon_generation": generation, - "queue": queue, - "queue_index": queue_index, - "scope": scope, - "old_shpool_id": old_shpool_id, - "display_old_shpool_id": display_shpool_id(old_shpool_id), - "provider": provider if provider in PROVIDERS else None, - "uuid": uuid, - "title": clean_text(raw_session.get("title"), 120), - "cwd": clean_text(raw_session.get("cwd"), 4096) or None, - "argv": list(raw_session.get("argv", ())) - if isinstance(raw_session.get("argv"), list) - else [], - "command": clean_text(raw_session.get("command"), 8192) - or None, - "actionable": bool(key and uuid and provider in PROVIDERS), - } - ) - entries.sort( - key=lambda item: ( - item["source_generation_key"] or "", - natural_name_key(item["old_shpool_id"]), - item["uuid"] or "", - ) - ) - return { - "schema_version": SCHEMA_VERSION, - "generated_at": value.get("generated_at"), - "detected_boot_id": value.get("detected_boot_id"), - "detected_daemon_generation": value.get("detected_daemon_generation"), - "entries": entries, - } + shpool_id = row.get("shpool_id_raw") + color = row.get("display_color") + if not isinstance(shpool_id, str) or not shpool_id or "/" in shpool_id: + continue + code = _colors.SESSION_SGR.get(color if isinstance(color, str) else "") + if not code: + continue + live.add(shpool_id) + generation = _session_color_generation(row) + line = f"{color} {code}{f' {generation}' if generation else ''}\n" + path = directory / shpool_id + try: + if path.is_file() and path.read_text(encoding="utf-8") == line: + continue + _state_io.ensure_private_directory(directory) + temporary = directory / f".{shpool_id}.{os.getpid()}" + temporary.write_text(line, encoding="utf-8") + os.chmod(temporary, 0o600) + os.replace(temporary, path) + written += 1 + except OSError: + continue + # A colour file outliving its session would paint the next session that + # reuses the name. They go when the session does. + allowed = frozenset(retire_generations) + try: + for path in directory.iterdir(): + if path.name.startswith(".") or path.name in live: + continue + if _published_color_generation(path) not in allowed: + continue + with contextlib.suppress(OSError): + path.unlink() + except OSError: + pass + return written -def list_pending(config: Mapping[str, Any]) -> dict[str, Any]: - paths = _state_paths(config) - with StateLock(paths["root"], paths["lock"]): - return flatten_pending(_read_state_json(paths["pending"])) +def _conversation_is_readable(provider: str, uuid: str) -> bool: + """True while this machine can still READ that conversation's record. + Locating the file is not reading it. A transcript that exists with mode + 000 -- a botched chmod, a restore from an archive that lost its bits, a + file owned by another account -- was located happily and then reported as + `restorable: true`, which is a promise the kit cannot keep and which the + person only discovers when the restore fails. Found in review 2026-08-15, + together with the close path that trusted the same answer. -def _remove_pending_entry( - pending: dict[str, Any], - *, - queue: str, - queue_index: int | None, - scope: str, - old_shpool_id: str, -) -> None: - container = "outside_agents" if scope == "outside" else "sessions" - if queue == "primary": - sessions = pending.get(container) - if isinstance(sessions, dict): - sessions.pop(old_shpool_id, None) - return - queued = pending.get("queued_generations") - if not isinstance(queued, list) or not isinstance(queue_index, int): - raise CollectionError("pending queue changed during acknowledgment") - if queue_index < 0 or queue_index >= len(queued): - raise CollectionError("pending generation changed during acknowledgment") - generation = queued[queue_index] - if not isinstance(generation, dict) or not isinstance( - generation.get(container), dict - ): - raise CollectionError("pending generation is malformed") - generation[container].pop(old_shpool_id, None) - if not generation.get("sessions") and not generation.get("outside_agents"): - queued.pop(queue_index) + So the file is opened. One byte is enough: an empty transcript is a real + conversation that has not been written to yet, while EACCES, EISDIR or a + vanished path are all "this cannot come back". + """ + found = _transcripts.locate_transcript(provider, uuid) + if found is None: + return False + try: + with open(found, "rb") as handle: + handle.read(1) + except OSError: + return False + return True -def acknowledge_pending( +def _record_closed_session( config: Mapping[str, Any], - generation_key: str, - old_shpool_id: str, - uuid: str, *, - collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, + provider: str, + uuid: str = "", + shpool_id: str = "", + title: str = "", + cwd: str = "", + origin: str = "", + account_alias: str = "", ) -> dict[str, Any]: - """Compare-and-ack one restored entry under the inventory state lock.""" - exact_uuid = valid_uuid(uuid) - if not generation_key or not old_shpool_id or not exact_uuid: - raise CollectionError("pending ack requires generation key, old shpool ID, and exact UUID") - paths = _state_paths(config) - with StateLock(paths["root"], paths["lock"]): - pending_raw = _read_state_json(paths["pending"]) - if not _valid_recovery_state(pending_raw): - raise CollectionError("no valid pending recovery queue exists") - pending = json.loads(json.dumps(pending_raw)) - flattened = flatten_pending(pending) - matches = [ - item - for item in flattened["entries"] - if item["source_generation_key"] == generation_key - and item["old_shpool_id"] == old_shpool_id - and item["uuid"] == exact_uuid - and item["actionable"] - ] - if len(matches) != 1: - raise CollectionError("pending entry no longer matches generation, shpool ID, and UUID") - target = matches[0] - - settings = dict(config) - live = ( - collector(settings) - if collector is not None - else collect_live(settings) + """Append one deliberate close, filling in what the caller did not know.""" + title_source = "" + if not title or not cwd or not origin: + try: + cached = _read_state_json(_state_paths(config)["inventory"]) + except (CollectionError, OSError): + cached = None + known = _closed_sessions.entry_from_inventory( + cached if isinstance(cached, Mapping) else None, + provider=provider, + uuid=uuid, + shpool_id=shpool_id, ) - complete = bool(live.pop("_complete", True)) - if not complete or not strict_live_inventory(live): - raise CollectionError("strict live inventory unavailable; pending entry was not acknowledged") - active_matches = [ - item - for item in [*live.get("sessions", ()), *live.get("outside_agents", ())] - if item.get("provider") == target["provider"] - and item.get("identity", {}).get("uuid") == exact_uuid - and item.get("identity", {}).get("confidence") == "exact" - ] - if len(active_matches) != 1: - raise CollectionError( - "exact pending provider UUID is not uniquely active; pending entry was not acknowledged" - ) - _remove_pending_entry( - pending, - queue=target["queue"], - queue_index=target["queue_index"], - scope=target["scope"], - old_shpool_id=old_shpool_id, + # Only a title read from the row carries that row's provenance. One + # the caller passed in is theirs, and nothing here knows where it + # came from -- so it is recorded without a source rather than under + # somebody else's. + if not title: + title_source = known["title_source"] + title = title or known["title"] + cwd = cwd or known["cwd"] + origin = origin or known["origin"] + account_alias = account_alias or known["account_alias"] + try: + return _closed_sessions.record_close( + provider=provider, + uuid=uuid, + title=title, + title_source=title_source, + cwd=cwd, + # "unknown", never "human". This row is provenance, it outlives + # the session, and a session nobody stamped has none to record; a + # default of "human" turns that absence into a positive claim that + # every future restore reads back as fact. + origin=origin or _closed_sessions.UNKNOWN_ORIGIN, + shpool_id=shpool_id, + account_alias=account_alias, ) - pending["updated_at"] = _utc_now() - atomic_write_json(paths["pending"], pending) + except (CollectionError, OSError) as exc: + # The close already happened. A ledger that cannot be written costs + # the person a listing, never the close they asked for. return { - "schema_version": SCHEMA_VERSION, - "acknowledged": target, - "remaining": flatten_pending(pending), + "recorded": False, + "provider": provider, + "uuid": uuid, + "reason": str(exc), } -def _cold_inventory(error: str) -> dict[str, Any]: - return { - "schema_version": SCHEMA_VERSION, - "generated_at": _utc_now(), - "source": "cold", - "stale": True, - "warnings": [clean_text(error, 400)], - "daemon_generation": None, - "sessions": [], - "outside_agents": [], - } +def _short_path(path: str) -> str: + return _render._short_path(path, home_factory=_home) -def snapshot(*, write_state: bool = True, config: dict[str, Any] | None = None) -> dict[str, Any]: - settings = config or load_config() - paths = _state_paths(settings) - if write_state: - with StateLock(paths["root"], paths["lock"]): - cached = _read_state_json(paths["inventory"]) - else: - # --no-write is also useful in deployment validation. Do not create a - # state directory or lock file merely to inspect existing state. - cached = _read_state_json(paths["inventory"]) - try: - live = collect_live(settings) - except CollectionError as exc: - live = None - failure = str(exc) - else: - failure = "; ".join(live.get("warnings", ())) - complete = bool(live and live.pop("_complete", True)) - if complete: - result = live - try: - boot_id = _boot_id() - if not boot_id: - raise CollectionError("current boot identity is unavailable") - if write_state: - with StateLock(paths["root"], paths["lock"]): - locked_cached = _read_state_json(paths["inventory"]) - _lifecycle.persist_last_exact( - result, - ( - locked_cached - if isinstance(locked_cached, Mapping) - else None - ), - state_dir=paths["root"], - boot_id=boot_id, - ) - _lifecycle.apply_provider_exit_states( - result, - ( - locked_cached - if isinstance(locked_cached, Mapping) - else None - ), - state_dir=paths["root"], - boot_id=boot_id, - ) - registry = _read_terminal_registry( - paths["terminal_numbers"], - boot_id, - paths["terminal_numbers_epoch"], - ) - updated_registry = apply_terminal_numbers( - result, registry, boot_id=boot_id, allocate=True - ) - atomic_write_json( - paths["terminal_numbers"], updated_registry - ) - atomic_write_json( - paths["terminal_numbers_epoch"], - { - "schema_version": SCHEMA_VERSION, - "boot_id": boot_id, - }, - ) - atomic_write_json(paths["inventory"], result) - update_recovery_state(paths, result) - _lifecycle.prune_inactive_state( - paths["root"], - [ - item["shpool_id_raw"] - for item in result.get("sessions", ()) - if isinstance(item, Mapping) - and isinstance(item.get("shpool_id_raw"), str) - ], - ) - else: - _lifecycle.apply_provider_exit_states( - result, - cached if isinstance(cached, Mapping) else None, - state_dir=paths["root"], - boot_id=boot_id, - ) - registry = _read_terminal_registry( - paths["terminal_numbers"], - boot_id, - paths["terminal_numbers_epoch"], - ) - apply_terminal_numbers( - result, registry, boot_id=boot_id, allocate=False - ) - except CollectionError as exc: - failure = str(exc) - warnings = list(result.get("warnings", ())) - warnings.append(f"terminal numbering unavailable: {failure}") - result["warnings"] = warnings - complete = False - if complete: - return result - if isinstance(cached, Mapping) and cached.get("schema_version") == SCHEMA_VERSION: - result = dict(cached) - result["source"] = "cache" - result["stale"] = True - warnings = list(result.get("warnings", ())) - warnings.append(f"live refresh failed; showing last-known-good: {failure}") - result["warnings"] = warnings - return result - if live is not None: - live["source"] = "cold" - live["stale"] = True - return live - return _cold_inventory(f"inventory unavailable and no last-known-good cache exists: {failure}") - - -def _format_age(seconds: int | None) -> str: - if seconds is None: - return "" - minutes = seconds // 60 - if minutes < 60: - return f"{minutes}m" - hours, minutes = divmod(minutes, 60) - if hours < 24: - return f"{hours}h {minutes}m" - days, hours = divmod(hours, 24) - return f"{days}d {hours}h" +def render_inventory(inventory: Mapping[str, Any], rows_only: bool = False) -> str: + return _render.render_inventory( + inventory, + rows_only, + color_enabled=_color_enabled, + ) -def _short_path(path: str) -> str: - home = str(_home()) - return f"~{path[len(home):]}" if path == home or path.startswith(home + os.sep) else path - - -def _display_width(value: str) -> int: - """Conservative terminal-cell width without adding a runtime dependency.""" - return sum( - 0 - if unicodedata.combining(character) - else 2 - if unicodedata.east_asian_width(character) in {"W", "F"} - else 1 - for character in value - ) - - -def _display_title(value: Any, limit: int = 48) -> str: - """Control-safe, terminal-cell-aware truncation for display only.""" - text = clean_text(value, 10000) - if _display_width(text) <= limit: - return text - if limit <= 1: - return "…" - kept: list[str] = [] - used = 0 - for character in text: - cells = _display_width(character) - if used + cells > limit - 1: - break - kept.append(character) - used += cells - return f"{''.join(kept)}…" - - -def _color_enabled() -> bool: - return ( - sys.stdout.isatty() - and not os.environ.get("NO_COLOR") - and not os.environ.get("SESSION_KIT_NO_COLOR") +def render_detail( + inventory: Mapping[str, Any], + selector: str, + *, + state_dir: Path | str | None = None, +) -> str: + """One session in full, for a person to read. + + `state_dir` is still accepted so every caller keeps working; the activity + line it used to feed came from the retired attention queue, and a detail + view now renders from the inventory row alone. + """ + return _render.render_detail( + inventory, + selector, + home_factory=_home, + activity=None, + now_ms=int(time.time() * 1000), ) -DEFAULT_STALL_SECONDS = 2700 +# The fleet flagger writes one file; these are the same bounds the picker reads +# it with. A hostile or corrupt file must never hide the rest of the estate and +# must never stall a login. +FLEET_STALLS_MAX_BYTES = 262_144 +FLEET_STALLS_FRESH_SECONDS = 300 +FLEET_STALLS_MAX_RECORDS = 200 -def stall_threshold_seconds() -> int: - """Silence after which a session stops being described as running. +def _fleet_state_dir() -> Path: + """Where the fleet keeps its own state. SESSION_KIT_FLEET_DIR overrides.""" + override = os.environ.get("SESSION_KIT_FLEET_DIR") + if override: + return Path(override).expanduser() + return _home() / ".local" / "state" / "fleet" - Deliberately far above any normal pause. Codex sessions report "running" - for their whole life, working or waiting, so a short threshold would label - every parked session and the warning would stop meaning anything. Override - with SESSION_KIT_STALL_SECONDS. - """ - return _positive_int( - os.environ.get("SESSION_KIT_STALL_SECONDS"), - DEFAULT_STALL_SECONDS, - 60, - 86400, - ) +def read_fleet_stalls(*, now: float | None = None) -> dict[str, list[str]]: + """Stall flags the fleet raised, as {identifier: reasons}. -def render_inventory(inventory: Mapping[str, Any], rows_only: bool = False) -> str: - """Render action-first/provider groups with sequential visible row numbers.""" - color = _color_enabled() - bold = "\033[1m" if color else "" - dim = "\033[2m" if color else "" - cyan = "\033[36m" if color else "" - green = "\033[32m" if color else "" - yellow = "\033[33m" if color else "" - reset = "\033[0m" if color else "" - # Truecolor values equal to what Codex RENDERS for each sk-<color> theme - # accent (measured from captured frames — Codex contrast-adjusts the raw - # theme hex), so a session's name is pixel-identical here and in its own - # Codex status bar. Remeasure if the theme anchors ever change. - session_palette = ( - { - "red": "\033[38;2;237;93;93m", - "blue": "\033[38;2;97;166;240m", - "green": "\033[38;2;63;221;115m", - "yellow": "\033[38;2;249;215;108m", - "purple": "\033[38;2;173;115;239m", - "orange": "\033[38;2;242;144;81m", - "pink": "\033[38;2;240;113;177m", - "cyan": "\033[38;2;64;216;209m", - } - if color - else {} - ) - - def tint(item: Mapping[str, Any], text: str) -> str: - code = session_palette.get(item.get("display_color") or "") - return f"{code}{text}{reset}" if code else text - terminal_columns = shutil.get_terminal_size(fallback=(101, 24)).columns - columns = _positive_int( - os.environ.get("COLUMNS"), - max(2, min(240, terminal_columns)), - 2, - 240, - ) - width = max(1, columns - 1) - lines: list[str] = [] - if inventory.get("stale"): - lines.append(f"{yellow} Warning: showing {inventory.get('source')} inventory.{reset}") - sessions = list(inventory.get("sessions", ())) - has_terminal_numbers = any( - isinstance(item, Mapping) - and isinstance(item.get("terminal_number"), int) - and not isinstance(item.get("terminal_number"), bool) - and item.get("terminal_number", 0) > 0 - for item in sessions - ) - available_count = sum( - 1 for item in sessions if item.get("availability") == "ready" - ) - attached_count = sum( - 1 for item in sessions if item.get("availability") == "attached" - ) - session_word = "session" if len(sessions) == 1 else "sessions" - lines.append( - f" {len(sessions)} {session_word}: {available_count} available, " - f"{attached_count} open in another window" - ) - if sessions: - lines.append("") - for availability in ("ready", "attached"): - selected = [item for item in sessions if item.get("availability") == availability] - if not selected: + Only the three reasons `docs/voice.md` calls degrees of **needs you** are + returned. `orphan` is the fourth reason the flagger can write and is + deliberately dropped: an orphaned session is a dead session, not a person's + turn, and folding it in would make the count lie. A stale file (the flagger + stopped running) is no evidence at all and reads as no flags. + """ + path = _fleet_state_dir() / "stalls.json" + try: + if path.stat().st_size > FLEET_STALLS_MAX_BYTES: + return {} + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + if not isinstance(payload, Mapping): + return {} + try: + generated_at = float(payload.get("generated_at") or 0) + except (TypeError, ValueError): + return {} + moment = time.time() if now is None else now + if moment - generated_at >= FLEET_STALLS_FRESH_SECONDS: + return {} + records = payload.get("stalled") + records = records if isinstance(records, list) else [] + flags: dict[str, list[str]] = {} + for record in records[:FLEET_STALLS_MAX_RECORDS]: + if not isinstance(record, Mapping): continue - heading = ( - "Available to open" - if availability == "ready" - else "Already open in another window" - ) - lines.append(f" {bold}{heading}{reset}") - for provider in ("claude", "codex", "shell", "unknown"): - # Group by the display provider so this view agrees with the - # chooser: a started-but-unused Codex session belongs under Codex, - # not under Unknown, even though its identity is not yet resolvable. - group = [ - item - for item in selected - if (item.get("display_provider") or item.get("provider")) == provider - ] - if not group: - continue - lines.append(f" {cyan}{provider.title()}{reset}") - for item in group: - process_age = _format_age(item.get("process_age_seconds")) - recent_output_age = _format_age( - item.get("recent_output_age_seconds") - ) - status = clean_text(item.get("agent_status") or "unknown", 64) - recent_seconds = item.get("recent_output_age_seconds") - # "running" is not evidence for Codex, which reports it whether - # it is working or waiting. State the silence instead. - if ( - status.casefold() == "running" - and isinstance(recent_seconds, int) - and not isinstance(recent_seconds, bool) - and recent_seconds >= stall_threshold_seconds() - and recent_output_age - ): - status = f"quiet {recent_output_age}" - status_parts = ( - ["needs your reply"] - if item.get("needs_you") - else [status] - ) - if recent_output_age: - status_parts.append(f"recent output {recent_output_age} ago") - if process_age: - status_parts.append(f"process age {process_age}") - agent_count = len(item.get("subagents", ())) - if agent_count: - status_parts.append( - f"{agent_count} subagent{'s' if agent_count != 1 else ''}" - ) - selector = item.get("terminal_number") - if ( - isinstance(selector, bool) - or not isinstance(selector, int) - or selector <= 0 - ): - selector = "-" if has_terminal_numbers else item.get("row") - prefix = f" {selector:>2} " - title_room = max(1, width - _display_width(prefix)) - title = _display_title(item.get("title"), title_room) - lines.append( - f" {green}{selector:>2}{reset} {tint(item, title)}" - ) - detail_prefix = " " - detail_room = max(1, width - _display_width(detail_prefix)) - details = _display_title(" | ".join(status_parts), detail_room) - detail_color = ( - yellow - if item.get("needs_you") - or status.casefold() == "provider exited" - else dim - ) - lines.append(f"{detail_prefix}{detail_color}{details}{reset}") - outside = list(inventory.get("outside_agents", ())) - if outside: - lines.append(f" {bold}Outside shpool{reset}") - for item in outside: - uuid = item.get("identity", {}).get("uuid") or "" - agent_count = len(item.get("subagents", ())) - prefix = f" - [{item.get('provider')}] " - title = _display_title( - item.get("title"), max(1, width - _display_width(prefix)) - ) - lines.append(f"{prefix}{tint(item, title)}") - detail_parts = [clean_text(item.get("agent_status") or "unknown", 64)] - if uuid: - detail_parts.append(uuid[:8]) - if agent_count: - detail_parts.append( - f"{agent_count} subagent{'s' if agent_count != 1 else ''}" - ) - detail_prefix = " " - details = _display_title( - " | ".join(detail_parts), - max(1, width - _display_width(detail_prefix)), - ) - lines.append(f"{detail_prefix}{dim}{details}{reset}") - if not sessions and not outside: - lines.append(" No sessions found.") - if not rows_only: - lines.extend( - [ - "", - f" {dim}sp go <n>: open | sp new: start{reset}", - f" {dim}sp close <n>: close | sp find \"text\": search history{reset}", - ] - ) - return "\n".join(lines) + key = clean_text(record.get("key"), 200) + reason = clean_text(record.get("reason"), 32).casefold() + if not key or reason not in _labels.STALL_REASONS_NEEDS_YOU: + continue + reasons = flags.setdefault(key, []) + if reason not in reasons: + reasons.append(reason) + return flags -def lookup(inventory: Mapping[str, Any], selector: str) -> dict[str, Any] | None: - matches: list[dict[str, Any]] = [] - if selector.isdigit(): - number = int(selector) - sessions = list(inventory.get("sessions", ())) - if any( - isinstance(item.get("terminal_number"), int) - and not isinstance(item.get("terminal_number"), bool) - and item.get("terminal_number", 0) > 0 - for item in sessions - if isinstance(item, Mapping) - ): - matches = [ - item - for item in sessions - if item.get("terminal_number") == number - ] - else: - matches = [item for item in sessions if item.get("row") == number] - else: - matches = [ - item - for item in inventory.get("sessions", ()) - if (item.get("shpool_id_raw") or item.get("shpool_id")) == selector - ] - return matches[0] if len(matches) == 1 else None +def _stall_identifiers(row: Mapping[str, Any]) -> tuple[str, ...]: + """Every identifier the flagger could have keyed this row by. + It keys by the first of conversation uuid, shpool id, title that it finds, + against an inventory it read at its own moment. Matching any of them keeps + a flag attached to its session when a later collection resolves an identity + the flagger did not have. + """ + identity = row.get("identity") + identity = identity if isinstance(identity, Mapping) else {} + candidates = ( + identity.get("uuid"), + row.get("uuid"), + row.get("shpool_id_raw"), + row.get("shpool_id"), + row.get("title"), + ) + seen: list[str] = [] + for candidate in candidates: + if isinstance(candidate, str) and candidate and candidate not in seen: + seen.append(candidate) + return tuple(seen) -def strict_live_inventory(inventory: Mapping[str, Any]) -> bool: - """True only for a complete, unambiguous live mutation guard snapshot.""" - if not guard_live_inventory(inventory): - return False - generation = inventory.get("daemon_generation") - outside_agents = inventory.get("outside_agents") - if ( - inventory.get("source") != "live" - or inventory.get("stale") is not False - or inventory.get("warnings") - or not isinstance(generation, Mapping) - or not isinstance(generation.get("pid"), int) - or generation.get("pid", 0) <= 0 - or not isinstance(generation.get("process_start_ticks"), int) - or generation.get("process_start_ticks", 0) <= 0 - or not isinstance(outside_agents, list) - ): - return False - exact_provider_uuids: set[tuple[str, str]] = set() - for item in inventory.get("sessions", ()): - identity = item.get("identity") - shell = item.get("shpool_shell") - raw_id = item.get("shpool_id_raw") - provider = item.get("provider") - mutation_allowed, mutation_reason = shpool_id_mutation_policy(raw_id) - if ( - not isinstance(raw_id, str) - or raw_id != item.get("shpool_id") - or item.get("mutation_allowed") is not mutation_allowed - or item.get("mutation_rejection_reason") != mutation_reason - or provider not in {"claude", "codex", "shell"} - or not isinstance(identity, Mapping) - or identity.get("confidence") != "exact" - or not isinstance(identity.get("pid"), int) - or not isinstance(identity.get("process_start_ticks"), int) - or not isinstance(shell, Mapping) - or not isinstance(shell.get("pid"), int) - or not isinstance(shell.get("process_start_ticks"), int) - ): - return False - if provider in PROVIDERS: - uuid = identity.get("uuid") - key = (provider, uuid) - if valid_uuid(uuid) != uuid or key in exact_provider_uuids: - return False - exact_provider_uuids.add(key) - elif identity.get("uuid") is not None: - return False - for item in outside_agents: - if not isinstance(item, Mapping): - return False - provider = item.get("provider") - identity = item.get("identity") - if ( - provider not in PROVIDERS - or not isinstance(identity, Mapping) - or identity.get("confidence") != "exact" - or isinstance(identity.get("pid"), bool) - or not isinstance(identity.get("pid"), int) - or identity.get("pid", 0) <= 0 - or isinstance(identity.get("process_start_ticks"), bool) - or not isinstance(identity.get("process_start_ticks"), int) - or identity.get("process_start_ticks", 0) <= 0 - ): - return False - uuid = identity.get("uuid") - key = (provider, uuid) - if valid_uuid(uuid) != uuid or key in exact_provider_uuids: - return False - exact_provider_uuids.add(key) - return True +def _view_seconds_since(timestamp_ms: Any, now_ms: int) -> int | None: + if isinstance(timestamp_ms, bool) or not isinstance(timestamp_ms, int): + return None + if timestamp_ms <= 0: + return None + return max(0, (now_ms - timestamp_ms) // 1000) -def guard_live_inventory(inventory: Mapping[str, Any]) -> bool: - """Validate a live mutation snapshot without rejecting unrelated unknown rows. - Unlike ``strict_live_inventory``, this predicate permits an unresolved - provider session. It still requires an exact daemon generation, an exact - shell generation for every managed row, and exact provider identity for - every row whose provider is known. - """ +def _view_text(value: Any, limit: int = 120) -> str | None: + text = clean_text(value, limit) + return text or None - def positive_int(value: Any) -> bool: - return isinstance(value, int) and not isinstance(value, bool) and value > 0 - if not isinstance(inventory, Mapping): - return False - generation = inventory.get("daemon_generation") - warnings = inventory.get("warnings") - sessions = inventory.get("sessions") - outside_agents = inventory.get("outside_agents") - if ( - inventory.get("schema_version") != SCHEMA_VERSION - or inventory.get("source") != "live" - or inventory.get("stale") is not False - or not isinstance(warnings, list) - or warnings - or not isinstance(generation, Mapping) - or not positive_int(generation.get("pid")) - or not positive_int(generation.get("process_start_ticks")) - or not isinstance(sessions, list) - or not isinstance(outside_agents, list) - ): - return False +def publish_view_fields( + inventory: dict[str, Any], + *, + stalls: Mapping[str, list[str]] | None = None, + now_ms: int | None = None, +) -> dict[str, Any]: + """Add the fields every picker reads, on top of the collectors' evidence. - rows: set[int] = set() - terminal_numbers: set[int] = set() - raw_ids: set[str] = set() - exact_provider_uuids: set[tuple[str, str]] = set() - for item in sessions: - if not isinstance(item, Mapping): - return False - row = item.get("row") - raw_id = item.get("shpool_id_raw") - identity = item.get("identity") - shell = item.get("shpool_shell") - provider = item.get("provider") - terminal_number = item.get("terminal_number") - if _missing_shell_generation_is_quarantined(item): - if ( - not positive_int(row) - or row in rows - or not isinstance(raw_id, str) - or raw_id in raw_ids - ): - return False - rows.add(row) - raw_ids.add(raw_id) - continue - mutation_allowed, mutation_reason = shpool_id_mutation_policy(raw_id) - if ( - not positive_int(row) - or row in rows - or not isinstance(raw_id, str) - or not raw_id - or raw_id in raw_ids - or raw_id != item.get("shpool_id") - or item.get("mutation_allowed") is not mutation_allowed - or item.get("mutation_rejection_reason") != mutation_reason - or not isinstance(identity, Mapping) - or not isinstance(shell, Mapping) - or not positive_int(shell.get("pid")) - or not positive_int(shell.get("process_start_ticks")) - ): - return False - if terminal_number is not None and ( - isinstance(terminal_number, bool) - or not isinstance(terminal_number, int) - or terminal_number <= 0 - or terminal_number in terminal_numbers - ): - return False - if isinstance(terminal_number, int): - terminal_numbers.add(terminal_number) - rows.add(row) - raw_ids.add(raw_id) + Additive by contract: nothing here renames or removes a field, because the + login picker parses this same document and a rename would blind the screen + a person is looking at. `lib/sessionkit_inventory/SNAPSHOT.md` is the + written form of what this publishes. - if provider == "unknown": - if ( - identity.get("confidence") != "unknown" - or identity.get("uuid") is not None - or identity.get("pid") != shell.get("pid") - or identity.get("process_start_ticks") - != shell.get("process_start_ticks") - ): - return False + One field is not merely added: a session the fleet flagged as unsurfaced, + unanswered or silent comes back with `needs_you` true and the reasons + listed. That is the point -- "needs you" means one thing, and it means it at + the source rather than in each screen's own arithmetic. + """ + sessions = inventory.get("sessions") + if not isinstance(sessions, list): + return inventory + flags = read_fleet_stalls() if stalls is None else stalls + as_of = ( + now_ms + if isinstance(now_ms, int) and not isinstance(now_ms, bool) + else int(time.time() * 1000) + ) + stall_seconds = _render.stall_threshold_seconds() + for row in sessions: + if not isinstance(row, dict): continue - if provider not in {"claude", "codex", "shell"}: - return False - if ( - identity.get("confidence") != "exact" - or not positive_int(identity.get("pid")) - or not positive_int(identity.get("process_start_ticks")) - ): - return False - if provider in {"claude", "codex"}: - uuid = identity.get("uuid") - key = (provider, uuid) - if ( - valid_uuid(uuid) != uuid - or key in exact_provider_uuids - ): - return False - exact_provider_uuids.add(key) - elif identity.get("uuid") is not None: - return False + reasons: list[str] = [] + if flags: + for identifier in _stall_identifiers(row): + for reason in flags.get(identifier, ()): + if reason not in reasons: + reasons.append(reason) + if reasons: + row["needs_you"] = True + row["needs_you_reasons"] = reasons + number = row.get("terminal_number") + row["number"] = ( + number + if isinstance(number, int) and not isinstance(number, bool) and number > 0 + else None + ) + row["attached"] = row.get("availability") == _labels.AVAILABILITY_ATTACHED + row["state"] = _labels.session_state(row, stall_seconds=stall_seconds) + row["age_seconds"] = _view_seconds_since(row.get("started_at_unix_ms"), as_of) + count = row.get("active_subagent_count") + if not isinstance(count, int) or isinstance(count, bool): + subagents = row.get("subagents") + count = len(subagents) if isinstance(subagents, list) else 0 + row["subagent_count"] = count + row["account_alias"] = _view_text(row.get("account_alias"), 20) + # Collected by another pass and simply carried through, so the field is + # in the contract before the collector exists and no screen has to grow + # a special case the day it lands. + row["model"] = _view_text(row.get("model"), 80) + # The identifier is for a machine; the name is what every screen puts + # in the column, and the state is why there is no name when there is + # none. All three ride the same document so no surface has to guess. + row["display_model"] = _view_text(row.get("display_model"), 80) + row["model_source"] = _view_text(row.get("model_source"), 40) + row["model_state"] = _view_text(row.get("model_state"), 40) + # Evidence, never copy: what the process was started with. No screen + # reads it, because a session's launch argument is not what it runs. + row["launch_model"] = _view_text(row.get("launch_model"), 80) + row["model_handoff_capable"] = row.get("model_handoff_capable") is True + row["origin"] = _view_text(row.get("origin"), 40) + return inventory - for item in outside_agents: - if not isinstance(item, Mapping): - return False - provider = item.get("provider") - identity = item.get("identity") - if ( - provider not in PROVIDERS - or not isinstance(identity, Mapping) - or identity.get("confidence") != "exact" - or not positive_int(identity.get("pid")) - or not positive_int(identity.get("process_start_ticks")) - ): - return False - uuid = identity.get("uuid") - key = (provider, uuid) - if ( - valid_uuid(uuid) != uuid - or key in exact_provider_uuids - ): - return False - exact_provider_uuids.add(key) - return rows == set(range(1, len(sessions) + 1)) +def strict_live_inventory(inventory: Mapping[str, Any]) -> bool: + return _validation.strict_live_inventory( + inventory, + guard_inventory=guard_live_inventory, + ) + + +def guard_live_inventory(inventory: Mapping[str, Any]) -> bool: + return _validation.guard_live_inventory( + inventory, + schema_version=SCHEMA_VERSION, + ) def load_inventory_input(path: str | Path) -> dict[str, Any]: - """Load a previously frozen v1 snapshot for TOCTOU-safe render/lookup.""" - source = Path(path).expanduser() - try: - value = _load_json_file(source) - except (OSError, ValueError) as exc: - raise CollectionError(f"cannot read inventory input {source}: {exc}") from exc - if ( - not isinstance(value, Mapping) - or value.get("schema_version") != SCHEMA_VERSION - or not isinstance(value.get("sessions"), list) - or not isinstance(value.get("outside_agents"), list) - ): - raise CollectionError(f"inventory input {source} is not a valid v1 snapshot") - names: set[str] = set() - rows: set[int] = set() - terminal_numbers: set[int] = set() - has_terminal_numbers = any( - isinstance(item, Mapping) - and isinstance(item.get("terminal_number"), int) - and not isinstance(item.get("terminal_number"), bool) - and item.get("terminal_number", 0) > 0 - for item in value["sessions"] - ) - for item in value["sessions"]: - if not isinstance(item, Mapping): - raise CollectionError(f"inventory input {source} has a non-object session") - name = item.get("shpool_id") - raw_name = item.get("shpool_id_raw", name) - row = item.get("row") - terminal_number = item.get("terminal_number") - if ( - not isinstance(name, str) - or not name - or raw_name != name - or not isinstance(row, int) - or row <= 0 - or name in names - or row in rows - or ( - has_terminal_numbers - and _missing_shell_generation_is_quarantinable(item) - and not _missing_shell_generation_is_quarantined(item) - ) - or ( - has_terminal_numbers - and terminal_number is None - and not _missing_shell_generation_is_quarantined(item) - ) - or ( - has_terminal_numbers - and terminal_number is not None - and ( - isinstance(terminal_number, bool) - or not isinstance(terminal_number, int) - or terminal_number <= 0 - or terminal_number in terminal_numbers - ) - ) - ): - raise CollectionError(f"inventory input {source} has invalid or duplicate selectors") - names.add(name) - rows.add(row) - if has_terminal_numbers and terminal_number is not None: - terminal_numbers.add(terminal_number) - return dict(value) - - -def _process_ancestor_chain( - process_table: Mapping[int, Mapping[str, Any]], current_pid: int -) -> list[int]: - chain: list[int] = [] - seen: set[int] = set() - pid = current_pid - for _ in range(128): - if pid in seen or pid <= 0: - raise CollectionError("automatic name caller ancestry is ambiguous") - process = process_table.get(pid) - if not isinstance(process, Mapping): - raise CollectionError("automatic name caller process is stale") - chain.append(pid) - seen.add(pid) - parent = process.get("ppid") - if isinstance(parent, bool) or not isinstance(parent, int) or parent <= 0: - break - if parent not in process_table: - break - pid = parent - else: - raise CollectionError("automatic name caller ancestry exceeds safety bound") - return chain + return _validation.load_inventory_input( + path, + schema_version=SCHEMA_VERSION, + load_json_file=_load_json_file, + ) -def prove_self_name_caller( - inventory: Mapping[str, Any], - process_table: Mapping[int, Mapping[str, Any]], - environ: Mapping[str, str], - current_pid: int, -) -> dict[str, Any]: - """Prove one exact managed root conversation and its current shell generation.""" - if inventory.get("source") != "live" or inventory.get("stale") is not False: - raise CollectionError("automatic naming requires a fresh live inventory") - shpool_id = environ.get("SHPOOL_SESSION_NAME", "") - if not shpool_id: - raise CollectionError("automatic naming is unavailable outside a managed shell") - matches = [ - item - for item in inventory.get("sessions", ()) - if isinstance(item, Mapping) and item.get("shpool_id_raw") == shpool_id - ] - if len(matches) != 1: - raise CollectionError("automatic name caller session is ambiguous or stale") - item = matches[0] - provider = item.get("provider") - identity = item.get("identity") - shell = item.get("shpool_shell") - if ( - provider not in PROVIDERS - or item.get("setup_incomplete") is not False - or not isinstance(identity, Mapping) - or identity.get("confidence") != "exact" - or not isinstance(shell, Mapping) - ): - raise CollectionError("automatic name caller setup is incomplete") - uuid = valid_uuid(identity.get("uuid")) - provider_pid = identity.get("pid") - provider_start = identity.get("process_start_ticks") - shell_pid = shell.get("pid") - shell_start = shell.get("process_start_ticks") - if ( - not uuid - or any( - isinstance(value, bool) or not isinstance(value, int) or value <= 0 - for value in (provider_pid, provider_start, shell_pid, shell_start) - ) - ): - raise CollectionError("automatic name caller lacks an exact generation") - chain = _process_ancestor_chain(process_table, current_pid) - if provider_pid not in chain or shell_pid not in chain: - raise CollectionError("automatic name caller is outside the exact provider root") - if chain.index(provider_pid) >= chain.index(shell_pid): - raise CollectionError("automatic name caller ancestry is not a managed root") - if process_table[provider_pid].get("start_ticks") != provider_start: - raise CollectionError("automatic name provider generation changed") - if process_table[shell_pid].get("start_ticks") != shell_start: - raise CollectionError("automatic name shell generation changed") - for pid in chain[: chain.index(provider_pid) + 1]: - argv = process_table[pid].get("cmdline") or () - if "--parent-session-id" in argv or "--agent-name" in argv: - raise CollectionError("automatic naming is refused for a subagent") - if provider == "codex": - caller_uuid = valid_uuid(environ.get("CODEX_THREAD_ID")) - if any( - isinstance(child, Mapping) - and child.get("provider") == "codex" - and valid_uuid(child.get("uuid")) == caller_uuid - for child in item.get("subagents", ()) - ): - raise CollectionError("automatic naming is refused for a subagent") - if caller_uuid != uuid: - raise CollectionError("Codex caller is not the managed root conversation") - else: - claude_uuid = environ.get("CLAUDE_SESSION_ID") - if claude_uuid and valid_uuid(claude_uuid) != uuid: - raise CollectionError("Claude caller is not the managed root conversation") - return { - "provider": provider, - "uuid": uuid, - "shpool_id": shpool_id, - "provider_pid": provider_pid, - "provider_start_ticks": provider_start, - "shell_pid": shell_pid, - "shell_start_ticks": shell_start, - } +def _json_print(value: Any) -> None: + json.dump(value, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + + +def _terminate_exact_process(pid: int, start_ticks: int) -> str: + """TERM, then bounded KILL, addressed to one pidfd-pinned generation. + + The TERM must really land before KILL is allowed. Each delivery re-reads + PID/start ticks after opening the pidfd, so a recycled PID is never a + target. Synthetic process trees require an explicit test-only delivery + seam; they must never make a host PID with the same number signalable. + """ + if pid <= 0 or start_ticks <= 0: + raise CollectionError("exact process generation is invalid") + root = _common.proc_root() + send_signal = None + if root == Path("/proc") and not _subagent_sweep._HAS_PIDFD: + raise CollectionError("pidfd-pinned exact signal delivery is unavailable") + if root != Path("/proc"): + mode = os.environ.get("SESSION_KIT_TEST_EXACT_SIGNAL", "") + if os.environ.get("SESSION_KIT_TESTING") != "1" or mode not in { + "remove", + "survive", + }: + raise CollectionError( + "exact signal delivery is unavailable for the synthetic process table" + ) + def synthetic_send(target: int, signum: int) -> None: + log_path = os.environ.get("SESSION_KIT_TEST_EXACT_SIGNAL_LOG", "") + if log_path: + with open(log_path, "a", encoding="utf-8") as handle: + handle.write(f"{target}\t{start_ticks}\t{signum}\n") + if mode == "remove": + shutil.rmtree(root / str(target)) -def self_name_automatic_title( - config: Mapping[str, Any], - title: str, - *, - inventory: Mapping[str, Any] | None = None, - process_table: Mapping[int, Mapping[str, Any]] | None = None, - environ: Mapping[str, str] | None = None, - current_pid: int | None = None, -) -> dict[str, Any]: - """Provider-neutral exact self-name entry point used by root agents.""" - caller_environ = environ if environ is not None else os.environ - if not automatic_naming_enabled(caller_environ): - raise CollectionError("automatic naming is disabled") - proc_root = Path(caller_environ.get("SESSION_KIT_PROC_ROOT", "/proc")) - supplied_evidence = inventory is not None or process_table is not None - live = inventory or snapshot(write_state=False, config=dict(config)) - table = process_table or platform_process_table( - proc_root, int(config.get("max_proc_nodes", DEFAULT_MAX_PROC_NODES)) - ) - pid = current_pid if current_pid is not None else os.getpid() - evidence = prove_self_name_caller(live, table, caller_environ, pid) - - def revalidate() -> None: - if supplied_evidence: - new_live, new_table = live, table - else: - new_live = snapshot(write_state=False, config=dict(config)) - new_table = platform_process_table( - proc_root, - int(config.get("max_proc_nodes", DEFAULT_MAX_PROC_NODES)), - ) - repeated = prove_self_name_caller( - new_live, new_table, caller_environ, pid - ) - if repeated != evidence: - raise CollectionError("automatic name caller changed before write") + send_signal = synthetic_send + + def still_exact() -> bool: + return _subagent_sweep._still_the_process(root, pid, start_ticks) + + grace_seconds = 0.0 if send_signal is not None else 2.0 + if not still_exact(): + return "already-gone" + term_delivered = False try: - normalized = normalize_automatic_title(title) - except CollectionError as reason: - attempts = record_automatic_title_failure( - config, - str(evidence["provider"]), - str(evidence["uuid"]), - revalidate=revalidate, + _subagent_sweep._deliver_exact_process( + root, + pid, + start_ticks, + signal.SIGTERM, + send_signal=send_signal, ) - suffix = "name failed" if attempts >= 2 else "name pending" - raise CollectionError( - f"automatic title rejected ({reason}); {suffix}" - ) from reason - result = mutate_canonical_automatic_title( - config, - str(evidence["provider"]), - str(evidence["uuid"]), - normalized, - revalidate=revalidate, - ) - result["caller"] = evidence - result["automatic_name_state"] = "ready" - result.update( - propagate_provider_title( - str(evidence["provider"]), - str(evidence["uuid"]), - normalized, - environ=caller_environ, + term_delivered = True + except ProcessLookupError: + return "already-gone" + except OSError as exc: + raise CollectionError(f"exact TERM delivery failed: {exc}") from exc + + deadline = time.monotonic() + grace_seconds + while still_exact() and time.monotonic() < deadline: + time.sleep(0.05) + if not still_exact(): + return "terminated" + if not term_delivered: # defensive: KILL may only follow a delivered TERM + raise CollectionError("exact TERM was not delivered; KILL was not attempted") + try: + _subagent_sweep._deliver_exact_process( + root, + pid, + start_ticks, + signal.SIGKILL, + send_signal=send_signal, ) - ) - effective_color = session_color( - str(evidence["provider"]), - str(evidence["uuid"]), - canonical_colors(config), - ) - if effective_color: - result.update( - propagate_provider_color( - str(evidence["provider"]), - str(evidence["uuid"]), - effective_color, - environ=caller_environ, - ) + except ProcessLookupError: + return "terminated" + except OSError as exc: + raise CollectionError(f"exact KILL delivery failed: {exc}") from exc + deadline = time.monotonic() + grace_seconds + while still_exact() and time.monotonic() < deadline: + time.sleep(0.05) + if still_exact(): + raise CollectionError( + f"verified process PID {pid} start {start_ticks} survives TERM and KILL" ) - return result + return "killed" -def _json_print(value: Any) -> None: - json.dump(value, sys.stdout, indent=2, sort_keys=True) - sys.stdout.write("\n") +def _write_closed_sessions( + *, limit: int | None, allow_large: bool, json_lines: bool +) -> None: + """Validate first, then stream one selected row at a time to stdout.""" + + with _closed_sessions.closed_snapshot( + limit=limit, + still_readable=_conversation_is_readable, + allow_large=allow_large, + ) as rows: + if json_lines: + for row in rows: + json.dump(row, sys.stdout, ensure_ascii=False, sort_keys=True) + sys.stdout.write("\n") + return + sys.stdout.write('{"schema_version":' + str(SCHEMA_VERSION) + ',"closed":[') + separator = "" + for row in rows: + sys.stdout.write(separator) + json.dump(row, sys.stdout, ensure_ascii=False, sort_keys=True) + separator = "," + sys.stdout.write("]}\n") def _platform_command(args: argparse.Namespace) -> int: + if args.platform_action == "app-server-dir": + print(_private_app_server_dir(Path(args.state_root), args.session_id)) + return 0 platform = _require_supported_platform() + if args.platform_action == "timeout": + if not 0 < args.seconds <= 3600: + raise CollectionError("timeout seconds are outside the safe range") + command = list(args.argv) + if command and command[0] == "--": + command.pop(0) + if not command: + raise CollectionError("timeout command is empty") + child = subprocess.Popen(command, start_new_session=True) + try: + returncode = child.wait(timeout=args.seconds) + return returncode if returncode >= 0 else 128 - returncode + except subprocess.TimeoutExpired: + with contextlib.suppress(ProcessLookupError): + os.killpg(child.pid, 15) + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + with contextlib.suppress(ProcessLookupError): + os.killpg(child.pid, 9) + child.wait() + return 124 if args.platform_action == "boot-id": value = _boot_id() if not value: raise CollectionError("current boot identity is unavailable") print(value) return 0 - root = Path(os.environ.get("SESSION_KIT_PROC_ROOT", "/proc")) + if args.platform_action == "shpool-holder-generation": + # This is deliberately smaller than a live inventory collection. A + # caller that already has one raw client payload needs to know who + # answered that exact read, without issuing another client request. + # Linux can answer from the listening socket inode and the daemons' + # fd tables. Darwin has no equivalent observer in this release, so a + # destructive caller must refuse there instead of treating a unique + # process name as socket authorship. + if platform == DARWIN_PLATFORM: + raise CollectionError("exact shpool socket holder is unavailable on Darwin") + table = platform_process_table(_common.proc_root(), DEFAULT_MAX_PROC_NODES) + identity = _payload_daemon_identity(table) + # Synthetic command fixtures cannot own a real Unix listener. Their + # existing daemon-PID seam is accepted only behind the testing gate, + # and only when it names an exact readable daemon generation. Live + # code never reaches this branch. + if identity is None and os.environ.get("SESSION_KIT_TESTING") == "1": + fixture_pid = os.environ.get("SESSION_KIT_DAEMON_PID", "") + if fixture_pid.isdigit(): + pid = int(fixture_pid) + process = table.get(pid) + if isinstance(process, Mapping) and ( + _is_shpool_daemon(process) + or clean_text(process.get("comm"), 128) == "shpool" + ): + start = process.get("start_ticks") + if isinstance(start, int) and not isinstance(start, bool): + identity = {"pid": pid, "process_start_ticks": start} + if identity is None: + raise CollectionError("exact shpool socket holder is unavailable") + print(f"{identity['pid']}\t{identity['process_start_ticks']}") + return 0 + if args.platform_action == "terminate-exact-process": + if platform == DARWIN_PLATFORM: + raise CollectionError( + "pidfd-pinned exact termination is unavailable on Darwin" + ) + print(_terminate_exact_process(args.pid, args.process_generation)) + return 0 + if args.platform_action == "exact-shell-gone": + # A raw list losing a name proves only that a manager lost a record. + # A close is complete only when the exact shell generation is gone + # while the daemon generation on both sides of that observation is + # unchanged. PID reuse is a successful disappearance of the old + # generation; unreadable evidence refuses. + if platform == DARWIN_PLATFORM: + raise CollectionError( + "exact post-close shell proof is unavailable on Darwin" + ) + root = _common.proc_root() + + def exact_stat(pid: int) -> tuple[int, int, int] | None: + try: + return _proc_stat(root / str(pid) / "stat") + except FileNotFoundError: + return None + except (OSError, ValueError) as exc: + raise CollectionError( + f"process generation for PID {pid} is unreadable" + ) from exc + + daemon_before = exact_stat(args.daemon_pid) + if ( + daemon_before is None + or daemon_before[0] != args.daemon_pid + or daemon_before[2] != args.daemon_generation + ): + raise CollectionError("bound shpool daemon generation changed") + shell = exact_stat(args.shell_pid) + daemon_after = exact_stat(args.daemon_pid) + if daemon_after != daemon_before: + raise CollectionError( + "bound shpool daemon generation changed during close proof" + ) + if shell is not None and shell[2] == args.shell_generation: + raise CollectionError( + f"verified shell PID {args.shell_pid} start {args.shell_generation} survives" + ) + print("gone") + return 0 + if args.platform_action == "process-table": + table = platform_process_table( + _common.proc_root(), + DEFAULT_MAX_PROC_NODES, + ) + _json_print({"processes": [table[pid] for pid in sorted(table)]}) + return 0 + root = _common.proc_root() if platform == DARWIN_PLATFORM and args.platform_action == "process-info": table = scan_darwin_process_table(1, pids=[args.pid]) else: table = platform_process_table(root, DEFAULT_MAX_PROC_NODES) + if args.platform_action == "codex-refresh-target": + pid, generation = codex_refresh_target( + table, + args.shell_pid, + args.shell_generation, + args.provider_pid, + args.provider_generation, + ) + print(f"{pid}\t{generation}") + return 0 process = table.get(args.pid) if process is None: raise CollectionError("process generation is unavailable") if args.platform_action == "process-info": print(f"{process.get('ppid', 0)}\t{process.get('start_ticks', 0)}") return 0 + if args.platform_action == "process-generation-is": + if process.get("start_ticks") != args.generation: + raise CollectionError("process generation changed") + return 0 + if args.platform_action == "process-is": + argv = process.get("cmdline") or [] + executable = Path(str(argv[0])).name if argv else str(process.get("comm", "")) + if ( + process.get("start_ticks") != args.generation + or executable != args.executable + ): + raise CollectionError("process executable or generation changed") + return 0 if process.get("start_ticks") != args.generation: raise CollectionError("process generation changed") pattern = re.compile(rf"(^|/){re.escape(args.provider)}$") @@ -6381,13 +4325,57 @@ def _alias_command(args: argparse.Namespace, config: dict[str, Any]) -> int: if args.alias_action == "migrate-runtime": _json_print(migrate_runtime_aliases(config)) return 0 - title = args.title if args.alias_action == "set" else None - aliases = mutate_canonical_alias(config, args.provider, args.uuid, title) + if args.alias_action == "push": + # Re-assert a name the kit already holds, without renaming anything. + # A restore starts a NEW provider process for an old conversation, and + # that process reads its name from the provider's own store at start: + # if the kit's name never made it there (or a later provider write + # replaced it), the restored window comes back nameless while the + # picker still shows the name. Restore calls this so the name is in + # place before the provider reads it. + key = f"{args.provider}:{str(args.uuid).lower()}" + push_title = canonical_aliases(config).get(key) or "" + if not push_title: + # The alias tier is the name a person gave this session, so it is + # always safe to re-assert. The automatic tier is not: if somebody + # renamed the conversation in the window and no inventory build has + # adopted it yet, pushing the kit's derived title would overwrite + # the person's name in the provider's own store -- and the evidence + # adoption needs to recover it goes with it. Ownership decides. + owner = canonical_name_ownership(config).get(key, {}).get("owner") or "" + if owner != "human": + push_title = canonical_automatic_titles(config).get(key) or "" + push_payload = {"schema_version": SCHEMA_VERSION, "title": push_title} + if not push_title: + # Nothing named this conversation, so nothing is missing from the + # provider store either. Silence here is the truth. + push_payload.update( + {"provider_title_pushes": [], "provider_title_warnings": []} + ) + _json_print(push_payload) + return 0 + push_payload.update( + propagate_provider_title(args.provider, args.uuid, push_title) + ) + _json_print(push_payload) + # Three outcomes, told apart because the caller says something + # different about each. Nothing landed: the window comes back nameless. + # Something landed and something did not -- Codex writes an index entry + # and a database title, and the status bar reads the database one, so a + # half-push leaves the name on one surface and not the one people see. + if not push_payload.get("provider_title_pushes"): + return 1 + if push_payload.get("provider_title_warnings"): + return 3 + return 0 + alias_title = str(args.title) if args.alias_action == "set" else None + aliases = mutate_canonical_alias(config, args.provider, args.uuid, alias_title) payload: dict[str, Any] = {"schema_version": SCHEMA_VERSION, "aliases": aliases} if args.alias_action == "set": + assert alias_title is not None # Explicit assignment pushes once into the provider's own surfaces; # deletion only clears the local alias and leaves provider titles alone. - payload.update(propagate_provider_title(args.provider, args.uuid, title)) + payload.update(propagate_provider_title(args.provider, args.uuid, alias_title)) effective_color = session_color( args.provider, args.uuid, canonical_colors(config) ) @@ -6399,17 +4387,140 @@ def _alias_command(args: argparse.Namespace, config: dict[str, Any]) -> int: return 0 +def _private_app_server_dir(state_root: Path, session_id: str) -> Path: + """Create an App Server directory through an owner-private real-dir chain.""" + if ( + not session_id + or "/" in session_id + or session_id.startswith(".") + or len(session_id) > 128 + ): + raise CollectionError("unsafe App Server session ID") + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_CLOEXEC", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(state_root, flags) + except OSError as exc: + raise CollectionError("App Server state root is unavailable") from exc + current = state_root + try: + root_metadata = os.fstat(descriptor) + if ( + not statmod.S_ISDIR(root_metadata.st_mode) + or root_metadata.st_uid != os.geteuid() + or statmod.S_IMODE(root_metadata.st_mode) != 0o700 + ): + raise CollectionError("App Server state root must be owner mode-0700") + for component in ("session-kit", "app-server", session_id): + parent = descriptor + try: + os.mkdir(component, 0o700, dir_fd=parent) + except FileExistsError: + pass + try: + descriptor = os.open(component, flags, dir_fd=parent) + except OSError as exc: + raise CollectionError("unsafe App Server directory chain") from exc + finally: + os.close(parent) + metadata = os.fstat(descriptor) + if ( + not statmod.S_ISDIR(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or statmod.S_IMODE(metadata.st_mode) != 0o700 + ): + raise CollectionError( + "App Server directory chain must be owner mode-0700" + ) + current /= component + return current + finally: + with contextlib.suppress(OSError): + os.close(descriptor) + + def _color_command(args: argparse.Namespace, config: dict[str, Any]) -> int: + def occupied_live_colors() -> set[str] | None: + try: + live = snapshot(write_state=False, config=config) + except CollectionError: + return None + if live.get("source") != "live" or live.get("stale") is not False: + return None + return { + str(item.get("display_color")) + for item in [*live.get("sessions", ()), *live.get("outside_agents", ())] + if isinstance(item, Mapping) and item.get("display_color") in SESSION_COLORS + } + + if args.color_action == "reconcile": + live = snapshot(write_state=False, config=config) + if live.get("source") != "live" or live.get("stale") is not False: + print( + "session inventory: refusing to reconcile from a stale inventory", + file=sys.stderr, + ) + return 1 + + def revalidate_sessions() -> list[Mapping[str, Any]]: + current = snapshot(write_state=False, config=config) + if current.get("source") != "live" or current.get("stale") is not False: + raise CollectionError( + "session colors changed while fresh inventory was unavailable" + ) + return [ + *current.get("sessions", ()), + *current.get("outside_agents", ()), + ] + + result = reconcile_session_colors( + config, + [*live.get("sessions", ()), *live.get("outside_agents", ())], + revalidate_sessions=revalidate_sessions, + ) + # Push each moved color the way `set` does, so a window that is already + # open shows the new one at its next start or resume instead of waiting + # for something else to touch it. + pushes: list[str] = [] + warnings: list[str] = [] + for key, color in result["moved"].items(): + provider, _, uuid = key.partition(":") + pushed = propagate_provider_color(provider, uuid, color) + pushes.extend(pushed["provider_color_pushes"]) + warnings.extend(pushed["provider_color_warnings"]) + _json_print( + { + "schema_version": SCHEMA_VERSION, + **result, + "provider_color_pushes": pushes, + "provider_color_warnings": warnings, + } + ) + return 0 + if args.color_action == "conversation-release": + released = _release_conversation_color( + config, args.provider, args.uuid, args.color + ) + _json_print({"schema_version": SCHEMA_VERSION, "released": released}) + return 0 if released else 1 if args.color_action == "launch-pick": - color = record_launch_color(config, args.shpool_id) + occupied = occupied_live_colors() + if occupied is None: + return 1 + color = record_launch_color(config, args.shpool_id, occupied) if not color: return 1 print(color) return 0 + if args.color_action == "conversation-pick": + occupied = occupied_live_colors() + if occupied is None: + return 1 + color = _reserve_conversation_color(config, args.provider, args.uuid, occupied) + _json_print({"schema_version": SCHEMA_VERSION, "color": color}) + return 0 if args.color_action == "propagate": - effective = session_color( - args.provider, args.uuid, canonical_colors(config) - ) + effective = session_color(args.provider, args.uuid, canonical_colors(config)) if not effective: return 1 _json_print( @@ -6420,10 +4531,64 @@ def _color_command(args: argparse.Namespace, config: dict[str, Any]) -> int: } ) return 0 + if args.color_action == "bounce-ready": + # Can a restart actually paint this session's colour? + # + # `effective` alone answers nothing: it falls back to a hash of the + # conversation's identity, so it succeeds for any valid uuid. The real + # question is whether the record the provider reads at start exists -- + # for Claude, an agent-color record in the conversation's transcript; + # for Codex, the theme file the launcher passes on the command line. + # Without it the restart costs the person their window and changes + # nothing. + effective = session_color(args.provider, args.uuid, canonical_colors(config)) + if not effective: + return 1 + exact = valid_uuid(args.uuid) + if not exact: + return 1 + if args.provider == "claude": + applied = False + for transcript in _names_push._claude_transcripts(_home(), exact): + try: + with open(transcript, encoding="utf-8") as handle: + for line in handle: + try: + record = json.loads(line) + except ValueError: + continue + if ( + isinstance(record, Mapping) + and record.get("type") == "agent-color" + and record.get("agentColor") == effective + ): + applied = True + except OSError: + continue + if not applied: + print( + "session inventory: no color record for this conversation; " + "a restart would not change its color", + file=sys.stderr, + ) + return 1 + else: + theme = _codex_home() / "themes" / f"sk-{effective}.tmTheme" + try: + ready = theme.is_file() and not theme.is_symlink() + except OSError: + ready = False + if not ready: + print( + "session inventory: no Codex theme for this color; " + "a restart would not change its color", + file=sys.stderr, + ) + return 1 + print(effective) + return 0 if args.color_action == "effective": - effective = session_color( - args.provider, args.uuid, canonical_colors(config) - ) + effective = session_color(args.provider, args.uuid, canonical_colors(config)) if not effective: return 1 print(effective) @@ -6435,61 +4600,523 @@ def _color_command(args: argparse.Namespace, config: dict[str, Any]) -> int: "colors": canonical_colors(config), } ) - return 0 - color = args.color if args.color_action == "set" else None - colors = mutate_canonical_color(config, args.provider, args.uuid, color) - payload: dict[str, Any] = {"schema_version": SCHEMA_VERSION, "colors": colors} - effective_color = session_color(args.provider, args.uuid, colors) - if effective_color: - payload.update( - propagate_provider_color(args.provider, args.uuid, effective_color) + return 0 + color = args.color if args.color_action == "set" else None + colors = mutate_canonical_color(config, args.provider, args.uuid, color) + payload: dict[str, Any] = {"schema_version": SCHEMA_VERSION, "colors": colors} + effective_color = session_color(args.provider, args.uuid, colors) + if effective_color: + payload.update( + propagate_provider_color(args.provider, args.uuid, effective_color) + ) + _json_print(payload) + return 0 + + +def _self_name_command(config: dict[str, Any], title: str) -> int: + """`sp self-name`, with an exit status that means what it says. + + This used to `return 0` whatever came back. A root agent is told to name + itself on its first substantive turn and to report `name failed` if that + does not work, so it needs an answer it can act on; instead a self-name + that never took looked exactly like one that did. Observed live on + 2026-08-17: the call met the collector jam, was killed while waiting, and + the session ran for three hours unnamed with nothing to show for it. + + Two things are checked, both cheap and both read-back rather than trust: + the written document has to actually carry this title for this session, + and the name has to be showing rather than queued behind a retry. Either + way the JSON still prints, so a caller that wants the detail keeps it. + """ + result = self_name_automatic_title(config, title) + _json_print(result) + caller = result.get("caller") or {} + key = f"{caller.get('provider')}:{caller.get('uuid')}" + stored = (result.get("aliases") or {}).get(key) + if stored != result.get("title"): + print( + f"session-kit: the self-name did not take for {key}; " + "nothing was written. Retry.", + file=sys.stderr, + ) + return 1 + if result.get("automatic_name_state") != "ready": + print( + "session-kit: the name is recorded but not showing yet; a retry is " + "queued. Treat this as not yet named.", + file=sys.stderr, + ) + return 1 + return 0 + + +def _automatic_title_command(args: argparse.Namespace, config: dict[str, Any]) -> int: + if args.automatic_title_action == "list": + document = _private_alias_document(config_path(), allow_missing=True) + _json_print( + { + "schema_version": SCHEMA_VERSION, + "automatic_titles": _valid_automatic_titles( + document.get("automatic_titles") + ), + "automatic_title_failures": _valid_automatic_title_failures( + document.get("automatic_title_failures") + ), + "name_ownership": canonical_name_ownership(config), + } + ) + return 0 + if args.automatic_title_action == "self-name": + return _self_name_command(config, args.title) + if args.automatic_title_action == "from-hook": + _json_print(auto_title_from_hook(sys.stdin.read())) + return 0 + if args.automatic_title_action == "codex-pending": + _json_print({"titled": codex_pending_auto_titles()}) + return 0 + if args.automatic_title_action == "claude-pending": + _json_print({"hydrated": claude_pending_native_hydrations(config)}) + return 0 + if args.automatic_title_action == "reset": + _json_print( + mutate_canonical_automatic_title(config, args.provider, args.uuid, None) + ) + return 0 + live = snapshot(write_state=False, config=config) + if args.automatic_title_action == "audit": + _json_print(audit_automatic_titles(config, live)) + return 0 + if live.get("source") != "live" or live.get("stale") is not False: + raise CollectionError("automatic title prune requires a fresh live inventory") + + def revalidate_inventory() -> Mapping[str, Any]: + current = snapshot(write_state=False, config=config) + if current.get("source") != "live" or current.get("stale") is not False: + raise CollectionError( + "automatic title prune requires a fresh live inventory under lock" + ) + return current + + _json_print( + prune_automatic_titles( + config, + live, + args.prune_token, + revalidate_inventory=revalidate_inventory, + ) + ) + return 0 + + +def _render_rules_sync(result: dict[str, Any]) -> str: + """Say which rulebooks carry the current rules, in plain words.""" + if not result.get("ok"): + return ( + "No rules file to sync from. Put one at " + f"{result.get('source', '')} (or set SESSION_KIT_RULES_FILE) and run " + "this again." + ) + targets = result.get("targets", []) + if not targets: + return "No rulebooks were found to sync." + lines = [] + for item in targets: + lines.append(f" {item['state']:8} {item['label']:20} {item['path']}") + drifted = result.get("drifted", 0) + if drifted: + lines.append("") + lines.append( + f"{drifted} rulebook(s) no longer match the rules file. " + "Run `sp account sync-rules` to bring them back." + ) + return "\n".join(lines) + + +def _render_account_list(profiles: list[dict[str, Any]]) -> str: + """`sp account list` for a person. + + The JSON document this used to print carried profile_dir filesystem paths + and epoch milliseconds -- machine facts, in a place the help promises + "enrolled accounts and last verification". The document is still one + `--json` away. + """ + if not profiles: + return f" {_labels.empty_state('Accounts')}" + now_ms = int(time.time() * 1000) + count = len(profiles) + lines = [f" {count} {_labels.plural(count, 'account')}", ""] + for provider in sorted({str(item.get("provider")) for item in profiles}): + group = [item for item in profiles if item.get("provider") == provider] + lines.append(f" {_labels.provider_name(provider)}") + alias_width = max(len(str(item.get("alias") or "")) for item in group) + email_width = max(len(str(item.get("email") or "")) for item in group) + plan_width = max( + len(str(item.get("plan") or _labels.MISSING)) for item in group + ) + for item in group: + verified = _labels.relative_time( + int(item.get("verified_at_unix_ms") or 0), now_ms + ) + row = ( + f" {str(item.get('alias') or ''):<{alias_width}} " + f"{str(item.get('email') or ''):<{email_width}} " + f"{str(item.get('plan') or _labels.MISSING):<{plan_width}} " + f"verified {verified}" + ) + if item.get("enabled") is False: + row = f"{row} (disabled)" + lines.append(row.rstrip()) + return "\n".join(lines) + + +def _snapshot_session_row( + snapshot: str | None, shpool_id: str | None +) -> dict[str, Any] | None: + """One session row out of a guard snapshot, or None when it is not exactly one. + + The automatic switch decides on the same snapshot the picker's own guard + writes, and refuses on anything but a unique match: two rows claiming one + shell is precisely the state in which moving the wrong conversation is + possible. + """ + if not snapshot or not shpool_id: + return None + try: + with open(snapshot, encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, ValueError): + return None + if not isinstance(data, dict): + return None + rows = [ + row + for row in data.get("sessions", []) + if isinstance(row, dict) and row.get("shpool_id_raw") == shpool_id + ] + return rows[0] if len(rows) == 1 else None + + +def _account_command(args: argparse.Namespace, config: dict[str, Any]) -> int: + """Run one owner-only account profile or switch-transaction verb.""" + action = args.account_action + if action == "list": + profiles = _accounts.list_profiles(config, args.provider) + if getattr(args, "account_json", False): + _json_print( + { + "schema_version": _accounts.ACCOUNT_SCHEMA_VERSION, + "profiles": profiles, + } + ) + else: + print(_render_account_list(profiles)) + elif action == "choices": + _json_print(_accounts.account_choices(config, args.provider)) + elif action == "configure-feeds": + _json_print( + _accounts.configure_feeds(config, args.roster_path, args.advice_path) + ) + elif action == "adopt-default": + _json_print( + _accounts.adopt_default(config, args.provider, args.alias, args.email) + ) + elif action == "enroll": + _json_print(_accounts.enroll(config, args.provider, args.alias, args.email)) + elif action == "sync-rules": + result = _accounts.sync_rules(config, check=args.rules_check) + if getattr(args, "account_json", False): + _json_print(result) + else: + print(_render_rules_sync(result)) + if not result.get("ok"): + return 1 + if args.rules_check and result.get("drifted"): + return 1 + elif action == "verify": + _json_print(_accounts.verify_profile(config, args.provider, args.alias)) + elif action == "binding": + value = _accounts.binding_for(config, args.provider, args.uuid) + if value is None: + return 1 + _json_print(value) + elif action == "source": + _json_print( + _accounts.source_profile_for_thread(config, args.provider, args.uuid) + ) + elif action == "bind": + _json_print( + _accounts.bind( + config, + args.provider, + args.uuid, + args.alias, + source=args.source, + ) + ) + elif action == "launch-profile": + _json_print(_accounts.launch_profile(config, args.provider, args.alias)) + elif action == "resume-profile": + _json_print(_accounts.resume_profile(config, args.provider, args.alias)) + elif action == "sync-ui": + item = _accounts.resume_profile(config, args.provider, args.alias) + profile_dir = item["profile_dir"] + if args.provider == "claude": + os.environ["CLAUDE_CONFIG_DIR"] = profile_dir + else: + os.environ["CODEX_HOME"] = profile_dir + os.environ["SESSION_KIT_CODEX_HOME"] = profile_dir + key = f"{args.provider}:{valid_uuid(args.uuid) or ''}" + payload: dict[str, Any] = {"provider": args.provider, "uuid": args.uuid} + title = canonical_aliases(config).get(key) + if title: + payload.update(propagate_provider_title(args.provider, args.uuid, title)) + color = canonical_colors(config).get(key) + if color: + payload.update(propagate_provider_color(args.provider, args.uuid, color)) + _json_print(payload) + elif action == "auto-plan": + from sessionkit_inventory import account_guard as _guard + + _json_print( + _guard.plan( + config, + args.provider, + args.uuid, + _snapshot_session_row( + getattr(args, "snapshot", None), getattr(args, "shpool_id", None) + ), + ) + ) + elif action == "auto-spent": + from sessionkit_inventory import account_guard as _guard + + _json_print(_guard.spent_aliases(config, args.provider)) + elif action == "auto-begin": + from sessionkit_inventory import account_guard as _guard + + # Reserves the conversation's one move and prints the token that names + # it. Called before anything irreversible, so a failure afterwards can + # never leave a moved conversation looking unmoved. + print( + _guard.begin_hop( + config, + args.provider, + args.uuid, + args.source_alias, + args.target_alias, + reason=args.reason, + ) + ) + elif action in ("auto-commit", "auto-release"): + from sessionkit_inventory import account_guard as _guard + + verb = _guard.commit_hop if action == "auto-commit" else _guard.release_hop + return 0 if verb(config, args.provider, args.uuid, args.token) else 1 + elif action == "auto-target-ok": + from sessionkit_inventory import account_guard as _guard + + value = _guard.target_still_eligible(config, args.provider, args.alias) + _json_print(value) + return 0 if value["eligible"] else 1 + elif action == "auto-queue-notice": + from sessionkit_inventory import account_guard as _guard + + # Records the debt only. Delivery claims it separately, so a notifier + # that is down leaves the operator still owed the sentence. + return ( + 0 + if _guard.queue_notice( + config, args.provider, args.uuid, args.token, args.sentence + ) + else 1 + ) + elif action == "auto-notice-delivered": + from sessionkit_inventory import account_guard as _guard + + return ( + 0 + if _guard.notice_delivered(config, args.provider, args.uuid, args.token) + else 1 + ) + elif action == "auto-pending-notices": + from sessionkit_inventory import account_guard as _guard + + _json_print({"notices": _guard.pending_notices(config)}) + elif action == "switch-prepare": + _json_print( + _accounts.prepare_switch( + config, + args.provider, + args.uuid, + args.source_alias, + args.target_alias, + args.cwd, + args.shpool_id, + ) ) - _json_print(payload) + elif action == "switch-apply": + _json_print(_accounts.apply_switch(config, args.txid)) + elif action == "switch-commit": + _json_print(_accounts.commit_switch(config, args.txid)) + elif action == "switch-rollback": + _json_print(_accounts.rollback_switch(config, args.txid)) + elif action == "switch-status": + _json_print(_accounts.transaction(config, args.txid)) + else: # pragma: no cover - argparse owns this boundary + raise CollectionError("unknown account action") return 0 -def _automatic_title_command( - args: argparse.Namespace, config: dict[str, Any] -) -> int: - if args.automatic_title_action == "list": - document = _private_alias_document(config_path(), allow_missing=True) +def _live_session_names(payload_text: str) -> list[str] | None: + """Session names from a ``shpool list --json`` payload, or ``None``. + + ``None`` is the answer that matters. "The list could not be read" and + "there are no sessions" produce the same empty list, and one of those two + means remove every working copy, so the difference has to survive as far + as the caller, which turns ``None`` into a refusal rather than a sweep. + + This lives here rather than in the shell that used to do it because the + shell had no way to tell an enumerator that failed from a machine with + nothing running: both produced an empty array, and the next line read the + empty array as "nothing is alive". A single stray row that is not a session + refuses the whole payload; a list this cannot fully account for is not a + list of what is alive. + """ + try: + payload = json.loads(payload_text) + except (ValueError, TypeError): + return None + rows = payload.get("sessions") if isinstance(payload, dict) else payload + if not isinstance(rows, list): + return None + names: list[str] = [] + for row in rows: + if not isinstance(row, dict): + return None + name = row.get("name") + if isinstance(name, str) and name.strip(): + names.append(name.strip()) + return names + + +def _worktree_command(args: argparse.Namespace, config: dict[str, Any]) -> int: + """One worktree-isolation verb; prints its JSON result.""" + from sessionkit_inventory import worktrees as _worktrees + + state_dir = Path(config["state_dir"]) + if args.worktree_action == "materialize": _json_print( - { - "schema_version": SCHEMA_VERSION, - "automatic_titles": _valid_automatic_titles( - document.get("automatic_titles") - ), - "automatic_title_failures": _valid_automatic_title_failures( - document.get("automatic_title_failures") - ), - } + _worktrees.materialize( + repo=args.repo, + branch=args.branch, + state_dir=state_dir, + start_ref=args.start_ref, + environ=os.environ, + auto=bool(getattr(args, "auto", False)), + origin=str(getattr(args, "origin", "") or ""), + ) ) return 0 - if args.automatic_title_action == "self-name": - _json_print(self_name_automatic_title(config, args.title)) - return 0 - if args.automatic_title_action == "from-hook": - _json_print(auto_title_from_hook(sys.stdin.read())) + if args.worktree_action == "copy-check": + # "Should a delegated session be given a copy of this repository + # without being asked?" Exit 0 with the reason when the answer is no. + reason = _worktrees.auto_copy_refusal(args.repo, os.environ) + if reason: + print(reason) + return 0 + return 1 + if args.worktree_action == "release": + verdict = _worktrees.release( + state_dir, + shpool_id=args.shpool_id or None, + path=args.path or None, + merged_into_ref=args.merged_into, + environ=os.environ, + ) + if args.as_json: + _json_print(verdict) + else: + print(_worktrees.render_verdict(verdict), end="") return 0 - if args.automatic_title_action == "codex-pending": - _json_print({"titled": codex_pending_auto_titles()}) + if args.worktree_action == "sweep": + # A sweep with no list of what is alive is not a sweep of nothing: + # it is a sweep of everything. The list is required, and `--none-alive` + # is how a caller says an empty list is what it means. + active = list(args.active or []) + none_alive = bool(args.none_alive) + if getattr(args, "active_stdin", False): + names = _live_session_names(sys.stdin.read()) + if names is None: + print( + "the live session list could not be read, so nothing was " + "swept; an unreadable list is never read as 'nothing is " + "alive'", + file=sys.stderr, + ) + return 2 + active.extend(names) + # A payload that parsed cleanly and held no sessions is the one + # case where an empty list really does mean nothing is alive. + none_alive = True + if not active and not none_alive: + print( + "sweep needs --active SHPOOL_ID for each session that is alive, " + "or --none-alive to say there are none", + file=sys.stderr, + ) + return 2 + verdicts = _worktrees.release_idle( + state_dir, + active, + merged_into_ref=args.merged_into, + environ=os.environ, + ) + if args.as_json: + _json_print({"schema_version": SCHEMA_VERSION, "released": verdicts}) + else: + for verdict in verdicts: + print(_worktrees.render_verdict(verdict), end="") return 0 - if args.automatic_title_action == "reset": + if args.worktree_action == "bind": _json_print( - mutate_canonical_automatic_title( - config, args.provider, args.uuid, None + _worktrees.bind( + state_dir=state_dir, + path=args.path, + shpool_id=args.shpool_id, + launch_key=args.launch_key, + environ=os.environ, ) ) return 0 - live = snapshot(write_state=False, config=config) - if args.automatic_title_action == "audit": - _json_print(audit_automatic_titles(config, live)) + if args.worktree_action == "list": + found = _worktrees.records(state_dir, os.environ) + if args.as_json: + _json_print({"schema_version": SCHEMA_VERSION, "worktrees": found}) + else: + print(_worktrees.render(found), end="") return 0 - if live.get("source") != "live" or live.get("stale") is not False: - raise CollectionError( - "automatic title prune requires a fresh live inventory" + if args.worktree_action == "lookup": + record = _worktrees.lookup( + state_dir, + path=args.path, + repo=args.repo, + branch=args.branch, + environ=os.environ, + include_released=bool(getattr(args, "include_released", False)), + ) + _json_print(record or {}) + return 0 if record else 2 + _json_print( + _worktrees.teardown( + state_dir=state_dir, + path=args.path, + repo=args.repo, + branch=args.branch, + merged_into_ref=args.merged_into, + force=args.force, + environ=os.environ, ) - _json_print(prune_automatic_titles(config, live, args.prune_token)) + ) return 0 @@ -6507,19 +5134,69 @@ def _parser() -> argparse.ArgumentParser: lookup_parser = subparsers.add_parser("lookup") lookup_parser.add_argument("selector") lookup_parser.add_argument("--input") + detail_parser = subparsers.add_parser("detail") + detail_parser.add_argument("selector") + detail_parser.add_argument("--input") recovery_parser = subparsers.add_parser("recovery-command") recovery_parser.add_argument("provider", choices=PROVIDERS) recovery_parser.add_argument("uuid") recovery_parser.add_argument("--cwd") + worker_model_parser = subparsers.add_parser("validate-worker-model") + worker_model_parser.add_argument("provider", choices=PROVIDERS) + worker_model_parser.add_argument("model") + model_available_parser = subparsers.add_parser("model-availability") + model_available_parser.add_argument("provider", choices=PROVIDERS) + model_available_parser.add_argument("model") + model_available_parser.add_argument( + "--flag", + default="--model-anyway", + help="the flag a person repeats to ask for the model anyway", + ) + model_available_parser.add_argument("--json", dest="as_json", action="store_true") + model_served_parser = subparsers.add_parser("model-served") + model_served_parser.add_argument("provider", choices=PROVIDERS) + model_served_parser.add_argument("requested") + model_served_parser.add_argument("served") bounce_parser = subparsers.add_parser("codex-bounce-title") bounce_parser.add_argument("uuid") + bounce_parser.add_argument("--read-only", action="store_true") + claude_bounce_parser = subparsers.add_parser("claude-bounce-title") + claude_bounce_parser.add_argument("uuid") platform_parser = subparsers.add_parser("platform") platform_subparsers = platform_parser.add_subparsers( dest="platform_action", required=True ) platform_subparsers.add_parser("boot-id") + platform_subparsers.add_parser("process-table") + platform_subparsers.add_parser("shpool-holder-generation") + platform_terminate = platform_subparsers.add_parser("terminate-exact-process") + platform_terminate.add_argument("pid", type=int) + platform_terminate.add_argument("process_generation", type=int) + platform_shell_gone = platform_subparsers.add_parser("exact-shell-gone") + platform_shell_gone.add_argument("daemon_pid", type=int) + platform_shell_gone.add_argument("daemon_generation", type=int) + platform_shell_gone.add_argument("shell_pid", type=int) + platform_shell_gone.add_argument("shell_generation", type=int) + platform_app_dir = platform_subparsers.add_parser("app-server-dir") + platform_app_dir.add_argument("state_root") + platform_app_dir.add_argument("session_id") + platform_timeout = platform_subparsers.add_parser("timeout") + platform_timeout.add_argument("seconds", type=float) + platform_timeout.add_argument("argv", nargs=argparse.REMAINDER) platform_process = platform_subparsers.add_parser("process-info") platform_process.add_argument("pid", type=int) + platform_generation = platform_subparsers.add_parser("process-generation-is") + platform_generation.add_argument("pid", type=int) + platform_generation.add_argument("generation", type=int) + platform_is = platform_subparsers.add_parser("process-is") + platform_is.add_argument("pid", type=int) + platform_is.add_argument("generation", type=int) + platform_is.add_argument("executable") + platform_refresh = platform_subparsers.add_parser("codex-refresh-target") + platform_refresh.add_argument("shell_pid", type=int) + platform_refresh.add_argument("shell_generation", type=int) + platform_refresh.add_argument("provider_pid", type=int) + platform_refresh.add_argument("provider_generation", type=int) platform_provider = platform_subparsers.add_parser("provider-present") platform_provider.add_argument("pid", type=int) platform_provider.add_argument("generation", type=int) @@ -6532,14 +5209,16 @@ def _parser() -> argparse.ArgumentParser: alias_set.add_argument("provider", choices=PROVIDERS) alias_set.add_argument("uuid") alias_set.add_argument("title") + alias_push = alias_subparsers.add_parser("push") + alias_push.add_argument("provider", choices=PROVIDERS) + alias_push.add_argument("uuid") alias_delete = alias_subparsers.add_parser("delete") alias_delete.add_argument("provider", choices=PROVIDERS) alias_delete.add_argument("uuid") color_parser = subparsers.add_parser("color") - color_subparsers = color_parser.add_subparsers( - dest="color_action", required=True - ) + color_subparsers = color_parser.add_subparsers(dest="color_action", required=True) color_subparsers.add_parser("list") + color_subparsers.add_parser("reconcile") color_set = color_subparsers.add_parser("set") color_set.add_argument("provider", choices=PROVIDERS) color_set.add_argument("uuid") @@ -6550,11 +5229,21 @@ def _parser() -> argparse.ArgumentParser: color_effective = color_subparsers.add_parser("effective") color_effective.add_argument("provider", choices=PROVIDERS) color_effective.add_argument("uuid") + color_ready = color_subparsers.add_parser("bounce-ready") + color_ready.add_argument("provider", choices=PROVIDERS) + color_ready.add_argument("uuid") color_propagate = color_subparsers.add_parser("propagate") color_propagate.add_argument("provider", choices=PROVIDERS) color_propagate.add_argument("uuid") color_launch = color_subparsers.add_parser("launch-pick") color_launch.add_argument("shpool_id") + color_conversation = color_subparsers.add_parser("conversation-pick") + color_conversation.add_argument("provider", choices=PROVIDERS) + color_conversation.add_argument("uuid") + color_release = color_subparsers.add_parser("conversation-release") + color_release.add_argument("provider", choices=PROVIDERS) + color_release.add_argument("uuid") + color_release.add_argument("color", choices=SESSION_COLORS) automatic_parser = subparsers.add_parser("automatic-title") automatic_subparsers = automatic_parser.add_subparsers( dest="automatic_title_action", required=True @@ -6562,6 +5251,7 @@ def _parser() -> argparse.ArgumentParser: automatic_subparsers.add_parser("list") automatic_subparsers.add_parser("from-hook") automatic_subparsers.add_parser("codex-pending") + automatic_subparsers.add_parser("claude-pending") automatic_self = automatic_subparsers.add_parser("self-name") automatic_self.add_argument("title") automatic_reset = automatic_subparsers.add_parser("reset") @@ -6570,15 +5260,198 @@ def _parser() -> argparse.ArgumentParser: automatic_subparsers.add_parser("audit") automatic_prune = automatic_subparsers.add_parser("prune") automatic_prune.add_argument("--apply", dest="prune_token", required=True) + account_parser = subparsers.add_parser("account") + account_subparsers = account_parser.add_subparsers( + dest="account_action", required=True + ) + account_list = account_subparsers.add_parser("list") + account_list.add_argument( + "provider", nargs="?", choices=sorted(_accounts.PROVIDERS) + ) + account_list.add_argument("--json", dest="account_json", action="store_true") + account_choices = account_subparsers.add_parser("choices") + account_choices.add_argument("provider", choices=sorted(_accounts.PROVIDERS)) + account_feeds = account_subparsers.add_parser("configure-feeds") + account_feeds.add_argument("roster_path") + account_feeds.add_argument("advice_path") + for account_action in ("adopt-default", "enroll"): + account_mutation = account_subparsers.add_parser(account_action) + account_mutation.add_argument("provider", choices=sorted(_accounts.PROVIDERS)) + account_mutation.add_argument("alias") + account_mutation.add_argument("email") + account_sync_rules = account_subparsers.add_parser("sync-rules") + account_sync_rules.add_argument( + "--check", dest="rules_check", action="store_true" + ) + account_sync_rules.add_argument( + "--json", dest="account_json", action="store_true" + ) + account_verify = account_subparsers.add_parser("verify") + account_verify.add_argument("provider", choices=sorted(_accounts.PROVIDERS)) + account_verify.add_argument("alias") + account_binding = account_subparsers.add_parser("binding") + account_binding.add_argument("provider", choices=sorted(_accounts.PROVIDERS)) + account_binding.add_argument("uuid") + account_source = account_subparsers.add_parser("source") + account_source.add_argument("provider", choices=sorted(_accounts.PROVIDERS)) + account_source.add_argument("uuid") + account_auto_plan = account_subparsers.add_parser("auto-plan") + account_auto_plan.add_argument("provider", choices=sorted(_accounts.PROVIDERS)) + account_auto_plan.add_argument("uuid") + account_auto_plan.add_argument("--snapshot") + account_auto_plan.add_argument("--shpool-id", dest="shpool_id") + account_auto_spent = account_subparsers.add_parser("auto-spent") + account_auto_spent.add_argument("provider", choices=sorted(_accounts.PROVIDERS)) + account_auto_begin = account_subparsers.add_parser("auto-begin") + account_auto_begin.add_argument("provider", choices=sorted(_accounts.PROVIDERS)) + account_auto_begin.add_argument("uuid") + account_auto_begin.add_argument("source_alias") + account_auto_begin.add_argument("target_alias") + account_auto_begin.add_argument("--reason", default="") + for account_action in ("auto-commit", "auto-release"): + account_auto_state = account_subparsers.add_parser(account_action) + account_auto_state.add_argument("provider", choices=sorted(_accounts.PROVIDERS)) + account_auto_state.add_argument("uuid") + account_auto_state.add_argument("token") + account_auto_target = account_subparsers.add_parser("auto-target-ok") + account_auto_target.add_argument("provider", choices=sorted(_accounts.PROVIDERS)) + account_auto_target.add_argument("alias") + account_auto_queue = account_subparsers.add_parser("auto-queue-notice") + account_auto_queue.add_argument("provider", choices=sorted(_accounts.PROVIDERS)) + account_auto_queue.add_argument("uuid") + account_auto_queue.add_argument("token") + account_auto_queue.add_argument("sentence") + account_auto_done = account_subparsers.add_parser("auto-notice-delivered") + account_auto_done.add_argument("provider", choices=sorted(_accounts.PROVIDERS)) + account_auto_done.add_argument("uuid") + account_auto_done.add_argument("token") + account_subparsers.add_parser("auto-pending-notices") + account_bind = account_subparsers.add_parser("bind") + account_bind.add_argument("provider", choices=sorted(_accounts.PROVIDERS)) + account_bind.add_argument("uuid") + account_bind.add_argument("alias") + account_bind.add_argument("--source", default="verified") + for account_action in ("launch-profile", "resume-profile"): + account_launch = account_subparsers.add_parser(account_action) + account_launch.add_argument("provider", choices=sorted(_accounts.PROVIDERS)) + account_launch.add_argument("alias") + account_sync = account_subparsers.add_parser("sync-ui") + account_sync.add_argument("provider", choices=sorted(_accounts.PROVIDERS)) + account_sync.add_argument("uuid") + account_sync.add_argument("alias") + account_prepare = account_subparsers.add_parser("switch-prepare") + account_prepare.add_argument("provider", choices=sorted(_accounts.PROVIDERS)) + account_prepare.add_argument("uuid") + account_prepare.add_argument("source_alias") + account_prepare.add_argument("target_alias") + account_prepare.add_argument("cwd") + account_prepare.add_argument("shpool_id") + for account_action in ( + "switch-apply", + "switch-commit", + "switch-rollback", + "switch-status", + ): + account_transaction = account_subparsers.add_parser(account_action) + account_transaction.add_argument("txid") pending_parser = subparsers.add_parser("recovery-pending") pending_subparsers = pending_parser.add_subparsers( dest="pending_action", required=True ) - pending_subparsers.add_parser("list") + pending_list = pending_subparsers.add_parser("list") + pending_list.add_argument("--without-closed", action="store_true") + pending_list.add_argument("--projection-inputs", action="store_true") + pending_list.add_argument("--stream-closed", action="store_true") + pending_list.add_argument("--stream-recovery-snapshot", action="store_true") + pending_list.add_argument("--allow-large-ledger", action="store_true") pending_ack = pending_subparsers.add_parser("ack") pending_ack.add_argument("source_generation_key") pending_ack.add_argument("old_shpool_id") pending_ack.add_argument("uuid") + # What a recovery screen printed beside each row, and the one question an + # action asks about it: does that word still name the same conversation? + # A word like "unnamed", or the clock face a shared name falls back to, is + # a property of the list rather than of the conversation, so a list rebuilt + # between printing it and acting on it can move it to another row. + printed_parser = subparsers.add_parser("recovery-selectors") + printed_subparsers = printed_parser.add_subparsers( + dest="printed_action", required=True + ) + printed_remember = printed_subparsers.add_parser("remember") + printed_remember.add_argument("--payload", default="") + printed_remember.add_argument("--json-lines", action="store_true") + printed_check = printed_subparsers.add_parser("check") + printed_check.add_argument("selector") + printed_check.add_argument("provider") + printed_check.add_argument("uuid") + # Closing on purpose is not a crash, and the queue has to be told so by + # whoever did it. The in-session verb rides the lifecycle proof; this one + # exists for `k` on the picker, which closes a session it is not inside. + # The session shell carries no kit environment, so it cannot source the + # shell logger. It gets the same log through the one program it always + # has a path to. + action_parser = subparsers.add_parser("action-log") + action_parser.add_argument("action") + action_parser.add_argument("outcome") + action_parser.add_argument("--session", default=None) + # Who asked for a session, stamped by the verb that creates it. The + # picker's default view is the person's own screen; anything a machine + # started belongs behind a counted row, and only the creator knows which + # it is. + origin_parser = subparsers.add_parser("origin") + origin_subparsers = origin_parser.add_subparsers( + dest="origin_action", required=True + ) + origin_record = origin_subparsers.add_parser("record") + origin_record.add_argument("shpool_id") + origin_record.add_argument("origin", choices=sorted(_origins.ORIGINS)) + origin_subparsers.add_parser("list") + # Closing a session ends the terminal, never the conversation. Every + # deliberate close lands here so the conversation can be listed and + # restored afterwards, for as long as its transcript exists. + closed_parser = subparsers.add_parser("closed-sessions") + closed_subparsers = closed_parser.add_subparsers( + dest="closed_action", required=True + ) + closed_record = closed_subparsers.add_parser("record") + closed_record.add_argument("provider", choices=sorted(_closed_sessions.PROVIDERS)) + closed_record.add_argument("--uuid", default="") + closed_record.add_argument("--session", default="") + closed_record.add_argument("--title", default="") + closed_record.add_argument("--cwd", default="") + closed_record.add_argument("--origin", default="") + closed_record.add_argument("--account", default="") + closed_list = closed_subparsers.add_parser("list") + closed_list.add_argument("--limit", type=int, default=_closed_sessions.MAX_ENTRIES) + closed_list.add_argument("--allow-large-ledger", action="store_true") + closed_stream = closed_subparsers.add_parser("stream") + closed_stream.add_argument( + "--limit", type=int, default=_closed_sessions.MAX_ENTRIES + ) + closed_stream.add_argument("--allow-large-ledger", action="store_true") + closed_forget = closed_subparsers.add_parser("forget") + closed_forget.add_argument("provider", choices=("claude", "codex")) + closed_forget.add_argument("uuid") + # Can every conversation this machine recorded still be READ here? A + # session running under a rotated account profile once had its transcript + # on this disk while the tool asking for it reported none, because one + # resolver knew a root the other did not. Nothing warned. + transcript_parser = subparsers.add_parser("transcript") + transcript_subparsers = transcript_parser.add_subparsers( + dest="transcript_action", required=True + ) + transcript_locate = transcript_subparsers.add_parser("locate") + transcript_locate.add_argument("provider", choices=("claude", "codex")) + transcript_locate.add_argument("uuid") + transcript_subparsers.add_parser("reachability") + intent_parser = subparsers.add_parser("close-intent") + intent_subparsers = intent_parser.add_subparsers( + dest="intent_action", required=True + ) + intent_record = intent_subparsers.add_parser("record") + intent_record.add_argument("provider", choices=sorted(PROVIDERS)) + intent_record.add_argument("uuid") + intent_subparsers.add_parser("list") manifest_parser = subparsers.add_parser("recovery-manifest") manifest_subparsers = manifest_parser.add_subparsers( dest="manifest_action", required=True @@ -6600,8 +5473,86 @@ def _parser() -> argparse.ArgumentParser: lifecycle_subparsers.add_parser("provider-exited") lifecycle_subparsers.add_parser("user-input") lifecycle_subparsers.add_parser("reopen") + lifecycle_closed = lifecycle_subparsers.add_parser("closed") + # Closing a session that CRASHED is only better than leaving it in the + # list if the conversation comes back afterwards. With this flag the verb + # is a single decision, close and keep the conversation, or change + # nothing at all, so the caller can keep a session it must not lose + # without the asking itself leaving a record behind. + lifecycle_closed.add_argument("--only-with-conversation", action="store_true") lifecycle_keep = lifecycle_subparsers.add_parser("keep") lifecycle_keep.add_argument("choice", choices=("on", "off")) + worktree_parser = subparsers.add_parser("worktree") + worktree_subparsers = worktree_parser.add_subparsers( + dest="worktree_action", required=True + ) + worktree_materialize = worktree_subparsers.add_parser("materialize") + worktree_materialize.add_argument("--repo", required=True) + worktree_materialize.add_argument("--branch", required=True) + worktree_materialize.add_argument("--start-ref", dest="start_ref", default="HEAD") + worktree_materialize.add_argument( + "--auto", + action="store_true", + help="the kit chose this branch for delegated work, so the copy is " + "released again when the session closes", + ) + worktree_materialize.add_argument("--origin", default="") + worktree_copy_check = worktree_subparsers.add_parser("copy-check") + worktree_copy_check.add_argument("--repo", required=True) + worktree_release = worktree_subparsers.add_parser("release") + worktree_release.add_argument("--shpool-id", dest="shpool_id", default="") + worktree_release.add_argument("--path", default="") + worktree_release.add_argument("--merged-into", dest="merged_into", default="HEAD") + worktree_release.add_argument("--json", dest="as_json", action="store_true") + worktree_sweep = worktree_subparsers.add_parser("sweep") + worktree_sweep.add_argument( + "--active", + action="append", + default=[], + metavar="SHPOOL_ID", + help="a session that is still live; repeat for more", + ) + worktree_sweep.add_argument("--merged-into", dest="merged_into", default="HEAD") + worktree_sweep.add_argument( + "--none-alive", + dest="none_alive", + action="store_true", + help="there are no live sessions; without this an empty --active list is refused", + ) + worktree_sweep.add_argument( + "--active-stdin", + dest="active_stdin", + action="store_true", + help=( + "read a shpool list --json payload on stdin and take the live " + "session names from it; a payload that cannot be read refuses the " + "sweep instead of being treated as an empty list" + ), + ) + worktree_sweep.add_argument("--json", dest="as_json", action="store_true") + worktree_bind = worktree_subparsers.add_parser("bind") + worktree_bind.add_argument("--path", required=True) + worktree_bind.add_argument("--shpool-id", dest="shpool_id", default="") + worktree_bind.add_argument("--launch-key", dest="launch_key", default="") + worktree_list = worktree_subparsers.add_parser("list") + worktree_list.add_argument("--json", dest="as_json", action="store_true") + worktree_lookup = worktree_subparsers.add_parser("lookup") + worktree_lookup.add_argument("--path") + worktree_lookup.add_argument("--repo") + worktree_lookup.add_argument("--branch") + worktree_lookup.add_argument( + "--include-released", + dest="include_released", + action="store_true", + help="also answer for a copy that has been given back, so a caller can " + "find the repository it came from", + ) + worktree_teardown = worktree_subparsers.add_parser("teardown") + worktree_teardown.add_argument("--path") + worktree_teardown.add_argument("--repo") + worktree_teardown.add_argument("--branch") + worktree_teardown.add_argument("--merged-into", dest="merged_into", default="HEAD") + worktree_teardown.add_argument("--force", action="store_true") return parser @@ -6611,9 +5562,7 @@ def _lifecycle_environment() -> tuple[Path, str, str, int, int]: boot_id = os.environ.get("SESSION_KIT_LIFECYCLE_BOOT_ID", "") try: shell_pid = int(os.environ.get("SESSION_KIT_LIFECYCLE_SHELL_PID", "")) - shell_start = int( - os.environ.get("SESSION_KIT_LIFECYCLE_SHELL_START_TICKS", "") - ) + shell_start = int(os.environ.get("SESSION_KIT_LIFECYCLE_SHELL_START_TICKS", "")) except ValueError as exc: raise CollectionError("lifecycle shell generation is invalid") from exc return state_dir, session_id, boot_id, shell_pid, shell_start @@ -6624,15 +5573,13 @@ def _prove_lifecycle_caller( shell_pid: int, shell_start: int, ) -> None: - if _require_supported_platform() != "linux": - raise CollectionError("lifecycle state is supported on Linux only") - proc_root = Path("/proc") - if ( - os.environ.get("SESSION_KIT_TESTING") == "1" - and os.environ.get("SESSION_KIT_PROC_ROOT") - ): - proc_root = Path(os.environ["SESSION_KIT_PROC_ROOT"]) - process_table = scan_process_table(proc_root, DEFAULT_MAX_PROC_NODES) + platform = _require_supported_platform() + proc_root = _common.proc_root() + process_table = ( + scan_darwin_process_table(DEFAULT_MAX_PROC_NODES) + if platform == DARWIN_PLATFORM + else scan_process_table(proc_root, DEFAULT_MAX_PROC_NODES) + ) chain = _process_ancestor_chain(process_table, os.getpid()) shell = process_table.get(shell_pid) if ( @@ -6646,34 +5593,381 @@ def _prove_lifecycle_caller( ) -def _lifecycle_command(args: argparse.Namespace) -> int: - state_dir, session_id, boot_id, shell_pid, shell_start = ( - _lifecycle_environment() +def _prove_unchanged_daemon_generation(live: Mapping[str, Any]) -> None: + """Re-prove the daemon behind a validated snapshot is still the same one.""" + expected = live.get("daemon_generation") + platform = _require_supported_platform() + proc_root = _common.proc_root() + process_table = ( + scan_darwin_process_table(DEFAULT_MAX_PROC_NODES) + if platform == DARWIN_PLATFORM + else scan_process_table(proc_root, DEFAULT_MAX_PROC_NODES) + ) + current = daemon_generation(process_table) + if ( + not isinstance(expected, Mapping) + or current is None + or current.get("pid") != expected.get("pid") + or current.get("process_start_ticks") != expected.get("process_start_ticks") + ): + raise CollectionError("the shpool daemon generation changed; nothing reopened") + + +def _lifecycle_committed_conversation( + *, + provider: str, + boot_id: str, + shell_pid: int, + shell_start: int, +) -> str | None: + conversation = os.environ.get("SESSION_KIT_LIFECYCLE_CONVERSATION_UUID", "") + marker_raw = os.environ.get("SESSION_KIT_LIFECYCLE_INTAKE_COMMIT", "") + generation = os.environ.get("SESSION_KIT_MANAGED_GENERATION", "") + if not conversation and not marker_raw and not generation: + return None + exact = valid_uuid(conversation) + # Resumes and ordinary provider starts bind their exact launch-record UUID + # without a first-prompt handoff. A configured handoff path/generation is + # stricter: accepted-without-intake must still fail closed. + if exact and not marker_raw and not generation: + return exact + if not exact or not marker_raw or not generation: + raise CollectionError("lifecycle intake commit evidence is incomplete") + parts = generation.split(":") + if ( + len(parts) != 4 + or parts[0] != boot_id + or parts[1] != str(shell_pid) + or parts[2] != str(shell_start) + or not parts[3].isdigit() + or int(parts[3]) <= 0 + ): + raise CollectionError( + "lifecycle intake commit generation does not match the shell" + ) + marker = Path(marker_raw) + if not marker.is_absolute() or not marker.name.endswith(".intake_committed"): + raise CollectionError("lifecycle intake commit path is invalid") + try: + parent_info = marker.parent.lstat() + info = marker.lstat() + value = _state_io.read_private_json(marker, max_bytes=8192) + except (OSError, ValueError) as exc: + raise CollectionError("lifecycle intake commit marker is unavailable") from exc + if ( + statmod.S_ISLNK(parent_info.st_mode) + or not statmod.S_ISDIR(parent_info.st_mode) + or parent_info.st_uid != os.geteuid() + or statmod.S_IMODE(parent_info.st_mode) != 0o700 + or statmod.S_ISLNK(info.st_mode) + or not statmod.S_ISREG(info.st_mode) + or info.st_uid != os.geteuid() + or statmod.S_IMODE(info.st_mode) != 0o600 + or info.st_size > 8192 + or not isinstance(value, Mapping) + ): + raise CollectionError( + "lifecycle intake commit marker does not match the provider generation" + ) + # Read the three numeric fields once. Repeating `value.get(...)` inside the + # chain re-reads the mapping after its own type test, which is both slower + # and unprovable. + marker_bytes = value.get("bytes") + revision = value.get("requirements_revision") + committed = value.get("committed_unix_ms") + if ( + value.get("schema_version") != 2 + or value.get("status") != "intake_committed" + or value.get("provider") != provider + or valid_uuid(value.get("session_id")) != exact + or value.get("managed_generation") != generation + or not isinstance(value.get("submission_key"), str) + or not value.get("submission_key") + or not re.fullmatch(r"[0-9a-f]{64}", str(value.get("prompt_sha256") or "")) + or not isinstance(marker_bytes, int) + or marker_bytes <= 0 + or not re.fullmatch(r"[0-9a-f]{64}", str(value.get("source_event_id") or "")) + or not re.fullmatch(r"[0-9a-f]{8}", str(value.get("intake_msg_id") or "")) + or not isinstance(revision, int) + or revision < 0 + or not re.fullmatch( + r"[0-9a-f]{64}", str(value.get("requirements_digest") or "") + ) + or not isinstance(committed, int) + or committed <= 0 + ): + raise CollectionError( + "lifecycle intake commit marker does not match the provider generation" + ) + return exact + + +MODEL_REFUSED_EXIT = 3 + + +def _model_availability_command(args: argparse.Namespace) -> int: + """Is the model that was asked for the model that will serve the session? + + Exit 0 when it is, or when nothing on this machine can say. Exit + ``MODEL_REFUSED_EXIT`` when this host would answer the request with a + different model, or does not offer it at all, with the reason on standard + error, because the caller's job is to stop rather than to substitute. + """ + from sessionkit_inventory import worker_model as _worker_model + + state_dir = Path(load_config()["state_dir"]) + if args.command == "model-served": + _json_print( + _worker_model.record_served( + state_dir, args.provider, args.requested, args.served + ) + ) + return 0 + verdict = _worker_model.availability( + args.provider, args.model, state_dir=state_dir, environ=os.environ ) + if args.as_json: + _json_print(verdict) + if verdict["verdict"] not in _worker_model.REFUSALS: + if not args.as_json: + # An answer of "unknown" belongs on stderr with the refusals, not + # on stdout: the callers capture stderr, and an unknown that lands + # on stdout is an unknown the person never sees. "This is the model + # you asked for and nothing here could confirm it" is exactly the + # sentence R11 exists to make sure gets said. + stream = ( + sys.stdout + if verdict["verdict"] != _worker_model.UNKNOWN + else sys.stderr + ) + stream.write(f"{verdict['reason']}\n") + return 0 + sys.stderr.write(_worker_model.render_availability(verdict, flag=args.flag)) + return MODEL_REFUSED_EXIT + + +def _release_session_worktree(state_dir: Path, session_id: str) -> dict[str, Any]: + """Give back the working copy a closing session was given, if it had one. + + This runs inside the session that is ending -- `bye`, or a provider that + exited cleanly -- so it is deliberately quiet and never raises: an exit + must not fail because of a directory. The core keeps the copy, and says + why, whenever there is unmerged work or anybody still in it. + """ + try: + from sessionkit_inventory import worktrees as _worktrees + + verdict = _worktrees.release( + state_dir, shpool_id=session_id, environ=os.environ + ) + # A copy kept because there is work in it is the one thing on this path + # a person has to be told. The JSON goes to the caller's /dev/null; the + # sentence goes to standard error, which the close path leaves open for + # exactly this. + if verdict.get("action") in (_worktrees.KEPT, _worktrees.MANY): + sys.stderr.write(_worktrees.render_verdict(verdict)) + return verdict + except Exception: # noqa: BLE001 - an exit is never failed by this + return {"action": "none", "reason": "worktree release was unavailable"} + + +def _lifecycle_command(args: argparse.Namespace) -> int: + state_dir, session_id, boot_id, shell_pid, shell_start = _lifecycle_environment() _prove_lifecycle_caller(session_id, shell_pid, shell_start) - common = { - "state_dir": state_dir, - "session_id": session_id, - "boot_id": boot_id, - "shell_pid": shell_pid, - "shell_start_ticks": shell_start, - } if args.lifecycle_action == "provider-exited": provider = os.environ.get("SESSION_KIT_LIFECYCLE_PROVIDER", "") try: - exit_code = int( - os.environ.get("SESSION_KIT_LIFECYCLE_EXIT_CODE", "") - ) + exit_code = int(os.environ.get("SESSION_KIT_LIFECYCLE_EXIT_CODE", "")) except ValueError as exc: raise CollectionError("lifecycle provider exit code is invalid") from exc value = _lifecycle.record_provider_exit( - **common, + state_dir, + session_id=session_id, + boot_id=boot_id, + shell_pid=shell_pid, + shell_start_ticks=shell_start, provider=provider, + conversation_uuid=_lifecycle_committed_conversation( + provider=provider, + boot_id=boot_id, + shell_pid=shell_pid, + shell_start=shell_start, + ), exit_code=exit_code, input_tracking=True, ) + elif args.lifecycle_action == "closed": + # Intent, recorded by the shell that is about to end. A session with + # no exact conversation (a plain managed shell) has nothing to + # tombstone and says so instead of inventing a key. + # + # THE DOCUMENT MUST BELONG TO THIS SHELL, not merely to this session + # id. `_prove_lifecycle_caller` checks the caller against /proc; it + # says nothing about the record, which was then loaded by session id + # alone. A reused shpool id -- `main2` closes, a new `main2` opens + # before the collector prunes the old document -- let a fresh shell + # tombstone the PREVIOUS occupant's conversation, and `update_state` + # had guarded exactly this since it was written ("provider-exit + # lifecycle generation changed"). Found in review 2026-08-15, along + # with the comment further down that claimed this binding already + # existed; it was true of `load_last_exact` and not of this. + state = _lifecycle.load_state(state_dir, session_id) + if state is not None and ( + state.get("boot_id") != boot_id + or state.get("shell_pid") != shell_pid + or state.get("shell_start_ticks") != shell_start + ): + state = None + raw_provider = (state or {}).get("provider") + provider = raw_provider if isinstance(raw_provider, str) else "" + conversation = valid_uuid((state or {}).get("conversation_uuid")) + if args.only_with_conversation and (state or {}).get("keep"): + # `keep_session` says "automatic cleanup is off for this session", + # and a crash is not the person asking for anything. This flag is + # only ever passed by an automatic close, so the marker binds here + # exactly as it binds the reaper (reaper.py `_safe_candidate` + # requires provider_exit_keep is False). `bye` and a clean provider + # exit pass no flag and still close: those ARE the person asking. + _json_print({"recorded": False, "reason": "keep is set"}) + return 0 + if provider in PROVIDERS and not conversation: + # A provider ran here and the record names no conversation. + # That is not "there was none": Codex allocates its thread ID + # inside the TUI, so a session started with `sp new` never hands + # one back to the shell, and its provider-exit record carries + # none. Such a session could then never be closed into anything + # but history, the one shape that loses the conversation, so it + # sat in the list until the 72-hour reaper. + # + # The collector proved an exact conversation while the provider + # was still live and kept it for exactly this handoff. THIS record + # validates itself against the boot, the shell PID and the shell + # start (`_validated_last_exact`), so it can only ever name the + # conversation this shell generation ran; `record_provider_exit` + # refuses a conversation that changes inside one generation, so + # there is only one to name. The same record already decides what + # the picker offers to recover for these sessions + # (lifecycle.apply_provider_exit_states). + retained = _lifecycle.load_last_exact( + state_dir, + session_id, + boot_id=boot_id, + shell_pid=shell_pid, + shell_start_ticks=shell_start, + ) + if retained is not None and retained["provider"] == provider: + conversation = retained["uuid"] + if provider not in PROVIDERS or not conversation: + if args.only_with_conversation: + # The caller asked to close ONLY if the conversation survives + # the close. It would not, so nothing is recorded and nothing + # is closed; the caller keeps the session, which is the only + # thing left that still knows which conversation this was. + # Answering used to write the history-only row below, so + # merely ASKING put a closed row on the list for a session + # that then stayed open and kept running. + _json_print({"recorded": False, "reason": "no exact conversation"}) + return 0 + # A plain managed shell has no conversation to tombstone, but the + # person still closed something on purpose. It goes on the closed + # list as history: its scrollback is all there ever was. + row = _record_closed_session( + load_config(), provider="shell", shpool_id=session_id + ) + if isinstance(row, Mapping) and row.get("recorded") is False: + _json_print(row) + return 1 + released = _release_session_worktree(state_dir, session_id) + _json_print( + { + "recorded": True, + "provider": "shell", + "reason": "no exact conversation; shell history was recorded", + "worktree": released, + } + ) + return 0 + # Asked ONCE, and reported either way. An automatic close refuses on a + # no; a close the person asked for still happens, but says so, because + # "closed, and here it is in Closed sessions" is a promise the list + # will quietly break when it filters the row out as unreadable. + restorable = _conversation_is_readable(provider, conversation) + if args.only_with_conversation and not restorable: + # A syntactically valid UUID is not a conversation you can get + # back. The transcript is what a restore actually reads, and the + # closed-sessions list drops any row whose transcript this machine + # cannot read -- so closing on the UUID alone ended the live + # session AND produced a row the person would never even see. + # An automatic close asks the same question the list will ask + # later, before it does anything irreversible. + _json_print( + {"recorded": False, "reason": "the conversation cannot be read back"} + ) + return 0 + # THE LEDGER ROW FIRST, AND THE TOMBSTONE ONLY IF IT LANDED. + # + # These two records do opposite jobs. The ledger row is what makes a + # closed conversation findable; the tombstone is what stops the crash + # queue offering it as lost work. Written in the old order -- tombstone + # first, ledger second, neither checked -- a full disk produced the one + # state with no way back: no row to restore from, and recovery told not + # to offer it. The operator was told "the conversation is in Closed + # sessions" while it was reachable from nowhere at all. + # + # So the findable record is written first and its result is read. No + # row means no tombstone: a conversation the crash queue offers by + # mistake is a nuisance, and one that no surface will ever mention + # again is the thing this branch exists to prevent. + row = _record_closed_session( + load_config(), + provider=provider, + uuid=conversation, + shpool_id=session_id, + ) + if isinstance(row, Mapping) and row.get("recorded") is False: + _json_print( + { + "recorded": False, + "reason": row.get("reason") + or "the closed-sessions ledger could not be written", + } + ) + return 1 + # The row landed, so the conversation is findable and the close is + # safe to allow. A tombstone that cannot be written after that costs + # the person one wrong offer from the crash queue -- annoying, and + # strictly better than refusing a close whose conversation is already + # safely listed. It is reported rather than hidden. + tombstoned = True + try: + _lifecycle.record_close_intent( + state_dir, + provider=provider, + uuid=conversation, + ) + except (CollectionError, OSError): + tombstoned = False + released = _release_session_worktree(state_dir, session_id) + _json_print( + { + "recorded": True, + "provider": provider, + "uuid": conversation, + "tombstoned": tombstoned, + "restorable": restorable, + "worktree": released, + } + ) + return 0 elif args.lifecycle_action == "user-input": - value = _lifecycle.update_state(**common, event="user-input") + value = _lifecycle.update_state( + state_dir, + session_id=session_id, + boot_id=boot_id, + shell_pid=shell_pid, + shell_start_ticks=shell_start, + event="user-input", + ) elif args.lifecycle_action == "reopen": state = _lifecycle.load_state(state_dir, session_id) if state is None: @@ -6681,53 +5975,109 @@ def _lifecycle_command(args: argparse.Namespace) -> int: settings = load_config() live = snapshot(write_state=True, config=settings) item = lookup(live, session_id) + # One sentence per condition. The menu that offers this reopen prints + # what comes back here, and a single "recovery is unavailable" for six + # different causes told the person nothing about which of them to fix. + if not guard_live_inventory(live): + raise CollectionError( + "the live session list could not be trusted; nothing reopened" + ) + if item is None: + raise CollectionError( + "this terminal is no longer in the live session list; nothing reopened" + ) + if item.get("provider") != "shell": + raise CollectionError( + "this terminal is already running a provider; nothing reopened" + ) + if item.get("exited_provider") != state["provider"]: + raise CollectionError( + "the recorded exit was not the provider this terminal last ran;" + " nothing reopened" + ) + shell = item.get("shpool_shell") if ( - item is None - or not guard_live_inventory(live) - or item.get("provider") != "shell" - or item.get("exited_provider") != state["provider"] - or item.get("shpool_shell", {}).get("pid") != shell_pid - or item.get("shpool_shell", {}).get("process_start_ticks") - != shell_start + not isinstance(shell, Mapping) + or shell.get("pid") != shell_pid + or shell.get("process_start_ticks") != shell_start ): raise CollectionError( - "exact provider-exit recovery is unavailable; nothing reopened" + "this shell is not the one that recorded the exit; nothing reopened" ) recovery = item.get("recovery") if not isinstance(recovery, Mapping): raise CollectionError( - "exact provider recovery is unavailable; nothing reopened" + "no conversation was recorded for this terminal; nothing reopened" ) provider = state["provider"] uuid = valid_uuid(recovery.get("uuid")) - if recovery.get("provider") != provider or not uuid: + if recovery.get("provider") != provider: raise CollectionError( - "exact provider recovery is unavailable; nothing reopened" + f"the recorded conversation is not a {provider} one; nothing reopened" ) - expected = recovery_spec(provider, uuid, recovery.get("cwd")) - if recovery.get("argv") != expected["argv"]: + if not uuid: raise CollectionError( - "provider recovery command changed; nothing reopened" + "the recorded conversation has no usable id; nothing reopened" ) + expected = recovery_spec(provider, uuid, recovery.get("cwd")) + if recovery.get("argv") != expected["argv"]: + raise CollectionError("provider recovery command changed; nothing reopened") argv = list(expected["argv"]) if provider == "codex": argv[1:1] = ["-c", "check_for_update_on_startup=false"] + # The session's theme rides on the launch command everywhere + # else; a reopen without it came back in the stock theme and + # lost the window's color identity. + theme_color = session_color("codex", uuid, canonical_colors(load_config())) + if theme_color: + argv[1:1] = ["-c", f'tui.theme="sk-{theme_color}"'] + # And the same for the tab name (K3). Without it, a crash-reopen + # came back writing the personal config's title items over the + # name the kit had just put on the tab, so that one window + # disagreed with the picker until it was closed and opened again. + title_items = _codex_title_items(os.environ) + if title_items: + argv[1:1] = ["-c", f"tui.terminal_title={title_items}"] + elif provider == "claude": + signals = read_claude_transcript_signals(uuid) + provider_name = signals["agent_name"] or signals["ai_title"] + if provider_name: + argv[1:1] = ["--name", provider_name] cwd = expected.get("cwd") if cwd is not None and (not os.path.isabs(cwd) or not os.path.isdir(cwd)): raise CollectionError( "provider recovery directory is unavailable; nothing reopened" ) + # The snapshot above is evidence with an age, and everything between + # it and this line is work. A daemon that restarted in that window + # would leave this terminal describing a generation that no longer + # exists, so the generation is proven once more with nothing left to + # do but launch. + _prove_unchanged_daemon_generation(live) completed = subprocess.run(argv, cwd=cwd, check=False) + exit_code = completed.returncode % 256 value = _lifecycle.record_provider_exit( - **common, + state_dir, + session_id=session_id, + boot_id=boot_id, + shell_pid=shell_pid, + shell_start_ticks=shell_start, provider=provider, - exit_code=completed.returncode % 256, + exit_code=exit_code, input_tracking=True, ) - return 0 + # The reopened provider's own outcome is the answer, because the + # caller has to make the same decision it makes for a first exit: + # a clean exit closes the terminal, a crash stops at the menu. + # Reporting success either way redrew the menu after a clean /exit. + return 0 if exit_code == 0 else LIFECYCLE_REOPENED_PROVIDER_CRASHED else: value = _lifecycle.update_state( - **common, + state_dir, + session_id=session_id, + boot_id=boot_id, + shell_pid=shell_pid, + shell_start_ticks=shell_start, event="keep", keep=args.choice == "on", ) @@ -6738,29 +6088,66 @@ def _lifecycle_command(args: argparse.Namespace) -> int: def main(argv: Sequence[str] | None = None) -> int: args = _parser().parse_args(argv) try: + if args.command == "validate-worker-model": + from sessionkit_inventory.worker_model import validate_requested_model + + print(validate_requested_model(args.provider, args.model)) + return 0 if args.command == "platform": return _platform_command(args) if args.command == "lifecycle": return _lifecycle_command(args) + if args.command in ("model-availability", "model-served"): + return _model_availability_command(args) config = load_config() if args.command == "recovery-command": _json_print(recovery_spec(args.provider, args.uuid, args.cwd)) return 0 if args.command == "codex-bounce-title": - bounce_title = codex_bounce_prepare(args.uuid) + bounce_title = codex_bounce_prepare( + args.uuid, mirror_index=not args.read_only + ) if not bounce_title: return 1 print(bounce_title) return 0 + if args.command == "claude-bounce-title": + bounce_title, clear_marker = claude_bounce_prepare(args.uuid) + if bounce_title: + print(bounce_title) + return 0 + return 3 if clear_marker else 1 if args.command == "alias": return _alias_command(args, config) if args.command == "color": return _color_command(args, config) if args.command == "automatic-title": return _automatic_title_command(args, config) + if args.command == "account": + return _account_command(args, config) + if args.command == "worktree": + return _worktree_command(args, config) if args.command == "recovery-pending": if args.pending_action == "list": - _json_print(list_pending(config)) + if args.stream_recovery_snapshot: + _write_recovery_snapshot( + config=config, allow_large=args.allow_large_ledger + ) + return 0 + if args.stream_closed: + _write_closed_sessions( + limit=_closed_sessions.MAX_ENTRIES, + allow_large=args.allow_large_ledger, + json_lines=True, + ) + return 0 + _json_print( + recovery_list_payload( + config, + include_closed=not args.without_closed, + include_projection_inputs=args.projection_inputs, + ) + ) return 0 _json_print( acknowledge_pending( @@ -6771,6 +6158,160 @@ def main(argv: Sequence[str] | None = None) -> int: ) ) return 0 + if args.command == "recovery-selectors": + state_dir = _state_paths(config)["root"] + if args.printed_action == "remember": + _json_print( + _printed.remember_printed( + state_dir, + _printed_rows_from(args.payload, json_lines=args.json_lines), + ) + ) + return 0 + verdict = _printed.check_printed( + state_dir, args.selector, args.provider, args.uuid + ) + _json_print(verdict) + # A word that was printed for a different conversation is not a + # near miss to be reported and ridden past: the caller must be able + # to tell it apart from agreement without reading the document. + return 4 if verdict["verdict"] == _printed.DISAGREES else 0 + if args.command == "action-log": + _json_print( + _append_action_record( + _state_paths(config)["root"] / "action-events.jsonl", + args.action, + args.outcome, + args.session, + ) + ) + return 0 + if args.command == "origin": + state_dir = _state_paths(config)["root"] + if args.origin_action == "list": + _json_print(_origins.load_origins(state_dir)) + return 0 + _origins.record_origin( + state_dir, + shpool_id=args.shpool_id, + origin=args.origin, + ) + _json_print( + { + "recorded": True, + "shpool_id": args.shpool_id, + "origin": args.origin, + } + ) + return 0 + if args.command == "transcript": + if args.transcript_action == "locate": + found = _transcripts.locate_transcript(args.provider, args.uuid) + if found is None: + raise CollectionError( + f"no {args.provider} transcript on this machine for that" + " conversation" + ) + _json_print({"provider": args.provider, "path": os.fspath(found)}) + return 0 + _json_print(_transcript_reachability(config)) + return 0 + if args.command == "closed-sessions": + if args.closed_action in ("list", "stream"): + _write_closed_sessions( + limit=args.limit, + allow_large=args.allow_large_ledger, + json_lines=args.closed_action == "stream", + ) + return 0 + if args.closed_action == "forget": + _json_print( + {"forgotten": _closed_sessions.forget(args.provider, args.uuid)} + ) + return 0 + row = _record_closed_session( + config, + provider=args.provider, + uuid=args.uuid, + shpool_id=args.session, + title=args.title, + cwd=args.cwd, + origin=args.origin, + account_alias=args.account, + ) + _json_print(row) + # The answer has to be in the EXIT STATUS as well, because that is + # the only part the close paths read: both spell this verb + # `>/dev/null 2>&1 && return 0`. Exiting zero on a failed append + # made a lost row indistinguishable from a filed one and printed + # `Closed ...` over a session that reached no list at all -- the + # same shape as the sibling verb below, in the branch that handles + # a plain managed shell (found in review, 2026-08-15). + if isinstance(row, Mapping) and row.get("recorded") is False: + return 1 + return 0 + if args.command == "close-intent": + state_dir = _state_paths(config)["root"] + if args.intent_action == "list": + _json_print(_lifecycle.load_close_intents(state_dir)) + return 0 + # Refuse a key the tombstone store would reject, BEFORE writing + # anything. The ledger goes first from here on, so an argument that + # only the tombstone validates would otherwise leave a closed-list + # row for a conversation that can never be tombstoned or found. + _lifecycle.close_intent_key(args.provider, args.uuid) + # THE LEDGER ROW FIRST, AND THE TOMBSTONE ONLY IF IT LANDED. + # + # A tombstone says "not a crash"; the ledger row says "here it is". + # Written in the old order -- tombstone first, ledger second, + # neither checked -- a ledger that cannot be written produced the + # one state with no way back: no row to restore from, and recovery + # told not to offer it. This verb is the shared one, so BOTH live + # close paths ran it after they had already killed the session + # (`sp close` in lib/sh/sp_commands.sh, `k` in lib/sh/sp_picker.sh) + # and both were told `recorded: true`. The conversation was on + # neither surface, and nothing on screen said so. + # + # So the findable record is written first and its result is read. + # No row means no tombstone and no close: the crash queue keeps + # offering the conversation, which is the one remaining way back + # to it (found in review, 2026-08-15). + row = _record_closed_session(config, provider=args.provider, uuid=args.uuid) + if isinstance(row, Mapping) and row.get("recorded") is False: + # Non-zero on purpose. Both callers branch on the exit status, + # and this is the status that makes them say so out loud. + _json_print( + { + "recorded": False, + "provider": args.provider, + "uuid": args.uuid, + "reason": row.get("reason") + or "the closed-sessions ledger could not be written", + } + ) + return 1 + # The row landed, so the conversation is findable and suppressing + # the crash offer is safe. A tombstone that cannot be written after + # that costs one wrong offer from the crash queue -- reported, not + # hidden, and strictly better than losing the conversation. + tombstoned = True + try: + _lifecycle.record_close_intent( + state_dir, + provider=args.provider, + uuid=args.uuid, + ) + except (CollectionError, OSError): + tombstoned = False + _json_print( + { + "recorded": True, + "provider": args.provider, + "uuid": args.uuid, + "tombstoned": tombstoned, + } + ) + return 0 if args.command == "recovery-manifest": if args.manifest_action == "plan-legacy": plan = plan_legacy_recovery_manifest( @@ -6778,14 +6319,10 @@ def main(argv: Sequence[str] | None = None) -> int: args.continuity_evidence, args.release_sha, ) - _json_print( - publish_legacy_migration_plan(config, args.output, plan) - ) + _json_print(publish_legacy_migration_plan(config, args.output, plan)) elif args.manifest_action == "apply-legacy": _json_print( - apply_legacy_recovery_manifest( - config, args.plan, args.release_sha - ) + apply_legacy_recovery_manifest(config, args.plan, args.release_sha) ) else: _json_print( @@ -6797,16 +6334,24 @@ def main(argv: Sequence[str] | None = None) -> int: input_path = getattr(args, "input", None) or os.environ.get( "SESSION_KIT_INPUT_SNAPSHOT" ) - if input_path and args.command in {"render", "lookup"}: + if input_path and args.command in {"render", "lookup", "detail"}: inventory = load_inventory_input(input_path) else: inventory = snapshot( write_state=not ( - args.command == "snapshot" - and (args.no_write or args.guard_live) + args.command == "render" + or ( + args.command == "snapshot" + and (args.no_write or args.guard_live) + ) ), config=config, ) + # The state file keeps the collectors' own evidence; what leaves here + # for a screen carries the published view as well. Publishing at the + # boundary is why a stall flag can fold into `needs_you` for every + # reader without rewriting what the next collection compares against. + inventory = publish_view_fields(inventory) if args.command == "snapshot": if args.strict_live and not strict_live_inventory(inventory): print( @@ -6824,13 +6369,46 @@ def main(argv: Sequence[str] | None = None) -> int: elif args.command == "render": print(render_inventory(inventory, rows_only=args.rows)) elif args.command == "waiting-count": - print(sum(1 for item in inventory["sessions"] if item.get("needs_you"))) + # The state word each row displays, not the raw flags under it: a + # provider turn that ended normally reports `agent_status: idle` + # with `needs_you: false`, and `idle` is a needs-you session that + # has gone quiet. Counting the flags made this disagree with the + # picker it is supposed to summarise. + stall = _render.stall_threshold_seconds() + print( + sum( + 1 + for item in inventory["sessions"] + if _labels.session_state(item, stall_seconds=stall) + in {_labels.QUESTION, _labels.WAITING_ON_YOU, _labels.IDLE} + ) + ) elif args.command == "lookup": item = lookup(inventory, args.selector) if item is None: - print(f"no unique shpool session matches {args.selector!r}", file=sys.stderr) + print( + f"no unique shpool session matches {args.selector!r}", + file=sys.stderr, + ) return 2 _json_print(item) + elif args.command == "detail": + # `lookup` is the machine mode and carries every identifier. + # This is what a person is shown instead. + if lookup(inventory, args.selector) is None: + print( + "session-kit: no session matches that selector", + file=sys.stderr, + ) + return 2 + print( + render_detail( + inventory, + args.selector, + state_dir=Path(config["state_dir"]), + ), + end="", + ) return 0 except (CollectionError, OSError, ValueError) as exc: print(f"session inventory: {exc}", file=sys.stderr) diff --git a/lib/sessionkit_inventory/SNAPSHOT.md b/lib/sessionkit_inventory/SNAPSHOT.md new file mode 100644 index 0000000..12866b6 --- /dev/null +++ b/lib/sessionkit_inventory/SNAPSHOT.md @@ -0,0 +1,227 @@ +# The session snapshot, what a picker may read + +One JSON document describes every managed session. `sp list`, `sp detail`, the +login picker and any new screen read that one document, so a session cannot be +`needs you` on one screen and `working` on another. + +Produce it with: + +``` +shpool_status --json # the whole snapshot, refreshing state +shpool_status --strict-json # the same document, or nothing if it is not exact +python3 lib/session_inventory.py snapshot +``` + +`sp list` has no `--json` of its own; `shpool_status --json` is that channel and +is what `sp` itself uses (`sk_snapshot_file`). The login picker reads exactly +this document (`lib/sh/shpool_login_view.sh` projects it into a view). + +## The one rule + +**Additive only.** The picker a person is looking at parses this document live. +A renamed or removed field blinds that screen. Fields are added; they are never +renamed, retyped, or dropped. + +## Document + +| Field | Type | Meaning | +|---|---|---| +| `schema_version` | int | `1`. A reader that does not recognize it stops. | +| `generated_at` | string | UTC ISO-8601 of the collection. | +| `collection_start` | object | Persistent positive `sequence` allocated before process reading. A publisher refuses to replace any document stamped by a larger sequence. | +| `source` | string | `live`, `cache`, or `cold`. | +| `stale` | bool | `true` when the list is not live. Screens must say so and refuse actions. | +| `warnings` | list[string] | Why a collection was incomplete. Empty on a good live pass. | +| `daemon_generation` | object\|null | The session manager's own generation (`pid`, `process_start_ticks`, `boot_id`). | +| `sessions` | list[object] | Managed sessions. See below. | +| `outside_agents` | list[object] | Provider processes the kit does not manage. Rows only; no numbers, no actions. | + +Before a sequence is handed to a collector, it is fsynced into both the normal +counter and a separate durable allocation floor. If the normal counter is later +corrupt while that collector is still reading processes, rebuild starts above +the allocation floor rather than falling below the in-flight sequence. + +Every collection-derived state document has a witness in the private +`collection-markers/` directory containing its sequence and the hash of its +exact incoming bytes. Publication writes and fsyncs that witness **before** it +atomically replaces the document. During that short interval the hash does not +match the incumbent document, but freshness checks still honor the witness's +sequence. A crash can therefore leave a newer witness with older document +bytes, never newer document bytes with no ordering witness; the next still-newer +publication repairs the pair. A missing or structurally unreadable witness is +replaceable with a diagnostic only while at least one durable allocation record +is readable. A document or marker with neither allocation record is evidence +of lost ordering state, not evidence of a first install. + +`inventory-v2.lock` serializes new writers; the old `inventory.lock` becomes a +refusal fence so an in-flight pre-upgrade collector cannot publish after the +first marked collection starts. +The rollback manager detects a retained release without this fence-aware code, +publishes `inventory-rollback-v2`, durably restores a plain mode-0600 +`inventory.lock`, and selects that release while it still owns +`inventory-v2.lock`. The rollback hold makes an already-waiting generation-2 +publisher refuse read-only after the flip. It remains for the legacy release; +a newly launched, currently selected generation-2 publisher retires it on a +later forward update before recreating the refusal fence. The exact automatic +and emergency manual procedure is in +[`docs/update-and-rollback.md`](../../docs/update-and-rollback.md#inventory-publishing-fence). + +Collection sequence allocation has two durable records: the ordinary counter +and its allocation floor. Sequence 1 is allocated only when both records, all +six collection-derived documents, and all collection markers are absent. If +either record remains readable, recovery advances above both that durable +allocation bound and every readable document marker before it writes a fresh +floor. If neither record can be read while any collection document or marker +exists, the collector does not infer a number. It returns the live reading +through the read-only path with a diagnostic and publishes nothing; a skipped +refresh cannot collide with an allocation still in flight. The offline reset +for an estate that has lost both records is documented in +[`docs/update-and-rollback.md`](../../docs/update-and-rollback.md#lost-collection-ordering-state). + +## Session + +Everything below is present on every session row. The first block is the +collectors' evidence and predates this document; the second is published on top +of it by `publish_view_fields()` in `lib/session_inventory.py`. + +### Evidence (existing fields, never rename) + +| Field | Type | Meaning | +|---|---|---| +| `terminal_number` | int\|null | The number a person types. `null` when the session could not be numbered. | +| `row` | int | Collection order. Not a selector; never shown. | +| `shpool_id`, `shpool_id_raw`, `display_shpool_id` | string | Machine identifiers. **No screen may print one.** | +| `availability` | string | `ready` or `attached`. | +| `shpool_status` | string | The session manager's own word (`Attached`, `Disconnected`). | +| `provider` | string | `claude`, `codex`, `shell`, `unknown`. | +| `display_provider` | string | The provider to show; wins over `provider`. | +| `title`, `display_title`, `native_title` | string | `display_title` is the one to show. | +| `cwd` | string | The session's directory, the project a row belongs to. | +| `identity` | object | `uuid`, `pid`, `process_start_ticks`, `confidence`, `provenance`. Machine-only. | +| `account_email`, `account_plan` | string | Enrolled account facts. | +| `agent_status` | string | The provider's own state word. Evidence, not copy, translate before showing. | +| `needs_you` | bool | The session is a person's turn. | +| `reply_optional` | bool | The model would take a reply but is not blocked. | +| `setup_incomplete` | bool | The launch never finished. | +| `started_at_unix_ms` | int\|null | When the session was opened. | +| `process_age_seconds` | int\|null | Age of the provider process (a resume restarts it). | +| `recent_output_at_unix_ms`, `recent_output_age_seconds` | int\|null | Last output seen. | +| `transcript_idle` | bool | True only after the conversation transcript's path, size, and nanosecond mtime remain unchanged for the full configured idle window. Missing or unreadable evidence is false. | +| `_transcript_idle_evidence` | object | Private carried clock: transcript `path`, `size`, `mtime_ns`, observation/movement times, and the window that gathered them. | +| `blocking_question` | bool | Claude only: exact unmatched top-level AskUserQuestion evidence, or an unresolved top-level tool use correlated by timestamp to the current permission-prompt hook. Always false for Codex until the app-server records already read can prove a live picker/approval. | +| `subagents` | list[object] | Child agents, each with a `status`. | +| `aged_children` | list[object] | Live child shells and workers at least one hour old, each with `kind`, a safe display `title`, and `age_seconds`. Detail-only; it does not affect state or `active_subagent_count`. A worker with no exact live process has no age and is omitted. | +| `active_subagent_count` | int | Subagents not idle/finished. | +| `is_subagent` | bool | Present and true when exact provider/process evidence proves this row is a child, including a child whose parent is unavailable. | +| `parent_session` | object | Optional exact `provider`, `uuid`, and `provenance` link to the parent. Machine-only; absent when child status is proven but the parent cannot be resolved. | +| `worktree` | object\|null | `branch` and paths when the session is isolated. | +| `display_color`, `color` | string | The session's theme colour name. | +| `provider_title_state` | string | `ready`, `pending`, `deferred`. | +| `recovery` | object | `available`, `provider`, `uuid`. | +| `diagnostics` | list[string] | Why a row is incomplete. | +| `mutation_allowed`, `mutation_rejection_reason` | bool, string\|null | Whether the kit may act on this session. | + +### Published view (added by this pass) + +| Field | Type | Meaning | +|---|---|---| +| `number` | int\|null | `terminal_number` when it is a usable selector, else `null`. The one number a screen shows. | +| `state` | string | One of `question`, `needs you`, `working`, `idle`, or the non-state placeholder `pending`. The mapping is TOTAL: a collector word with no entry reads as `pending`, never as itself. `idle` is based only on unchanged transcript evidence; vendor idle notifications still map to `needs you`. | +| `needs_you` | bool | As above, **plus** any fleet stall flag (see below). One meaning, decided here. | +| `needs_you_reasons` | list[string] | Empty, or the fleet's degrees: `unsurfaced`, `unanswered`, `silent`. | +| `attached` | bool | `availability == "attached"`. True means open in another window. | +| `age_seconds` | int\|null | Seconds since the session was opened. `process_age_seconds` remains the provider process's own age. | +| `subagent_count` | int | `active_subagent_count`, or the length of `subagents`. | +| `account_alias` | string\|null | The account a session runs on. `null` when there is none, show `, `. | +| `model` | string\|null | The model identifier a session is running NOW, read from the conversation's own record so it follows a `/model` typed inside the session. `null` when there is none to read. | +| `display_model` | string\|null | That identifier as a product name (`Opus 5`, `GPT-5-Codex`). What every screen shows. | +| `model_source` | string\|null | `transcript` when the conversation's own record answered. Nothing else sets a model: the launch argument is never presented as the live model. | +| `launch_model` | string\|null | What the process was STARTED with, as evidence only. Never shown as the model a session is running, `/model` does not touch argv. | +| `model_state` | string\|null | Why there is no model: `not-applicable` (a shell), `no-reply-yet`, `unreadable`. Screens turn it into a word; the row does not carry copy. | +| `origin` | string\|null | How the session was started, a person or a machine. `null` until a collector records one. This is what the row IS; for an unstamped session it can be a reading of the moment, so nothing durable may be derived from it. | + +`project` is deliberately **not** published. The picker derives a project label +from `cwd` against `projects.tsv`, and a field of that name would silently +override it. Use `cwd`. + +`outside_agents` rows carry `provider`, `display_provider`, `title`, +`display_title`, `agent_status`, `subagents`, `active_subagent_count`, +`identity` and `cwd`. They are not published through the view above: they have +no number, no state, and nothing to act on. + +All picker surfaces sort session rows with the same published rule: ready +before attached; within each group, `needs_you` first, then +Claude/Codex/shell/unknown provider order, newest `recent_output_at_unix_ms`, +and natural `shpool_id_raw` as the stable tie-break. A nonzero +`active_subagent_count` is displayed directly after the state and before the +row's time, whether it came from provider evidence or grouped child rows. + +### Present only when something proved them + +These are written into published session rows, so a reader will meet them, but +they are deliberately **not** in the view above: each is absent unless the +evidence for it exists, and absence is the point. Test for presence; never read +one as `false` or `null` and treat that as a finding. Nothing in the picker's +own display depends on any of them. + +| Field | Type | Meaning | +|---|---|---| +| `origin_recorded` | string | The stamp itself, present only where creation recorded one. Anything that writes provenance down for good (a repair that re-declares an origin, a close that fills the closed-session ledger) reads this, never `origin`. Its **absence** is what says nobody was ever recorded as this session's creator. | +| `machine_driven` | bool | Present and true only where collection proved that a program, and no window, holds this Codex App Server's socket. It is the one reading that can put an unstamped row behind the machine count, and it is refused outright unless the reading of the machine was complete. | +| `app_server_window` | bool | Present and true only when a window was positively seen holding this Codex App Server's socket. It is how a provider restart is known to have finished: the shell that relaunches a provider blocks on it and cannot report its own success, so it leaves its bounce marker behind and a sighting here is what settles it. | + +## Fleet stalls fold into `needs_you` + +`~/.local/state/fleet/stalls.json` (override the directory with +`SESSION_KIT_FLEET_DIR` for the inventory reader, `FLEET_STATE_DIR` for the +pulse pass, two readers, two variables) records sessions +the fleet believes are stuck: + +```json +{"generated_at": 1786574881, + "stalled": [{"key": "<uuid|shpool id|title>", "reason": "unsurfaced", "since": 1786569781}]} +``` + +`unsurfaced`, `unanswered` and `silent` are degrees of **needs you** +(`docs/voice.md`) and are folded into `needs_you` here, with the reasons in +`needs_you_reasons`. A fourth reason, `orphan`, is dropped: an orphaned session +is a dead session, not a person's turn. + +The reader is bounded the same way the picker's is, file no larger than +256 KiB, at most 200 records, and ignored entirely once older than 300 seconds +(a flagger that stopped running is no evidence). A screen therefore never has to +read the fleet file itself, and the needs-you count on one screen can never +disagree with the rows on another. + +## Words + +Never spell a label into a screen. `lib/sessionkit_inventory/labels.py` holds +every word the kit shows a person, states, group headings, provider names, age +phrases, the placeholder `, `, the refusal and cancel lines, and +`tests/test_voice_labels.py` fails when a literal appears in a renderer instead. + +| Need | Call | +|---|---| +| State word for a row | `labels.session_state(row, stall_seconds=...)` | +| Provider name | `labels.provider_name(row["display_provider"])` | +| Group heading | `labels.group_heading(row["availability"])` | +| Where it can be opened | `labels.where_word(row["availability"])` | +| `needs you 12 min` | `labels.waiting_phrase(seconds)` | +| `last active 3h 9m ago` | `labels.last_active(seconds)`, the one time a row shows | +| `5m`, `2h 3m` | `labels.duration(seconds)` | +| Missing value | `labels.MISSING` | +| Nothing matched a filter | `labels.NO_MATCHES` | +| No such number | `labels.no_such_session(number)` | +| Nothing happened | `labels.CANCEL` | +| A real failure, on stderr | `labels.error("...")` | + +## Known drift, not yet converged + +* `sp list` prints the collector's own status word (`running`) where the picker + prints the translated one (`working`). `labels.STATE_WORDS` is the full + translation and `labels.LIST_STATE_WORDS` is the subset `sp list` uses today; + converging is a one-line change in `render.py` and a deliberate change to what + `sp list` prints. +* `lib/sh/shpool_login_render.sh` carries its own copy of the state table and + the age phrases, because it is bash and cannot import this module. The tests + compare the two, so a change on either side is caught rather than shipped. diff --git a/lib/sessionkit_inventory/account_guard.py b/lib/sessionkit_inventory/account_guard.py new file mode 100644 index 0000000..733a9ee --- /dev/null +++ b/lib/sessionkit_inventory/account_guard.py @@ -0,0 +1,980 @@ +"""When a live conversation may be moved to another account on its own. + +Session Kit could already move an exact conversation between two enrolled +accounts, but only because a human sat in the picker and chose it. Nothing +stopped one long conversation from spending an account down to nothing, and +nothing stopped the next choice from spending the one after it. This module is +the rule that closes that: a conversation whose own account has run dry may be +moved once, only into an account that still holds a stated reserve, and only +while facts about both accounts are fresh enough to believe. + +Everything here is a decision, never an action. :func:`plan` reads facts and +returns a verdict with a sentence a human can read; the caller performs the +move with the same machinery the manual switch uses, or performs nothing. The +split matters because every refusal in this file has to be provable from a +file on disk, and a function that also kills processes cannot be tested that +way. + +Four facts, and only these four, decide a move: + +* the kill switch (``account-switching-off``) is absent; +* the usage feed is fresh -- a stale roster is exactly how a machine drains an + account it believed was full, so unproven usage refuses rather than guesses; +* the conversation's own account is spent, by the weekly number the feed + publishes for that account; +* some other account is both selectable and still above the reserve. + +Usage is published per ACCOUNT, never per conversation. This module therefore +never estimates what one conversation spent, and no rule here depends on such +a number. The hop limit and the reserve are exact for precisely that reason: +both are account-level facts, counted and compared, never apportioned. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import time +from typing import Any, Mapping, Sequence +import uuid as uuidlib + +from . import accounts as _accounts +from .common import CollectionError, clean_text, valid_uuid +from .state_io import StateLock, atomic_write_private_json, read_private_json + + +GUARD_SCHEMA_VERSION = 1 + +# One hop. A conversation that has already been carried off a spent account +# stops on the second wall and reports; it never walks to a third account. +MAX_AUTOMATIC_HOPS = 1 + +# Percent of an account's weekly window that automatic switching may never +# consume. A move into an account already past this line is refused even when +# it is the only candidate left. +DEFAULT_RESERVE_PERCENT = 25 + +# Percent of the weekly window at which the conversation's own account counts +# as run dry. 100 means "the published weekly number says there is nothing +# left"; lowering it moves conversations earlier, which spends the single hop +# earlier and is therefore not the default. +DEFAULT_EXHAUSTED_PERCENT = 100 + +# The statuses the manual switch already accepts as safe to move, and the +# quiet period it already requires. Mirrored from +# lib/sh/sp_picker.sh:account_switch_stable_snapshot; tests pin the two +# copies together so the automatic path can never be laxer than the manual one. +MOVABLE_AGENT_STATUS = frozenset({"idle", "needs your reply", "reply optional"}) +MIN_QUIET_SECONDS = 5 + +# The ledger is the only thing enforcing "one move". Every bound on it is +# therefore fail-closed: reading more than MAX_LEDGER_THREADS keys raises +# rather than returning a slice, because a silent slice makes every +# conversation past the cut look like it has never moved. Counted rows are +# never removed by age; reaching the cap stops automation instead of granting +# an old conversation another move. +MAX_HOP_LEDGER_BYTES = 4 * 1024 * 1024 +MAX_LEDGER_THREADS = 4096 +MAX_LEDGER_HOPS = 8 + + +def hop_ledger_path(config: Mapping[str, Any]) -> Path: + return Path(str(config["state_dir"])) / "account-auto-hops.json" + + +def _hop_ledger_lock(config: Mapping[str, Any]) -> StateLock: + # Its own lock, not the account registry's: this ledger is written after a + # switch commits, and borrowing the registry lock would put a second + # waiter behind whatever long account operation happens to hold it. + root = Path(str(config["state_dir"])) + return StateLock(root, root / "account-auto-hops.lock") + + +def _percent_from_environment( + name: str, default: int, environ: Mapping[str, str] | None = None +) -> int: + source = os.environ if environ is None else environ + raw = source.get(name) + if raw is None or str(raw).strip() == "": + return default + try: + value = int(str(raw).strip()) + except ValueError: + # An unreadable setting is not permission to use a laxer one. Falling + # back to the shipped default keeps a typo from silently removing the + # reserve, which is the one number here that protects money. + return default + if not 0 <= value <= 100: + return default + return value + + +def reserve_percent(environ: Mapping[str, str] | None = None) -> int: + return _percent_from_environment( + "SESSION_KIT_ACCOUNT_RESERVE_PERCENT", DEFAULT_RESERVE_PERCENT, environ + ) + + +def exhausted_percent(environ: Mapping[str, str] | None = None) -> int: + return _percent_from_environment( + "SESSION_KIT_ACCOUNT_EXHAUSTED_PERCENT", DEFAULT_EXHAUSTED_PERCENT, environ + ) + + +def automatic_switching_off(config: Mapping[str, Any]) -> bool: + """True when the shipped kill switch disables every account change. + + The same sentinel the manual switch honours, deliberately: a second file + would mean an operator who switched account changes off could still be + surprised by an automatic one. + """ + return _accounts.kill_switch_path(config).exists() + + +# A published window number is a fraction: 0.55 means 55% used. A little over +# 1.0 is real (overage), but a value far above it is not this scale at all -- +# a feed that switched to whole percents would publish 55, which read as a +# fraction is 5500% used and would make the reserve meaningless in the +# permissive direction. Anything past this is treated as unreadable, so both +# the reserve and the exhaustion test fail closed instead of silently +# misjudging every account. +MAX_CREDIBLE_USAGE_FRACTION = 2.0 + + +def _usage_state(value: Any) -> tuple[str, float | None]: + """Classify one published usage number. + + Three outcomes, and keeping them apart is load-bearing: + + * ``("absent", None)`` -- the feed published nothing. Codex publishes no + five-hour number at all, so "absent" has to stay usable or automatic + switching could never touch a Codex conversation. + * ``("unreadable", None)`` -- the feed published something that is not a + window fraction. This must never read as "no objection". Collapsing it + into "absent" is how a target whose five-hour window was published as + ``100`` sailed past the wall check and got the conversation. + * ``("ok", x)`` -- a number to compare. + """ + if value is None: + return "absent", None + if isinstance(value, bool) or not isinstance(value, (int, float)): + return "unreadable", None + number = float(value) + if number != number or number < 0 or number > MAX_CREDIBLE_USAGE_FRACTION: + return "unreadable", None + return "ok", number + + +def _usage_fraction(value: Any) -> float | None: + """The number when there is one to trust, else None. + + Callers that must tell "absent" from "unreadable" use :func:`_usage_state` + directly. This stays for the places where both answers mean the same + thing: no proof, so no move. + """ + return _usage_state(value)[1] + + +def implausible_feed_reason(rows: Sequence[Any]) -> str: + """Why the whole published feed cannot be believed, or "" when it can. + + A single number cannot reveal a units change. If the feed ever published + whole percents instead of fractions, ``55`` is obviously wrong but ``0``, + ``1`` and ``2`` are not -- they would read as 0%, 100% and 200% used, and + the reserve would quietly stop meaning anything for those accounts. + + Across a roster the change is visible, because some account will publish a + value no fraction can take. So one unreadable number condemns the whole + feed rather than one row: judging the other accounts on the same feed + means trusting the same units that just proved themselves wrong. + """ + for row in rows: + if not isinstance(row, Mapping): + continue + for key in ("u5h", "u7d"): + if _usage_state(row.get(key))[0] == "unreadable": + return ( + "the usage feed published a %s figure for %s that is not a " + "window fraction" + % (key, clean_text(row.get("alias"), 12) or "an account") + ) + return "" + + +def _thread_key(provider: str, uuid: str) -> str: + exact = valid_uuid(uuid) + if provider not in _accounts.PROVIDERS or not exact: + raise CollectionError("automatic account hop needs an exact provider UUID") + return f"{provider}:{exact}" + + +def _empty_ledger() -> dict[str, Any]: + return {"schema_version": GUARD_SCHEMA_VERSION, "threads": {}} + + +def load_hop_ledger(config: Mapping[str, Any]) -> dict[str, Any]: + """Read the automatic-hop ledger. + + Raises when the file exists but cannot be read or parsed. That is on + purpose: an unreadable ledger means the hop count is unknown, and an + unknown hop count must never be rounded down to zero, which is what a + silent empty-ledger fallback would do on every future run. + """ + raw = read_private_json( + hop_ledger_path(config), + max_bytes=MAX_HOP_LEDGER_BYTES, + allow_missing=True, + ) + if raw is None: + return _empty_ledger() + if not isinstance(raw, Mapping) or raw.get("schema_version") != GUARD_SCHEMA_VERSION: + raise CollectionError("automatic account hop ledger has an unknown shape") + threads_raw = raw.get("threads") + if not isinstance(threads_raw, Mapping): + raise CollectionError("automatic account hop ledger has an unknown shape") + # No slice. A ledger larger than the cap is refused outright, because + # returning the first N keys silently reports "never moved" for every + # conversation past the cut -- which is the one-hop rule failing open on + # exactly the busiest estates. + if len(threads_raw) > MAX_LEDGER_THREADS: + raise CollectionError( + "automatic account hop ledger holds more than %d conversations" + % MAX_LEDGER_THREADS + ) + threads: dict[str, Any] = {} + for key, value in threads_raw.items(): + if not isinstance(key, str) or not isinstance(value, Mapping): + raise CollectionError("automatic account hop ledger has an unknown shape") + hops = value.get("hops") + if not isinstance(hops, Sequence) or isinstance(hops, (str, bytes)): + raise CollectionError("automatic account hop ledger has an unknown shape") + kept = [] + for hop in hops: + if not isinstance(hop, Mapping): + raise CollectionError( + "automatic account hop ledger has an unknown shape" + ) + kept.append( + { + "at_unix": int(hop.get("at_unix") or 0), + "from": clean_text(hop.get("from"), 12), + "to": clean_text(hop.get("to"), 12), + "reason": clean_text(hop.get("reason"), 200), + "state": clean_text(hop.get("state"), 16) or "committed", + "token": clean_text(hop.get("token"), 32), + } + ) + recorded = value.get("count") + threads[key] = { + # `count` is the authority and only ever moves with a hop's own + # lifecycle. The list is kept for the operator to read and may be + # trimmed; the count may not, so trimming can never lower it. + "count": max( + int(recorded) if isinstance(recorded, int) and not isinstance(recorded, bool) else 0, + len(kept), + ), + "hops": kept[-MAX_LEDGER_HOPS:], + "notices": _clean_notices(value.get("notices")), + } + return {"schema_version": GUARD_SCHEMA_VERSION, "threads": threads} + + +def _clean_notices(raw: Any) -> list[dict[str, Any]]: + if raw is None: + return [] + if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes)): + raise CollectionError("automatic account hop ledger has an unknown shape") + notices = [] + for item in raw: + if not isinstance(item, Mapping): + raise CollectionError("automatic account hop ledger has an unknown shape") + token = clean_text(item.get("token"), 64) + if not token: + continue + notices.append( + { + "token": token, + "sentence": clean_text(item.get("sentence"), 400), + "at_unix": int(item.get("at_unix") or 0), + "delivered": item.get("delivered") is True, + } + ) + return notices[-MAX_LEDGER_HOPS:] + + +def _empty_thread() -> dict[str, Any]: + return {"count": 0, "hops": [], "notices": []} + + +def _prune_empty_threads(ledger: dict[str, Any]) -> None: + """Drop only rows that no longer carry a move or a notice. + + Age can never make a counted hop disposable. Removing an old counted row + makes the next read report zero and grants that exact conversation a + second automatic move. Released reservations, by contrast, leave a + zero-count empty row and are safe to remove. + """ + for key, thread in list(ledger["threads"].items()): + if int(thread["count"]) == 0 and not thread["hops"] and not thread["notices"]: + del ledger["threads"][key] + + +def hop_count(config: Mapping[str, Any], provider: str, uuid: str) -> int: + """How many automatic moves this exact conversation has been given. + + A move is counted the moment it is reserved, before the provider is + signalled -- not when it completes. A crash between the two therefore + reads as "already moved", which refuses a second move rather than + granting one; the opposite ordering let a half-finished move look like no + move at all and buy another paid account. + + Manual moves are not counted. The operator choosing an account himself is + not the machine walking a conversation across their estate, and a rule that + conflated them would take their own escape hatch away after one use. + """ + thread = load_hop_ledger(config)["threads"].get(_thread_key(provider, uuid)) + return int(thread["count"]) if thread else 0 + + +def begin_hop( + config: Mapping[str, Any], + provider: str, + uuid: str, + source_alias: str, + target_alias: str, + *, + reason: str = "", + now: int | None = None, +) -> str: + """Reserve this conversation's one move. Returns the token that names it. + + Called BEFORE anything irreversible. From here on the conversation counts + as moved whatever happens next, so no failure can leave a moved + conversation with a zero-hop record. + + The limit is enforced HERE, under the lock, not only where the plan reads + the count. Two passes running at once -- the resident watchdog loop and a + hand-run `--once`, say -- can both read a count of zero before either + writes; only one of them can win the reservation, and the loser is refused + rather than granted a second paid account. + """ + key = _thread_key(provider, uuid) + stamp = int(time.time() if now is None else now) + token = uuidlib.uuid4().hex + with _hop_ledger_lock(config): + ledger = load_hop_ledger(config) + _prune_empty_threads(ledger) + thread = ledger["threads"].setdefault(key, _empty_thread()) + if int(thread["count"]) >= MAX_AUTOMATIC_HOPS: + raise CollectionError( + "this conversation has already had its %d automatic move" + % MAX_AUTOMATIC_HOPS + ) + thread["count"] = int(thread["count"]) + 1 + thread["hops"] = ( + thread["hops"] + + [ + { + "at_unix": stamp, + "from": clean_text(source_alias, 12), + "to": clean_text(target_alias, 12), + "reason": clean_text(reason, 200), + "state": "pending", + "token": token, + } + ] + )[-MAX_LEDGER_HOPS:] + atomic_write_private_json(hop_ledger_path(config), ledger) + return token + + +def _set_hop_state( + config: Mapping[str, Any], provider: str, uuid: str, token: str, state: str +) -> bool: + key = _thread_key(provider, uuid) + stamp = clean_text(token, 32) + if not stamp: + return False + with _hop_ledger_lock(config): + ledger = load_hop_ledger(config) + thread = ledger["threads"].get(key) + if not thread: + return False + found = False + for hop in thread["hops"]: + if hop.get("token") == stamp: + hop["state"] = state + found = True + if not found: + return False + atomic_write_private_json(hop_ledger_path(config), ledger) + return True + + +def commit_hop( + config: Mapping[str, Any], provider: str, uuid: str, token: str +) -> bool: + """Mark a reserved move as completed. The count does not change.""" + return _set_hop_state(config, provider, uuid, token, "committed") + + +def release_hop( + config: Mapping[str, Any], provider: str, uuid: str, token: str +) -> bool: + """Give a reserved move back, only after a proven return to the source. + + The single caller is the rollback path, and only where the conversation + has been proven to be running on the account it started on. Anywhere + the outcome is unproven the reservation stands, because refusing a second + automatic move costs the operator a manual switch while granting one costs + them an account. + """ + key = _thread_key(provider, uuid) + stamp = clean_text(token, 32) + if not stamp: + return False + with _hop_ledger_lock(config): + ledger = load_hop_ledger(config) + thread = ledger["threads"].get(key) + if not thread: + return False + reserved = next( + (hop for hop in thread["hops"] if hop.get("token") == stamp), None + ) + if not reserved or reserved.get("state") != "pending": + # A stale rollback command may outlive the move it belonged to. + # Once committed, that move is the conversation's one hop forever + # and no later caller may turn its count back into zero. + return False + remaining = [hop for hop in thread["hops"] if hop.get("token") != stamp] + thread["hops"] = remaining + thread["count"] = max(0, int(thread["count"]) - 1) + _prune_empty_threads(ledger) + atomic_write_private_json(hop_ledger_path(config), ledger) + return True + + +def queue_notice( + config: Mapping[str, Any], + provider: str, + uuid: str, + token: str, + sentence: str, + *, + now: int | None = None, +) -> bool: + """Record that the operator is owed one sentence about one move. + + Queuing is not telling them. The earlier version claimed the right to + announce and then called the notifier, so a delivery that failed was + filed as delivered and never retried -- the move happened and they were + never told. The claim now belongs to delivery: this only records the debt, + and it stays owed until something reports success. + + Returns True when this call created the entry, False when it already + existed (delivered or not), so a driver running every few minutes cannot + queue the same move twice. + """ + key = _thread_key(provider, uuid) + stamp = clean_text(token, 64) + if not stamp: + return False + when = int(time.time() if now is None else now) + with _hop_ledger_lock(config): + ledger = load_hop_ledger(config) + thread = ledger["threads"].setdefault(key, _empty_thread()) + if any(item["token"] == stamp for item in thread["notices"]): + return False + thread["notices"] = ( + thread["notices"] + + [ + { + "token": stamp, + "sentence": clean_text(sentence, 400), + "at_unix": when, + "delivered": False, + } + ] + )[-MAX_LEDGER_HOPS:] + atomic_write_private_json(hop_ledger_path(config), ledger) + return True + + +def notice_delivered( + config: Mapping[str, Any], provider: str, uuid: str, token: str +) -> bool: + """Mark one queued notice as actually delivered. Only a success may call.""" + key = _thread_key(provider, uuid) + stamp = clean_text(token, 64) + if not stamp: + return False + with _hop_ledger_lock(config): + ledger = load_hop_ledger(config) + thread = ledger["threads"].get(key) + if not thread: + return False + found = False + for item in thread["notices"]: + if item["token"] == stamp and not item["delivered"]: + item["delivered"] = True + found = True + if not found: + return False + atomic_write_private_json(hop_ledger_path(config), ledger) + return True + + +def pending_notices(config: Mapping[str, Any]) -> list[dict[str, Any]]: + """Every move the operator has not been told about yet, oldest first. + + This is the hand-off point for whatever route actually reaches them. A + queue that survives a failed delivery is the whole reason it exists, so + nothing here expires an undelivered entry. + """ + ledger = load_hop_ledger(config) + owed: list[dict[str, Any]] = [] + for key, thread in ledger["threads"].items(): + provider, _, uuid = key.partition(":") + for item in thread["notices"]: + if item["delivered"]: + continue + owed.append( + { + "provider": provider, + "uuid": uuid, + "token": item["token"], + "sentence": item["sentence"], + "at_unix": item["at_unix"], + } + ) + owed.sort(key=lambda item: (item["at_unix"], item["token"])) + return owed + + +def session_block_reason( + row: Mapping[str, Any] | None, + provider: str, + uuid: str, + *, + source_alias: str = "", +) -> str: + """Why this conversation must not be moved right now, or "" when it may be. + + The predicates are the manual switch's, plus two the automatic path needs + and a human at the picker does not. A switch kills and relaunches the + provider under a new profile, so a conversation that is mid-turn, running + sub-agents, or has printed something in the last few seconds would lose + work. + + The two extra checks are about acting on the right conversation. The + account that ran dry is decided from the persistent binding, but the + binding is a record and the live row is the fact: the collector publishes + the account the process is actually signed in to, and publishes + ``account_binding_mismatch`` when the two disagree. Moving on the record + while the process is on some other account restarts a conversation whose + live account was never the one found dry. + """ + if not isinstance(row, Mapping): + return "the conversation could not be found in the live inventory" + identity = row.get("identity") + identity = identity if isinstance(identity, Mapping) else {} + if row.get("provider") != provider or identity.get("uuid") != valid_uuid(uuid): + return "the conversation's identity did not match" + if row.get("mutation_allowed") is not True: + return "the conversation is not one Session Kit may change" + if row.get("account_binding_mismatch") is True: + return ( + "the account it is signed in to does not match the account on record" + ) + if source_alias: + live_alias = clean_text(row.get("account_alias"), 12) + if not live_alias: + return "the account it is signed in to could not be read" + if live_alias != source_alias: + return ( + "it is signed in to %s, not the %s that ran dry" + % (live_alias, source_alias) + ) + if row.get("subagents") or int(row.get("active_subagent_count") or 0) != 0: + return "the conversation is running sub-agents" + status = str(row.get("agent_status") or "").casefold() + if status not in MOVABLE_AGENT_STATUS: + return f"the conversation is {status or 'in an unknown state'}" + age = row.get("recent_output_age_seconds") + if isinstance(age, int) and not isinstance(age, bool) and age < MIN_QUIET_SECONDS: + return "the conversation printed something a moment ago" + if clean_text(row.get("model"), 128) and row.get("model_handoff_capable") is not True: + return ( + "its requested model is not bound to this managed shell generation" + ) + return "" + + +def target_still_eligible( + config: Mapping[str, Any], + provider: str, + alias: str, + *, + environ: Mapping[str, str] | None = None, +) -> dict[str, Any]: + """Re-read, without changing anything, whether one account may be used. + + The last look before the point of no return, and deliberately read-only: + it consults the registry and the usage feed and writes nothing, so asking + the question can never be what makes the answer true. It exists because + preparing a target profile is a *mutating* call, and the operator can + switch an account off at any moment -- including between the moment policy + chose it and the moment a conversation is handed to it. + """ + if automatic_switching_off(config): + return {"alias": alias, "eligible": False, "reason": "account changes are switched off"} + choices = _accounts.account_choices(config, provider) + if choices.get("roster_fresh") is not True or choices.get("advice_fresh") is not True: + return {"alias": alias, "eligible": False, "reason": "the usage feed is stale"} + rows = [ + item for item in choices.get("choices", []) if isinstance(item, Mapping) + ] + implausible = implausible_feed_reason(rows) + if implausible: + return {"alias": alias, "eligible": False, "reason": implausible} + row = next( + ( + item + for item in rows + if item.get("alias") == alias + ), + None, + ) + if row is None: + return {"alias": alias, "eligible": False, "reason": "it is not enrolled"} + if row.get("eligible") is not True: + return { + "alias": alias, + "eligible": False, + "reason": clean_text(row.get("state"), 80) or "it is not selectable", + } + reserve = reserve_percent(environ) + weekly = _usage_fraction(row.get("u7d")) + if weekly is None or weekly > (100 - reserve) / 100.0: + return { + "alias": alias, + "eligible": False, + "reason": "it is no longer above the %d%% reserve" % reserve, + } + five_state, five_hour = _usage_state(row.get("u5h")) + if five_state == "unreadable": + return { + "alias": alias, + "eligible": False, + "reason": "its five-hour usage is unreadable", + } + if five_hour is not None and five_hour >= 1.0: + return { + "alias": alias, + "eligible": False, + "reason": "its five-hour window is spent", + } + return {"alias": alias, "eligible": True, "reason": ""} + + +def spent_aliases( + config: Mapping[str, Any], + provider: str, + *, + environ: Mapping[str, str] | None = None, + choices: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Which enrolled accounts the feed currently reports as run dry. + + The cheap question a periodic driver asks first. Nothing is spent while + the feeds are stale, so a driver reading this never begins the expensive + per-session pass on numbers it could not believe. + """ + if provider not in _accounts.PROVIDERS: + raise CollectionError("account provider is invalid") + if automatic_switching_off(config): + return {"provider": provider, "fresh": False, "aliases": [], "off": True} + if choices is None: + choices = _accounts.account_choices(config, provider) + fresh = ( + choices.get("roster_fresh") is True and choices.get("advice_fresh") is True + ) + if not fresh: + return {"provider": provider, "fresh": False, "aliases": [], "off": False} + rows = choices.get("choices", []) + if implausible_feed_reason(rows): + return {"provider": provider, "fresh": False, "aliases": [], "off": False} + line = exhausted_percent(environ) + aliases = [] + for row in rows: + if not isinstance(row, Mapping): + continue + weekly = _usage_fraction(row.get("u7d")) + alias = str(row.get("alias") or "") + if alias and weekly is not None and weekly * 100 >= line: + aliases.append(alias) + return { + "provider": provider, + "fresh": True, + "aliases": sorted(aliases), + "off": False, + } + + +def _verdict( + action: str, + code: str, + sentence: str, + *, + provider: str, + uuid: str, + source_alias: str = "", + target_alias: str = "", + reserve: int, + hops: int, + candidates: list[dict[str, Any]] | None = None, + extra: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + value: dict[str, Any] = { + "schema_version": GUARD_SCHEMA_VERSION, + "action": action, + "reason_code": code, + "reason": sentence, + "provider": provider, + "uuid": valid_uuid(uuid) or "", + "source_alias": source_alias, + "target_alias": target_alias, + "reserve_percent": reserve, + "hops_used": hops, + "hop_limit": MAX_AUTOMATIC_HOPS, + "candidates": candidates or [], + } + if extra: + value.update(dict(extra)) + return value + + +def plan( + config: Mapping[str, Any], + provider: str, + uuid: str, + session_row: Mapping[str, Any] | None, + *, + environ: Mapping[str, str] | None = None, + choices: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Decide whether this conversation should be moved, and where. + + Returns a verdict; performs nothing. ``action`` is ``"switch"`` with a + ``target_alias``, or ``"hold"`` with a ``reason`` written for a human. + """ + exact = valid_uuid(uuid) + if provider not in _accounts.PROVIDERS or not exact: + raise CollectionError("automatic account plan needs an exact provider UUID") + reserve = reserve_percent(environ) + spent_line = exhausted_percent(environ) + try: + hops = hop_count(config, provider, exact) + except CollectionError as exc: + # Unknown hop count, so the one-hop rule cannot be enforced. Holding + # is the only answer that cannot move a conversation twice. + return _verdict( + "hold", + "ledger_unreadable", + "The record of past automatic moves could not be read (%s), so the " + "one-move limit cannot be enforced; nothing was moved." % exc, + provider=provider, + uuid=exact, + reserve=reserve, + hops=-1, + extra={"needs_attention": True}, + ) + + if automatic_switching_off(config): + return _verdict( + "hold", + "kill_switch", + "Automatic account changes are switched off; nothing was moved.", + provider=provider, + uuid=exact, + reserve=reserve, + hops=hops, + ) + + if choices is None: + choices = _accounts.account_choices(config, provider) + rows = [row for row in choices.get("choices", []) if isinstance(row, Mapping)] + + # Requirement in one line: no fresh fact, no move. The roster carries the + # usage numbers the reserve is measured against and the advice carries the + # choice of best account; either one stale and there is nothing to judge. + if choices.get("roster_fresh") is not True or choices.get("advice_fresh") is not True: + which = [] + if choices.get("roster_fresh") is not True: + which.append("account usage") + if choices.get("advice_fresh") is not True: + which.append("rotation advice") + return _verdict( + "hold", + "feed_stale", + "The %s feed is stale or unreadable, so no account change can be " + "judged; nothing was moved." % " and ".join(which), + provider=provider, + uuid=exact, + reserve=reserve, + hops=hops, + ) + + implausible = implausible_feed_reason(rows) + if implausible: + return _verdict( + "hold", + "feed_implausible", + "%s, so none of its numbers can be judged; nothing was moved." + % implausible, + provider=provider, + uuid=exact, + reserve=reserve, + hops=hops, + extra={"needs_attention": True}, + ) + + binding = _accounts.binding_for(config, provider, exact) + source_alias = str(binding["alias"]) if binding else "" + if not source_alias: + return _verdict( + "hold", + "source_unknown", + "The account this conversation is signed in to could not be " + "proven; nothing was moved.", + provider=provider, + uuid=exact, + reserve=reserve, + hops=hops, + ) + + source_row = next((row for row in rows if row.get("alias") == source_alias), None) + source_weekly = _usage_fraction(source_row.get("u7d")) if source_row else None + if source_weekly is None: + return _verdict( + "hold", + "usage_unknown", + "The feed publishes no weekly usage for %s, so it cannot be shown " + "to have run dry; nothing was moved." % source_alias, + provider=provider, + uuid=exact, + source_alias=source_alias, + reserve=reserve, + hops=hops, + ) + + if source_weekly * 100 < spent_line: + return _verdict( + "hold", + "source_has_quota", + "%s still has %d%% of its weekly window; nothing was moved." + % (source_alias, round((1 - source_weekly) * 100)), + provider=provider, + uuid=exact, + source_alias=source_alias, + reserve=reserve, + hops=hops, + extra={"source_weekly_used_percent": round(source_weekly * 100)}, + ) + + # From here the account really is spent, so every remaining refusal is one + # the operator needs to read. + if hops >= MAX_AUTOMATIC_HOPS: + return _verdict( + "hold", + "hop_limit", + "This conversation was already moved once automatically and %s is " + "now spent too. It stops here and waits for you rather than " + "walking to a third account." % source_alias, + provider=provider, + uuid=exact, + source_alias=source_alias, + reserve=reserve, + hops=hops, + extra={"needs_attention": True}, + ) + + blocked = session_block_reason( + session_row, provider, exact, source_alias=source_alias + ) + if blocked: + return _verdict( + "hold", + "session_busy", + "%s is spent but the conversation cannot be moved safely right " + "now: %s." % (source_alias, blocked), + provider=provider, + uuid=exact, + source_alias=source_alias, + reserve=reserve, + hops=hops, + ) + + ceiling = (100 - reserve) / 100.0 + candidates: list[dict[str, Any]] = [] + for row in rows: + alias = str(row.get("alias") or "") + if not alias or alias == source_alias or row.get("eligible") is not True: + continue + weekly = _usage_fraction(row.get("u7d")) + if weekly is None or weekly > ceiling: + continue + five_state, five_hour = _usage_state(row.get("u5h")) + if five_state == "unreadable": + # A five-hour number the feed published but this code cannot read + # is not permission to move here. Treating it as absent let a + # target whose short window was already spent take the one hop. + continue + if five_hour is not None and five_hour >= 1.0: + # Published as spent for the current five-hour window: moving here + # lands the conversation in a wall and burns the only hop. + continue + candidates.append( + { + "alias": alias, + "weekly_used_percent": round(weekly * 100), + "weekly_left_percent": round((1 - weekly) * 100), + "recommended": row.get("recommended") is True, + } + ) + candidates.sort(key=lambda item: (not item["recommended"], item["weekly_used_percent"], item["alias"])) + + if not candidates: + return _verdict( + "hold", + "no_eligible_account", + "%s is spent and no other account is both selectable and above " + "the %d%% reserve, so automatic switching stood down and the work " + "waits for you." % (source_alias, reserve), + provider=provider, + uuid=exact, + source_alias=source_alias, + reserve=reserve, + hops=hops, + extra={"needs_attention": True, "stood_down": True}, + ) + + chosen = candidates[0] + return _verdict( + "switch", + "move", + "%s is spent; moving this conversation to %s, which still has %d%% of " + "its weekly window." + % (source_alias, chosen["alias"], chosen["weekly_left_percent"]), + provider=provider, + uuid=exact, + source_alias=source_alias, + target_alias=chosen["alias"], + reserve=reserve, + hops=hops, + candidates=candidates, + extra={ + "source_weekly_used_percent": round(source_weekly * 100), + "advice_reason": clean_text(choices.get("recommendation_reason"), 240), + }, + ) diff --git a/lib/sessionkit_inventory/accounts.py b/lib/sessionkit_inventory/accounts.py new file mode 100644 index 0000000..76837d0 --- /dev/null +++ b/lib/sessionkit_inventory/accounts.py @@ -0,0 +1,1682 @@ +"""Owner-only provider account profiles and exact-thread switch transactions.""" + +from __future__ import annotations + +import contextlib +import hashlib +import json +import os +from pathlib import Path +import re +import select +import shutil +import stat +import subprocess +import sys +import tempfile +import time +from typing import Any, Mapping +import uuid as uuidlib + +from .common import CollectionError, clean_text, valid_uuid +from .state_io import StateLock, ensure_private_directory, read_private_json + + +ACCOUNT_SCHEMA_VERSION = 1 +PROFILE_ALIAS_RE = re.compile(r"[a-z][a-z0-9_-]{0,11}") +PROFILE_KEY_RE = re.compile(r"(claude|codex):([a-z][a-z0-9_-]{0,11})") +DEFAULT_ADVICE_MAX_AGE_SECONDS = 600 +MAX_REGISTRY_BYTES = 1024 * 1024 +# Claude's own `.claude.json` is not a kit state file and does not obey a kit +# size budget: the provider keeps one entry per project directory ever opened, +# so it grows with the operator's history and never shrinks. Reading it under +# the 1 MiB registry cap meant that crossing that line would silently stop the +# first-run marker from being copied -- and the symptom of that is exactly the +# bug this marker exists to fix, coming back years later with no explanation +# (lane B F8). Measured 2026-08-15: the default profile is 90,786 bytes and the +# two enrolled profiles 98,594 and 48,814, so this is headroom rather than a +# fix for today. The cap still exists, because an unbounded read of a file this +# code did not write is not acceptable either -- it is just set where a real +# provider file will not reach it, and passing it is now reported, never +# silent. +MAX_CLAUDE_STATE_BYTES = 64 * 1024 * 1024 +MAX_TRANSACTION_BYTES = 256 * 1024 +MAX_FEED_CONFIG_BYTES = 16 * 1024 +MAX_ARTIFACT_BYTES = 512 * 1024 * 1024 +PROVIDERS = frozenset({"claude", "codex"}) +# One rulebook, every profile. Each provider reads its own instruction file, and +# each enrolled profile keeps a private copy of it, so an instruction added in +# one place used to reach only that one place. The canonical file is rendered +# into every rulebook between these markers; anything outside them is that +# provider's or that profile's own text and is preserved untouched. +RULES_BEGIN = "<!-- BEGIN UNIVERSAL RULES" +RULES_END = "<!-- END UNIVERSAL RULES -->" +RULES_BLOCK_RE = re.compile( + re.escape(RULES_BEGIN) + r".*?" + re.escape(RULES_END), re.DOTALL +) +RULES_FILENAME = {"claude": "CLAUDE.md", "codex": "AGENTS.md"} +MAX_RULES_BYTES = 512 * 1024 + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _profile_key(provider: str, alias: str) -> str: + if provider not in PROVIDERS or not PROFILE_ALIAS_RE.fullmatch(alias): + raise CollectionError("account provider or alias is invalid") + return f"{provider}:{alias}" + + +def registry_path(config: Mapping[str, Any]) -> Path: + override = os.environ.get("SESSION_KIT_ACCOUNT_REGISTRY") + if override: + path = Path(override) + else: + path = Path(str(config["state_dir"])) / "accounts.json" + if not path.is_absolute(): + raise CollectionError("account registry path must be absolute") + return path + + +def account_root(config: Mapping[str, Any]) -> Path: + override = os.environ.get("SESSION_KIT_ACCOUNT_ROOT") + if override: + root = Path(override) + else: + data_home = Path( + os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share") + ) + root = data_home / "session-kit" / "accounts" + if not root.is_absolute(): + raise CollectionError("account profile root must be absolute") + return root + + +def kill_switch_path(config: Mapping[str, Any]) -> Path: + return Path(str(config["state_dir"])) / "account-switching-off" + + +def feed_config_path(config: Mapping[str, Any]) -> Path: + return Path(str(config["state_dir"])) / "account-feeds.json" + + +def configure_feeds( + config: Mapping[str, Any], roster_path: str, advice_path: str +) -> dict[str, Any]: + value = { + "schema_version": ACCOUNT_SCHEMA_VERSION, + "roster_path": roster_path, + "advice_path": advice_path, + } + for key in ("roster_path", "advice_path"): + path = Path(str(value[key])) + if not path.is_absolute() or "\n" in str(path) or "\r" in str(path): + raise CollectionError("account feed path must be absolute") + _atomic_private_json(feed_config_path(config), value) + return value + + +def _feed_paths(config: Mapping[str, Any]) -> tuple[Path, Path]: + raw = read_private_json( + feed_config_path(config), + max_bytes=MAX_FEED_CONFIG_BYTES, + allow_missing=True, + ) + if raw is None: + root = Path(str(config["state_dir"])) + return root / "account-roster.json", root / "account-advice.json" + if ( + not isinstance(raw, Mapping) + or raw.get("schema_version") != ACCOUNT_SCHEMA_VERSION + ): + raise CollectionError("account feed configuration is invalid") + paths = tuple( + Path(str(raw.get(key) or "")) for key in ("roster_path", "advice_path") + ) + if any(not path.is_absolute() for path in paths): + raise CollectionError("account feed configuration is invalid") + return paths[0], paths[1] + + +def _empty_registry() -> dict[str, Any]: + return { + "schema_version": ACCOUNT_SCHEMA_VERSION, + "generation": 0, + "profiles": {}, + "bindings": {}, + } + + +def _safe_profile_path( + provider: str, + alias: str, + raw: Any, + *, + config: Mapping[str, Any], + legacy: bool, +) -> Path: + if not isinstance(raw, str) or not raw.startswith("/"): + raise CollectionError("account profile path is invalid") + path = Path(raw) + if legacy: + expected = Path.home() / (".claude" if provider == "claude" else ".codex") + if path != expected: + raise CollectionError("legacy account profile is not the provider default") + else: + expected = account_root(config) / provider / alias + if path != expected: + raise CollectionError("account profile escaped its owner-only root") + return path + + +def _validate_registry(raw: Any, config: Mapping[str, Any]) -> dict[str, Any]: + if not isinstance(raw, Mapping): + raise CollectionError("account registry is not an object") + if raw.get("schema_version") != ACCOUNT_SCHEMA_VERSION: + raise CollectionError("account registry schema is unsupported") + generation = raw.get("generation") + if ( + not isinstance(generation, int) + or isinstance(generation, bool) + or generation < 0 + ): + raise CollectionError("account registry generation is invalid") + profiles_raw = raw.get("profiles") + bindings_raw = raw.get("bindings") + if not isinstance(profiles_raw, Mapping) or not isinstance(bindings_raw, Mapping): + raise CollectionError("account registry collections are invalid") + profiles: dict[str, dict[str, Any]] = {} + for key, value in profiles_raw.items(): + match = PROFILE_KEY_RE.fullmatch(str(key)) + if not match or not isinstance(value, Mapping): + raise CollectionError("account registry profile is invalid") + provider, alias = match.groups() + if value.get("provider") != provider or value.get("alias") != alias: + raise CollectionError("account profile identity does not match its key") + email = clean_text(value.get("email"), 254).casefold() + if "@" not in email or any(character.isspace() for character in email): + raise CollectionError("account profile email is invalid") + legacy = value.get("legacy") is True + path = _safe_profile_path( + provider, alias, value.get("profile_dir"), config=config, legacy=legacy + ) + verified_at = value.get("verified_at_unix_ms") + if ( + not isinstance(verified_at, int) + or isinstance(verified_at, bool) + or verified_at <= 0 + ): + raise CollectionError("account profile verification time is invalid") + profiles[str(key)] = { + "provider": provider, + "alias": alias, + "email": email, + "profile_dir": os.fspath(path), + "legacy": legacy, + "plan": clean_text(value.get("plan"), 80), + "verified_at_unix_ms": verified_at, + "enabled": value.get("enabled") is not False, + } + bindings: dict[str, dict[str, Any]] = {} + for thread_key, value in bindings_raw.items(): + if not isinstance(thread_key, str) or not isinstance(value, Mapping): + raise CollectionError("account binding is invalid") + parts = thread_key.split(":", 1) + exact = valid_uuid(parts[1]) if len(parts) == 2 else None + profile_key = value.get("profile") + if ( + parts[0] not in PROVIDERS + or not exact + or not isinstance(profile_key, str) + or profile_key not in profiles + or not profile_key.startswith(parts[0] + ":") + ): + raise CollectionError("account binding identity is invalid") + bound_at = value.get("bound_at_unix_ms") + if not isinstance(bound_at, int) or isinstance(bound_at, bool) or bound_at <= 0: + raise CollectionError("account binding time is invalid") + bindings[f"{parts[0]}:{exact}"] = { + "profile": profile_key, + "bound_at_unix_ms": bound_at, + "source": clean_text(value.get("source"), 40) or "verified", + } + return { + "schema_version": ACCOUNT_SCHEMA_VERSION, + "generation": generation, + "profiles": profiles, + "bindings": bindings, + } + + +def load_registry( + config: Mapping[str, Any], *, allow_missing: bool = True +) -> dict[str, Any]: + path = registry_path(config) + raw = read_private_json( + path, + max_bytes=MAX_REGISTRY_BYTES, + allow_missing=allow_missing, + ) + if raw is None: + return _empty_registry() + return _validate_registry(raw, config) + + +def _registry_lock(config: Mapping[str, Any]) -> StateLock: + root = Path(str(config["state_dir"])) + return StateLock(root, root / "accounts.lock") + + +class _FileChangedUnderUs(Exception): + """Someone else wrote the file between our read and our replace.""" + + +def _file_identity(path: Path) -> tuple[int, int, int, int] | None: + """(dev, inode, mtime_ns, size), or None when the file is not there. + + Enough to detect either shape of foreign write: an in-place write moves + mtime_ns and usually size, and a write-to-temp-then-rename moves the inode. + """ + try: + info = os.stat(path) + except OSError: + return None + return (info.st_dev, info.st_ino, info.st_mtime_ns, info.st_size) + + +_NO_GUARD: Any = object() + + +def _atomic_private_json(path: Path, value: Any, *, expect: Any = _NO_GUARD) -> None: + """Durably replace one owner-only JSON file. + + ``expect`` is a compare-and-swap guard: pass the file identity observed + BEFORE the content was read, and the replace is abandoned -- raising + ``_FileChangedUnderUs`` -- if the file has moved since. The default + sentinel means no guard, which is what every caller that owns its file + outright wants. Only the Claude profile, which the provider also writes, + passes one. + """ + ensure_private_directory(path.parent) + payload = (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode() + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb") as handle: + descriptor = -1 + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + # As late as it can possibly be: the whole point is to shrink the gap + # between "the file is still the one I read" and the replace itself to + # two adjacent syscalls. + if expect is not _NO_GUARD and _file_identity(path) != expect: + raise _FileChangedUnderUs(str(path)) + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if descriptor >= 0: + os.close(descriptor) + with contextlib.suppress(FileNotFoundError): + os.unlink(temporary) + + +def write_registry( + config: Mapping[str, Any], registry: Mapping[str, Any] +) -> dict[str, Any]: + checked = _validate_registry(registry, config) + _atomic_private_json(registry_path(config), checked) + return checked + + +def _default_profile_dir(provider: str) -> Path: + return Path.home() / (".claude" if provider == "claude" else ".codex") + + +def profile(config: Mapping[str, Any], provider: str, alias: str) -> dict[str, Any]: + registry = load_registry(config) + value = registry["profiles"].get(_profile_key(provider, alias)) + if not isinstance(value, Mapping) or value.get("enabled") is not True: + raise CollectionError("account profile is unavailable") + return dict(value) + + +def list_profiles( + config: Mapping[str, Any], provider: str | None = None +) -> list[dict[str, Any]]: + if provider is not None and provider not in PROVIDERS: + raise CollectionError("account provider is invalid") + registry = load_registry(config) + result = [ + dict(value) + for value in registry["profiles"].values() + if provider is None or value["provider"] == provider + ] + return sorted(result, key=lambda item: (item["provider"], item["alias"])) + + +def binding_for( + config: Mapping[str, Any], provider: str, uuid: str +) -> dict[str, Any] | None: + exact = valid_uuid(uuid) + if provider not in PROVIDERS or not exact: + return None + registry = load_registry(config) + binding = registry["bindings"].get(f"{provider}:{exact}") + if not binding: + return None + found = dict(registry["profiles"][binding["profile"]]) + found["binding_source"] = binding["source"] + found["bound_at_unix_ms"] = binding["bound_at_unix_ms"] + return found + + +def source_profile_for_thread( + config: Mapping[str, Any], provider: str, uuid: str +) -> dict[str, Any]: + """Return the exact bound source, or uniquely prove a legacy first switch.""" + exact = valid_uuid(uuid) + if provider not in PROVIDERS or not exact: + raise CollectionError("account source needs an exact provider UUID") + current = binding_for(config, provider, exact) + if current is not None: + return current + matches: list[dict[str, Any]] = [] + for item in list_profiles(config, provider): + try: + _artifact_paths(provider, Path(item["profile_dir"]), exact) + except CollectionError: + continue + matches.append(item) + if len(matches) != 1: + raise CollectionError("legacy account source is missing or ambiguous") + bind(config, provider, exact, matches[0]["alias"], source="legacy-first-switch") + found = binding_for(config, provider, exact) + if found is None: + raise CollectionError("legacy account source binding could not be recorded") + return found + + +def bind( + config: Mapping[str, Any], + provider: str, + uuid: str, + alias: str, + *, + source: str, +) -> dict[str, Any]: + exact = valid_uuid(uuid) + if provider not in PROVIDERS or not exact: + raise CollectionError("account binding needs an exact provider UUID") + with _registry_lock(config): + registry = load_registry(config) + key = _profile_key(provider, alias) + if key not in registry["profiles"] or not registry["profiles"][key]["enabled"]: + raise CollectionError("account binding target is unavailable") + registry["bindings"][f"{provider}:{exact}"] = { + "profile": key, + "bound_at_unix_ms": _now_ms(), + "source": clean_text(source, 40) or "verified", + } + registry["generation"] += 1 + return write_registry(config, registry) + + +def _provider_environment(provider: str, profile_dir: Path) -> dict[str, str]: + environment = os.environ.copy() + if provider == "claude": + environment["CLAUDE_CONFIG_DIR"] = os.fspath(profile_dir) + environment.pop("ANTHROPIC_API_KEY", None) + environment.pop("ANTHROPIC_AUTH_TOKEN", None) + environment.pop("CLAUDE_CODE_OAUTH_TOKEN", None) + elif provider == "codex": + environment["CODEX_HOME"] = os.fspath(profile_dir) + environment.pop("OPENAI_API_KEY", None) + environment.pop("CODEX_ACCESS_TOKEN", None) + else: + raise CollectionError("account provider is invalid") + return environment + + +def _run_codex_account_read(profile_dir: Path) -> dict[str, Any]: + requests = ( + json.dumps( + { + "method": "initialize", + "id": 0, + "params": { + "clientInfo": { + "name": "session-kit", + "title": "Session Kit", + "version": "1", + }, + "capabilities": {}, + }, + }, + separators=(",", ":"), + ) + + "\n" + + json.dumps({"method": "initialized", "params": {}}, separators=(",", ":")) + + "\n" + + json.dumps( + {"method": "account/read", "id": 1, "params": {"refreshToken": False}}, + separators=(",", ":"), + ) + + "\n" + ) + # Codex 0.145+ exits the app-server on stdin EOF before answering, so the + # probe must keep stdin open until the account/read reply has arrived. + try: + process = subprocess.Popen( + ["codex", "app-server", "--listen", "stdio://"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + env=_provider_environment("codex", profile_dir), + ) + except OSError as exc: + raise CollectionError("Codex account identity probe did not complete") from exc + reply: Mapping[str, Any] | None = None + timed_out = False + stdin = process.stdin + stdout = process.stdout + assert stdin is not None and stdout is not None + try: + try: + stdin.write(requests) + stdin.flush() + except OSError as exc: + raise CollectionError( + "Codex account identity probe did not complete" + ) from exc + deadline = time.monotonic() + 15 + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + timed_out = True + break + ready, _, _ = select.select([stdout], [], [], remaining) + if not ready: + timed_out = True + break + line = stdout.readline() + if not line: + break + try: + value = json.loads(line) + except ValueError: + continue + if isinstance(value, Mapping) and value.get("id") == 1: + reply = value + break + finally: + with contextlib.suppress(OSError): + process.kill() + with contextlib.suppress(OSError, subprocess.TimeoutExpired): + process.wait(timeout=5) + if timed_out and reply is None: + raise CollectionError("Codex account identity probe did not complete") + if isinstance(reply, Mapping): + result = reply.get("result") + account = result.get("account") if isinstance(result, Mapping) else None + if isinstance(account, Mapping) and account.get("type") == "chatgpt": + email = clean_text(account.get("email"), 254).casefold() + if "@" in email: + return { + "provider": "codex", + "email": email, + "plan": clean_text(account.get("planType"), 80), + } + raise CollectionError("Codex did not report a signed-in ChatGPT account") + + +def probe_identity(provider: str, profile_dir: Path) -> dict[str, Any]: + if not profile_dir.is_absolute() or not profile_dir.is_dir(): + raise CollectionError("account profile directory is unavailable") + if provider == "codex": + return _run_codex_account_read(profile_dir) + if provider != "claude": + raise CollectionError("account provider is invalid") + try: + completed = subprocess.run( + ["claude", "auth", "status", "--json"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=15, + check=False, + env=_provider_environment("claude", profile_dir), + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise CollectionError("Claude account identity probe did not complete") from exc + try: + value = json.loads(completed.stdout) + except ValueError as exc: + raise CollectionError("Claude account identity response was invalid") from exc + if not isinstance(value, Mapping) or value.get("loggedIn") is not True: + raise CollectionError("Claude did not report a signed-in account") + email = clean_text(value.get("email"), 254).casefold() + if "@" not in email: + raise CollectionError("Claude did not report an account email") + return { + "provider": "claude", + "email": email, + "plan": clean_text( + value.get("subscriptionType") or value.get("subscription_type"), 80 + ), + } + + +def _copy_regular(source: Path, destination: Path, *, mode: int = 0o600) -> None: + info = source.lstat() + if ( + not stat.S_ISREG(info.st_mode) + or stat.S_ISLNK(info.st_mode) + or info.st_uid != os.geteuid() + ): + raise CollectionError("profile configuration source is unsafe") + destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(destination.parent, 0o700) + temporary = destination.parent / f".{destination.name}.{uuidlib.uuid4().hex}.tmp" + try: + with source.open("rb") as reader, temporary.open("xb") as writer: + os.chmod(temporary, mode) + shutil.copyfileobj(reader, writer) + writer.flush() + os.fsync(writer.fileno()) + os.replace(temporary, destination) + finally: + with contextlib.suppress(FileNotFoundError): + temporary.unlink() + + +def _copy_tree(source: Path, destination: Path) -> None: + if not source.is_dir() or source.is_symlink(): + return + destination.mkdir(mode=0o700, parents=True, exist_ok=True) + for entry in source.iterdir(): + if entry.is_symlink(): + continue + target = destination / entry.name + if entry.is_dir(): + _copy_tree(entry, target) + elif entry.is_file(): + _copy_regular(entry, target) + + +def sync_profile_configuration(provider: str, profile_dir: Path) -> None: + source = _default_profile_dir(provider) + profile_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(profile_dir, 0o700) + if provider == "claude": + candidate = source / "CLAUDE.md" + if candidate.is_file() and not candidate.is_symlink(): + _copy_regular(candidate, profile_dir / "CLAUDE.md") + settings = source / "settings.json" + # A source profile with no settings file still yields one here: the + # auto-continue default below is a promise enrolment makes about + # every managed profile, not a transform applied only when there was + # something to copy (review lane rv-pdn-2, 2026-08-17). + value: dict[str, Any] = {} + if settings.is_file() and not settings.is_symlink(): + try: + parsed = json.loads(settings.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise CollectionError( + "Claude settings could not be safely copied" + ) from exc + if not isinstance(parsed, dict): + raise CollectionError("Claude settings could not be safely copied") + value = parsed + allowed_environment = { + "CLAUDE_AFK_TIMEOUT_MS", + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", + "CLAUDE_CODE_SUBAGENT_MODEL", + "CLAUDE_CODE_TMPDIR", + } + environment = value.get("env") + if isinstance(environment, dict): + value["env"] = { + key: item + for key, item in environment.items() + if key in allowed_environment and isinstance(item, str) + } + else: + value.pop("env", None) + # Claude Code 2.1.234 resumes a limited session by itself when the + # usage window resets. In a managed profile the account layer is + # the only thing that decides when a reset account spends again, + # so enrolment starts every profile with the switch off; the + # person can turn it back on per profile in /config afterwards. + value["autoContinueAtUsageLimit"] = False + destination = profile_dir / "settings.json" + temporary = profile_dir / f".settings.{uuidlib.uuid4().hex}.tmp" + try: + temporary.write_text( + json.dumps(value, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + os.chmod(temporary, 0o600) + os.replace(temporary, destination) + finally: + with contextlib.suppress(FileNotFoundError): + temporary.unlink() + for name in ("agents", "commands", "output-styles"): + _copy_tree(source / name, profile_dir / name) + return + if provider == "codex": + for name in ("AGENTS.md", "hooks.json"): + candidate = source / name + if candidate.is_file() and not candidate.is_symlink(): + _copy_regular(candidate, profile_dir / name) + candidate = source / "config.toml" + if candidate.is_file() and not candidate.is_symlink(): + raw = candidate.read_text(encoding="utf-8", errors="replace") + if not re.search( + r"(?im)^\s*[A-Za-z0-9_.-]*(?:token|secret|password|credential|api[_-]?key)[A-Za-z0-9_.-]*\s*=", + raw, + ): + _copy_regular(candidate, profile_dir / "config.toml") + for name in ("skills", "prompts", "themes"): + _copy_tree(source / name, profile_dir / name) + return + raise CollectionError("account provider is invalid") + + +def _load_owned_claude_state(path: Path) -> Mapping[str, Any] | None: + """Claude's own default state, or None with a stated reason. + + Every None here means a first-run offer will NOT be marked seen, and the + operator meets the question again on their next fresh profile. That used to + happen in complete silence, so the one symptom they would ever see was the + bug returning (lane B F8). Now each refusal says which check failed. + """ + try: + info = path.lstat() + if stat.S_ISLNK(info.st_mode): + reason = "it is a symlink" + elif not stat.S_ISREG(info.st_mode): + reason = "it is not a regular file" + elif info.st_uid != os.geteuid(): + reason = "it is owned by another user" + elif info.st_size > MAX_CLAUDE_STATE_BYTES: + reason = ( + f"it is {info.st_size} bytes, past the {MAX_CLAUDE_STATE_BYTES} cap" + ) + else: + reason = "" + if reason: + _unmarked(path, reason) + return None + value = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + # There is no default Claude profile on this machine. Nothing to copy + # from, and nothing has gone wrong -- printing "could not be read: No + # such file" would dress an ordinary state up as a fault, on every + # enrollment, forever. The states that MATTER all still speak. + return None + except OSError as error: + _unmarked(path, f"it could not be read: {error}") + return None + except ValueError as error: + _unmarked(path, f"it is not valid JSON: {error}") + return None + if not isinstance(value, Mapping): + _unmarked(path, "it does not hold a JSON object") + return None + return value + + +def _unmarked(path: Path, reason: str) -> None: + """Say why a first-run offer is going unanswered. Never silent.""" + print( + f"session inventory: not marking Claude's first-run offers seen -- " + f"{path} could not be used because {reason}", + file=sys.stderr, + ) + + +# First-run offers this kit may answer on the operator's behalf, by marking +# them SEEN. Two rules decide what may go here, and both must hold: +# 1. marking it seen changes nothing about how the session behaves, and +# 2. "you have already been shown this" is honestly true -- the operator has +# seen it, on the profile Claude runs by default. +# So a flag is only ever copied FROM the default profile, and only when it is +# already true there. Choosing a permission mode, a model, or an update policy +# is not on this list and must never be: those are answers, not acknowledgments. +# +# hasSeenAutoDefaultNudge -- the "Make auto mode your default permission mode?" +# offer. Seen means it stops being offered; the permission mode is untouched, +# which is exactly the decision this must not make. A brand-new session on a +# kit profile opened onto this question and the operator could not answer it +# (2026-08-15), because a fresh profile has never been shown it. +CLAUDE_FIRST_RUN_SEEN_FLAGS = ("hasSeenAutoDefaultNudge",) +# Three shots at a file the provider may be writing. A real collision is one +# save, so the second attempt wins; a third means the provider is writing +# continuously and this courtesy write should stand aside. +_CLAUDE_PROFILE_WRITE_ATTEMPTS = 3 +# Claude Code guards its own config writes with a proper-lockfile mkdir lock at +# `<config>.lock`, refreshed every 5s and considered stale after 10s (read out +# of the shipped 2.1.233 binary, 2026-08-15). +# +# This code deliberately does NOT take that lock. Claude acquires it with +# `retries: 0`: a Claude process that finds the lock held does not wait, it +# logs "Failed to save config with lock" and writes anyway down an UNLOCKED +# path that skips its own re-read. Taking the lock would therefore not +# serialise anything -- it would convert an ordinary collision into a +# guaranteed unlocked overwrite. Its exit-time plugin-usage flush takes no lock +# at all either. +# +# What the lock is good for is the other direction: its presence is a reliable +# "Claude is writing right now" signal, so this pass stands aside instead of +# starting a compare-and-swap that is certain to lose. +_CLAUDE_LOCK_STALE_SECONDS = 10.0 +_CLAUDE_LOCK_WAIT_SECONDS = 0.2 + + +def _provider_is_writing(path: Path) -> bool: + """True while Claude's own config lock is held and fresh.""" + try: + info = os.stat(path.with_name(path.name + ".lock")) + except OSError: + return False + return (time.time() - info.st_mtime) < _CLAUDE_LOCK_STALE_SECONDS + + +def _complete_claude_profile_onboarding(profile_dir: Path) -> None: + """Keep an authenticated isolated profile out of Claude's first-run offers. + + Compare-and-swap against the profile Claude itself writes, so a value the + provider set while this ran can never be clobbered: only missing keys are + filled, and the replace is abandoned rather than forced if the file moved + under us. See the write at the foot of this function for why refusing is + the right failure. + """ + path = profile_dir / ".claude.json" + value = read_private_json( + path, + max_bytes=MAX_CLAUDE_STATE_BYTES, + allow_missing=False, + ) + if not isinstance(value, dict): + raise CollectionError("Claude profile state is invalid") + changed = value.get("hasCompletedOnboarding") is not True + value["hasCompletedOnboarding"] = True + + default_state = _load_owned_claude_state(Path.home() / ".claude.json") + default_projects = ( + default_state.get("projects", {}) if isinstance(default_state, Mapping) else {} + ) + projects = value.get("projects") + if projects is not None and not isinstance(projects, dict): + # Some other shape entirely: the provider owns this key and this + # function does not understand what it is looking at. Leave it exactly + # as found and copy no trust into it -- normalising it to {} destroyed + # a real provider value (Codex finding 7). + projects = None + elif projects is None: + projects = {} + value["projects"] = projects + if isinstance(default_projects, Mapping) and projects is not None: + for project_path, project_state in default_projects.items(): + if ( + not isinstance(project_path, str) + or not isinstance(project_state, Mapping) + or project_state.get("hasTrustDialogAccepted") is not True + ): + continue + current = projects.get(project_path) + if not isinstance(current, dict): + current = {} + projects[project_path] = current + if current.get("hasTrustDialogAccepted") is not True: + current["hasTrustDialogAccepted"] = True + changed = True + # Only what the operator has already been shown, and only where this + # profile has said nothing itself. An existing value -- true or false -- + # is their answer and is left exactly as it is. + for flag in CLAUDE_FIRST_RUN_SEEN_FLAGS: + if flag in value: + continue + if isinstance(default_state, Mapping) and default_state.get(flag) is True: + value[flag] = True + changed = True + if not changed: + return + # THE WRITE. The provider owns this file too and takes no lock this code + # could share, so "re-read, then replace" cannot be made safe by reading + # later -- there is always a gap after the last read, and a provider save + # landing in it was simply lost (lane B F7, Codex 5). + # + # So the write is a compare-and-swap instead of a replace. The file's + # identity is captured BEFORE the content is read -- that ordering is what + # makes it sound: if the identity is unchanged at replace time then nothing + # touched the file across the whole read-decide-write span, so the content + # being written is current. If it did change, the attempt is thrown away + # and retried against the new content. And if it keeps changing, the write + # is ABANDONED, not forced: the cost of not writing is that a first-run + # offer is shown once, and the cost of forcing is losing whatever the + # provider just saved. Those are not close. + # + # The guard detects a provider save with certainty rather than by luck: + # Claude writes this file as `.tmp.<pid>.<hex>` beside the target followed + # by a rename, so every one of its saves changes the inode. (Read out of + # the shipped binary; the orphaned `.claude.json.tmp.*` files on this box + # are the same mechanism leaving litter.) And a merged write of ours is not + # ignored by a running Claude: it polls the file's mtime once a second and + # re-reads on any advance. + for _attempt in range(_CLAUDE_PROFILE_WRITE_ATTEMPTS): + if _provider_is_writing(path): + # Claude is mid-save. Starting a swap now just burns an attempt. + time.sleep(_CLAUDE_LOCK_WAIT_SECONDS) + if _provider_is_writing(path): + continue + before = _file_identity(path) + latest = read_private_json( + path, max_bytes=MAX_CLAUDE_STATE_BYTES, allow_missing=True + ) + merged = value + if isinstance(latest, dict): + merged = dict(latest) + merged["hasCompletedOnboarding"] = True + latest_projects = merged.get("projects") + if latest_projects is not None and not isinstance(latest_projects, dict): + latest_projects = None # not ours to reshape + elif latest_projects is None: + latest_projects = {} + merged["projects"] = latest_projects + for project_path, project_state in (projects or {}).items(): + if latest_projects is None: + break + current = latest_projects.get(project_path) + if not isinstance(current, dict): + latest_projects[project_path] = dict(project_state) + elif project_state.get("hasTrustDialogAccepted") is True: + current.setdefault("hasTrustDialogAccepted", True) + for flag in CLAUDE_FIRST_RUN_SEEN_FLAGS: + if flag not in merged and value.get(flag) is True: + merged[flag] = True + try: + _atomic_private_json(path, merged, expect=before) + return + except _FileChangedUnderUs: + continue + print( + "session inventory: not marking Claude's first-run offers seen -- " + f"{path} is being written by Claude right now, and overwriting it " + f"would lose what Claude just saved (gave up after " + f"{_CLAUDE_PROFILE_WRITE_ATTEMPTS} attempts)", + file=sys.stderr, + ) + + +def _register_profile( + config: Mapping[str, Any], + provider: str, + alias: str, + expected_email: str, + profile_dir: Path, + *, + legacy: bool, + enable: bool = True, +) -> dict[str, Any]: + """Record one verified profile. + + ``enable=True`` is enrolment: the operator is adding or adopting an + account, and enabling it is the point of the call. ``enable=False`` is + re-verification of an account that is already enrolled, and it may never + turn a disabled profile back on. + + That distinction is not cosmetic. The identity probe below runs a provider + binary and can take seconds; the registry is shared, and the operator can + disable an account inside that window. Writing ``enabled: True`` + unconditionally afterwards silently undid their decision, and any caller + that then used the profile spent money on an account they had switched off. + Re-verification therefore re-reads the row under the lock and refuses on + anything but a still-enabled profile at the same directory. + """ + if enable: + before: Mapping[str, Any] | None = None + else: + registry_before = load_registry(config) + found = registry_before["profiles"].get(_profile_key(provider, alias)) + before = dict(found) if isinstance(found, Mapping) else None + if before is None or before.get("enabled") is not True: + raise CollectionError("account profile is unavailable") + identity = probe_identity(provider, profile_dir) + email = clean_text(expected_email, 254).casefold() + if identity["email"] != email: + raise CollectionError( + "provider-reported account does not match the selected email" + ) + if provider == "claude" and not legacy: + _complete_claude_profile_onboarding(profile_dir) + with _registry_lock(config): + registry = load_registry(config) + key = _profile_key(provider, alias) + current = registry["profiles"].get(key) + if not enable: + # Compare-and-set against what was read before the probe. An + # unrelated write elsewhere in the registry is fine; a change to + # THIS profile is not, and being switched off is the change that + # matters most. + if not isinstance(current, Mapping) or current.get("enabled") is not True: + raise CollectionError( + "account %s was disabled while it was being verified; " + "it was left disabled" % alias + ) + # This is a real compare-and-set on the operator-controlled + # identity fields, not merely an enabled-bit check wearing that + # name. Another verifier may refresh plan/timestamp while the + # probe runs, but an operator changing the email, directory, + # legacy status, or enabled state makes this probe stale and it + # may not overwrite that newer decision. + guarded_fields = ( + "provider", + "alias", + "email", + "profile_dir", + "legacy", + "enabled", + ) + if before is None or any( + current.get(field) != before.get(field) for field in guarded_fields + ): + raise CollectionError( + "account %s changed while it was being verified; its newer " + "settings were left unchanged" % alias + ) + for other_key, other in registry["profiles"].items(): + if ( + other_key != key + and other["provider"] == provider + and other["email"] == email + ): + raise CollectionError("that provider account is already enrolled") + _safe_profile_path( + provider, alias, os.fspath(profile_dir), config=config, legacy=legacy + ) + registry["profiles"][key] = { + "provider": provider, + "alias": alias, + "email": email, + "profile_dir": os.fspath(profile_dir), + "legacy": legacy, + "plan": identity.get("plan", ""), + "verified_at_unix_ms": _now_ms(), + # Never resurrected: re-verification carries the value it just + # proved was True, and enrolment is the only path that sets it. + "enabled": True if enable else current.get("enabled") is True, + } + registry["generation"] += 1 + write_registry(config, registry) + return dict(registry["profiles"][key]) + + +def adopt_default( + config: Mapping[str, Any], provider: str, alias: str, expected_email: str +) -> dict[str, Any]: + return _register_profile( + config, + provider, + alias, + expected_email, + _default_profile_dir(provider), + legacy=True, + ) + + +def enroll( + config: Mapping[str, Any], provider: str, alias: str, expected_email: str +) -> dict[str, Any]: + if kill_switch_path(config).exists(): + raise CollectionError("account enrollment and switching are disabled") + target = account_root(config) / provider / alias + _safe_profile_path(provider, alias, os.fspath(target), config=config, legacy=False) + sync_profile_configuration(provider, target) + environment = _provider_environment(provider, target) + argv = ( + ["claude", "auth", "login", "--claudeai", "--email", expected_email] + if provider == "claude" + else ["codex", "login", "--device-auth"] + ) + completed = subprocess.run(argv, env=environment, check=False) + if completed.returncode != 0: + raise CollectionError("provider login did not complete") + return _register_profile( + config, provider, alias, expected_email, target, legacy=False + ) + + +def verify_profile( + config: Mapping[str, Any], provider: str, alias: str +) -> dict[str, Any]: + """Re-prove an already-enrolled profile. Never enables anything.""" + current = profile(config, provider, alias) + return _register_profile( + config, + provider, + alias, + current["email"], + Path(current["profile_dir"]), + legacy=current["legacy"], + enable=False, + ) + + +def _load_optional_json(path: Path, max_bytes: int) -> Mapping[str, Any] | None: + try: + info = path.lstat() + if ( + stat.S_ISLNK(info.st_mode) + or not stat.S_ISREG(info.st_mode) + or info.st_size > max_bytes + ): + return None + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return value if isinstance(value, Mapping) else None + + +def _provider_advice(advice: Mapping[str, Any], provider: str) -> Any: + """Return the rotation advice addressed to one provider. + + Advice started out Claude-only: one top-level ``use_now``, with no way to + say which provider it meant. The roster beside it has always carried both + providers, so a Codex account could be healthy, serving, and still never + recommended, the audit finding this fixes. + + A feed that covers both says so, either as ``use_now_<provider>`` or under + ``providers.<provider>.use_now``. The bare top-level key keeps its original + meaning, Claude, because that is what every feed writing it means today; + reading it for Codex would recommend a Claude account for a Codex session. + """ + keyed = advice.get(f"use_now_{provider}") + if isinstance(keyed, Mapping): + return keyed + providers = advice.get("providers") + if isinstance(providers, Mapping): + section = providers.get(provider) + if isinstance(section, Mapping) and isinstance(section.get("use_now"), Mapping): + return section["use_now"] + if provider == "claude": + return advice.get("use_now") + return None + + +def account_choices(config: Mapping[str, Any], provider: str) -> dict[str, Any]: + if provider not in PROVIDERS: + raise CollectionError("account provider is invalid") + now = int(time.time()) + configured_roster, configured_advice = _feed_paths(config) + roster_path = Path( + os.environ.get("SESSION_KIT_ACCOUNT_ROSTER") or configured_roster + ) + advice_path = Path( + os.environ.get("SESSION_KIT_ROTATION_ADVICE") or configured_advice + ) + roster = _load_optional_json(roster_path, 2 * 1024 * 1024) + advice = _load_optional_json(advice_path, 2 * 1024 * 1024) + try: + max_age = int( + os.environ.get( + "SESSION_KIT_ACCOUNT_ADVICE_MAX_AGE_SECONDS", + str(DEFAULT_ADVICE_MAX_AGE_SECONDS), + ) + ) + except ValueError: + max_age = DEFAULT_ADVICE_MAX_AGE_SECONDS + roster_ts = roster.get("ts") if roster else None + roster_fresh = isinstance(roster_ts, int) and 0 <= now - roster_ts <= max_age + health_by_email: dict[str, Mapping[str, Any]] = {} + if roster is not None and roster_fresh: + key = "accounts" if provider == "claude" else "codex_accounts" + rows = roster.get(key) + if isinstance(rows, list): + for row in rows: + if not isinstance(row, Mapping): + continue + email = clean_text(row.get("email"), 254).casefold() + if email: + health_by_email[email] = row + recommended_email = "" + advice_reason = "" + advice_ts = advice.get("ts") if advice else None + advice_fresh = isinstance(advice_ts, int) and 0 <= now - advice_ts <= max_age + if advice is not None and advice_fresh: + use_now = _provider_advice(advice, provider) + if isinstance(use_now, Mapping): + recommended_email = clean_text(use_now.get("account"), 254).casefold() + # Feeds have shipped the explanation under both spellings. Taking + # either beats showing a recommendation with no stated reason. + advice_reason = clean_text(use_now.get("why") or use_now.get("reason"), 240) + roster_state = ( + "fresh" if roster_fresh else ("stale" if roster is not None else "absent") + ) + choices: list[dict[str, Any]] = [] + for item in list_profiles(config, provider): + health = health_by_email.get(item["email"]) + # `serving` is the CLIProxy cron-lane routing toggle. Interactive + # sessions authenticate with the profile's own login and never ride + # that lane, so a resting account with a healthy probe is selectable. + # `serving` stays in the payload as advisory display. + # + # A stale or absent roster proves nothing about any account. Failing + # closed there turns a dead feed process into a machine where no + # session can be started at all, so eligibility falls open to the + # profile registry and the row says its health is unverified. + if roster_fresh: + eligible = bool( + item["enabled"] + and health + and str(health.get("health") or "").casefold() == "ok" + ) + else: + eligible = bool(item["enabled"]) + health_word = ( + clean_text(health.get("health"), 40) + if (health and roster_fresh) + else "unverified" + ) + if eligible: + state = "ready" if roster_fresh else "ready (feed %s)" % roster_state + elif not item["enabled"]: + state = "blocked: profile disabled" + elif not health: + state = "blocked: not in the account roster" + else: + state = "blocked: health %s" % (health_word or "unknown") + choices.append( + { + "alias": item["alias"], + "email": item["email"], + "plan": item["plan"], + "eligible": eligible, + "state": state, + "health": health_word, + "serving": health.get("serving") is True if health else False, + "u5h": health.get("u5h") if (health and roster_fresh) else None, + "u7d": health.get("u7d") if (health and roster_fresh) else None, + "recommended": eligible and item["email"] == recommended_email, + } + ) + recommendation = next((row for row in choices if row["recommended"]), None) + # The reason travels only with a live recommendation. An advised account + # that is present but blocked is reported as exactly that, so a renderer + # can say "X is advised but not selectable because Y" instead of nothing. + blocked_advice = None + if recommendation is None and recommended_email: + advised = next( + (row for row in choices if row["email"] == recommended_email), None + ) + if advised is not None: + blocked_advice = {"alias": advised["alias"], "state": advised["state"]} + return { + "schema_version": ACCOUNT_SCHEMA_VERSION, + "provider": provider, + "roster_fresh": roster_fresh, + "roster_state": roster_state, + "advice_fresh": advice_fresh, + "recommendation": recommendation["alias"] if recommendation else None, + "recommendation_reason": advice_reason if recommendation else "", + "recommendation_blocked": blocked_advice, + "choices": choices, + } + + +def _require_eligible_profile( + config: Mapping[str, Any], provider: str, alias: str +) -> None: + choices = account_choices(config, provider)["choices"] + selected = next((row for row in choices if row["alias"] == alias), None) + if not selected: + raise CollectionError(f"account {alias} is not enrolled for {provider}") + if selected.get("eligible") is not True: + # Name the actual predicate that failed; "unavailable" refusals that + # guess at a login problem send the operator to re-enroll a working + # account. + raise CollectionError( + f"account {alias} is not selectable: {selected.get('state')}" + ) + + +def resume_profile( + config: Mapping[str, Any], provider: str, alias: str +) -> dict[str, Any]: + item = verify_profile(config, provider, alias) + if provider == "claude": + # Every profile the kit can launch, not only the ones it creates from + # here on. A profile enrolled before this shipped has never been told + # the first-run offers were seen, and it is exactly such a profile the + # operator's blocked session was running on. This writes only when a + # marker is missing, so it costs one write per profile, once. + try: + _complete_claude_profile_onboarding(Path(str(item["profile_dir"]))) + except (CollectionError, OSError): + # A launch must never fail because a courtesy write did not land. + pass + return { + "provider": provider, + "alias": alias, + "email": item["email"], + "profile_dir": item["profile_dir"], + "plan": item["plan"], + } + + +def launch_profile( + config: Mapping[str, Any], provider: str, alias: str +) -> dict[str, Any]: + if kill_switch_path(config).exists(): + raise CollectionError("account enrollment and switching are disabled") + item = resume_profile(config, provider, alias) + _require_eligible_profile(config, provider, alias) + return item + + +def _transaction_root(config: Mapping[str, Any]) -> Path: + return Path(str(config["state_dir"])) / "account-switches" + + +def _transaction_path(config: Mapping[str, Any], txid: str) -> Path: + if not re.fullmatch(r"[0-9a-f]{32}", txid): + raise CollectionError("account switch transaction id is invalid") + return _transaction_root(config) / f"{txid}.json" + + +def _load_transaction(config: Mapping[str, Any], txid: str) -> dict[str, Any]: + raw = read_private_json( + _transaction_path(config, txid), max_bytes=MAX_TRANSACTION_BYTES + ) + if ( + not isinstance(raw, Mapping) + or raw.get("schema_version") != ACCOUNT_SCHEMA_VERSION + ): + raise CollectionError("account switch transaction is invalid") + if raw.get("txid") != txid or raw.get("provider") not in PROVIDERS: + raise CollectionError("account switch transaction identity is invalid") + if not valid_uuid(raw.get("uuid")): + raise CollectionError("account switch transaction UUID is invalid") + return dict(raw) + + +def _save_transaction(config: Mapping[str, Any], value: Mapping[str, Any]) -> None: + _atomic_private_json(_transaction_path(config, str(value["txid"])), value) + + +def prepare_switch( + config: Mapping[str, Any], + provider: str, + uuid: str, + source_alias: str, + target_alias: str, + cwd: str, + shpool_id: str, +) -> dict[str, Any]: + if kill_switch_path(config).exists(): + raise CollectionError("account enrollment and switching are disabled") + exact = valid_uuid(uuid) + if provider not in PROVIDERS or not exact or not os.path.isabs(cwd): + raise CollectionError("account switch identity is invalid") + source = profile(config, provider, source_alias) + target = profile(config, provider, target_alias) + _require_eligible_profile(config, provider, target_alias) + if source_alias == target_alias: + raise CollectionError("account switch target is already active") + current = binding_for(config, provider, exact) + if current is None or current["alias"] != source_alias: + raise CollectionError("account switch source binding changed") + txid = uuidlib.uuid4().hex + value = { + "schema_version": ACCOUNT_SCHEMA_VERSION, + "txid": txid, + "provider": provider, + "uuid": exact, + "cwd": cwd, + "shpool_id": clean_text(shpool_id, 128), + "source_alias": source_alias, + "source_profile": source["profile_dir"], + "target_alias": target_alias, + "target_profile": target["profile_dir"], + "status": "prepared", + "created_at_unix_ms": _now_ms(), + "updated_at_unix_ms": _now_ms(), + "artifacts": [], + } + _save_transaction(config, value) + return value + + +def _artifact_paths(provider: str, profile_dir: Path, uuid: str) -> list[Path]: + if provider == "claude": + candidates = list((profile_dir / "projects").glob(f"**/{uuid}.jsonl")) + if len(candidates) != 1: + raise CollectionError("Claude exact transcript is missing or ambiguous") + result = [candidates[0]] + companion = candidates[0].with_suffix("") + if companion.is_dir() and not companion.is_symlink(): + result.append(companion) + return result + candidates = list((profile_dir / "sessions").glob(f"**/rollout-*{uuid}.jsonl")) + if len(candidates) != 1: + raise CollectionError("Codex exact rollout is missing or ambiguous") + return [candidates[0]] + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + size = 0 + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + size += len(chunk) + if size > MAX_ARTIFACT_BYTES: + raise CollectionError("provider artifact exceeds the switch limit") + digest.update(chunk) + return digest.hexdigest() + + +def _copy_artifact(source: Path, destination: Path) -> list[dict[str, Any]]: + if source.is_symlink(): + raise CollectionError("provider artifact is a symlink") + if source.is_file(): + _copy_regular(source, destination) + return [{"path": os.fspath(destination), "sha256": _sha256(destination)}] + if not source.is_dir(): + raise CollectionError("provider artifact type is unsupported") + records: list[dict[str, Any]] = [] + destination.mkdir(mode=0o700, parents=True, exist_ok=True) + for entry in sorted(source.rglob("*")): + if entry.is_symlink(): + raise CollectionError("provider artifact tree contains a symlink") + relative = entry.relative_to(source) + target = destination / relative + if entry.is_dir(): + target.mkdir(mode=0o700, parents=True, exist_ok=True) + elif entry.is_file(): + _copy_regular(entry, target) + records.append({"path": os.fspath(target), "sha256": _sha256(target)}) + return records + + +def apply_switch(config: Mapping[str, Any], txid: str) -> dict[str, Any]: + value = _load_transaction(config, txid) + if value.get("status") != "prepared": + raise CollectionError("account switch transaction is not prepared") + provider = str(value["provider"]) + exact = str(value["uuid"]) + source_root = Path(str(value["source_profile"])) + target_root = Path(str(value["target_profile"])) + transaction_dir = _transaction_root(config) / txid + backup_root = transaction_dir / "backup" + ensure_private_directory(transaction_dir) + ensure_private_directory(backup_root) + artifacts: list[dict[str, Any]] = [] + copied_targets: list[Path] = [] + try: + for source in _artifact_paths(provider, source_root, exact): + relative = source.relative_to(source_root) + backup = backup_root / relative + target = target_root / relative + if target.exists(): + raise CollectionError( + "target profile already contains this exact conversation" + ) + copied_targets.append(target) + _copy_artifact(source, backup) + _copy_artifact(source, target) + artifacts.append( + { + "relative": os.fspath(relative), + "source_sha256": _sha256(source) if source.is_file() else "tree", + "kind": "file" if source.is_file() else "directory", + } + ) + except BaseException: + for target in reversed(copied_targets): + if target.is_dir() and not target.is_symlink(): + shutil.rmtree(target) + elif target.is_file() and not target.is_symlink(): + target.unlink() + with contextlib.suppress(OSError): + shutil.rmtree(backup_root) + raise + value["artifacts"] = artifacts + value["status"] = "target_launching" + value["updated_at_unix_ms"] = _now_ms() + _save_transaction(config, value) + return { + "txid": txid, + "alias": value["target_alias"], + "profile_dir": value["target_profile"], + "uuid": exact, + } + + +def commit_switch(config: Mapping[str, Any], txid: str) -> dict[str, Any]: + value = _load_transaction(config, txid) + if value.get("status") != "target_launching": + raise CollectionError("account switch transaction cannot commit") + provider = str(value["provider"]) + exact = str(value["uuid"]) + target_identity = probe_identity(provider, Path(str(value["target_profile"]))) + target_profile = profile(config, provider, str(value["target_alias"])) + if target_identity["email"] != target_profile["email"]: + raise CollectionError("target provider account identity changed") + source_root = Path(str(value["source_profile"])) + for artifact in value.get("artifacts", []): + relative = Path(str(artifact["relative"])) + source = source_root / relative + if source.is_dir() and not source.is_symlink(): + shutil.rmtree(source) + elif source.is_file() and not source.is_symlink(): + source.unlink() + bind( + config, + provider, + exact, + str(value["target_alias"]), + source="account-switch", + ) + value["status"] = "committed" + value["updated_at_unix_ms"] = _now_ms() + _save_transaction(config, value) + return value + + +def rollback_switch(config: Mapping[str, Any], txid: str) -> dict[str, Any]: + value = _load_transaction(config, txid) + if value.get("status") not in {"target_launching", "prepared"}: + raise CollectionError("account switch transaction cannot roll back") + source_root = Path(str(value["source_profile"])) + target_root = Path(str(value["target_profile"])) + backup_root = _transaction_root(config) / txid / "backup" + for artifact in value.get("artifacts", []): + relative = Path(str(artifact["relative"])) + source = source_root / relative + target = target_root / relative + backup = backup_root / relative + # The target may contain newer provider output. Preserve that newest + # valid artifact when returning to the source profile. + restore_from = target if target.exists() else backup + if source.exists(): + if source.is_dir() and not source.is_symlink(): + shutil.rmtree(source) + elif source.is_file() and not source.is_symlink(): + source.unlink() + _copy_artifact(restore_from, source) + if target.is_dir() and not target.is_symlink(): + shutil.rmtree(target) + elif target.is_file() and not target.is_symlink(): + target.unlink() + # A commit can fail after publishing the target binding but before its + # transaction record reaches `committed`. Recovery must restore both the + # provider artifact and the exact UUID-to-profile binding. + bind( + config, + str(value["provider"]), + str(value["uuid"]), + str(value["source_alias"]), + source="account-switch-rollback", + ) + value["status"] = "rolled_back" + value["updated_at_unix_ms"] = _now_ms() + _save_transaction(config, value) + return { + "txid": txid, + "alias": value["source_alias"], + "profile_dir": value["source_profile"], + "uuid": value["uuid"], + } + + +def transaction(config: Mapping[str, Any], txid: str) -> dict[str, Any]: + return _load_transaction(config, txid) + + +def canonical_rules_path() -> Path: + """Where the one rulebook lives. + + Owner-private by design: it holds the operator's own working rules and is + never part of a repository, so the kit only ever reads it from a + configurable location outside its own tree. + """ + override = os.environ.get("SESSION_KIT_RULES_FILE", "").strip() + if override: + return Path(override).expanduser() + config_home = os.environ.get("XDG_CONFIG_HOME", "").strip() + base = Path(config_home).expanduser() if config_home else Path.home() / ".config" + return base / "agent-rules" / "universal-rules.md" + + +def rules_targets(config: Mapping[str, Any]) -> list[dict[str, str]]: + """Every rulebook that should carry the canonical block. + + Both provider default homes are included even when no profile is enrolled: + an unenrolled machine still has one rulebook per provider, and leaving it + out would let the default home drift away from the profiles. + """ + found: list[dict[str, str]] = [] + seen: set[str] = set() + + def offer(provider: str, path: Path, label: str) -> None: + resolved = os.path.realpath(path) + if resolved in seen or not path.is_file(): + return + seen.add(resolved) + found.append({"provider": provider, "label": label, "path": os.fspath(path)}) + + for provider in sorted(PROVIDERS): + offer( + provider, + _default_profile_dir(provider) / RULES_FILENAME[provider], + f"{provider}:default", + ) + for item in list_profiles(config): + provider = str(item.get("provider", "")) + if provider not in RULES_FILENAME: + continue + profile_dir = str(item.get("profile_dir", "")) + if not profile_dir: + continue + offer( + provider, + Path(profile_dir) / RULES_FILENAME[provider], + f"{provider}:{item.get('alias', '')}", + ) + return found + + +def render_rules_block(body: str) -> str: + """Wrap the canonical text in a marked, fingerprinted block.""" + digest = hashlib.sha256(body.encode("utf-8")).hexdigest()[:12] + head = ( + f"{RULES_BEGIN} · generated · sha256:{digest} · DO NOT EDIT HERE — edit " + f"the canonical rules file and run `sp account sync-rules` -->" + ) + return f"{head}\n\n{body.strip()}\n\n{RULES_END}" + + +def _write_rulebook(path: Path, text: str) -> None: + temporary = path.with_name(path.name + ".sync-tmp") + try: + with open(temporary, "w", encoding="utf-8") as writer: + writer.write(text) + writer.flush() + os.fsync(writer.fileno()) + os.replace(temporary, path) + finally: + with contextlib.suppress(FileNotFoundError): + temporary.unlink() + + +def sync_rules(config: Mapping[str, Any], *, check: bool = False) -> dict[str, Any]: + """Render the canonical rulebook into every provider and profile copy. + + `check` reports drift and changes nothing, which is what the doctor and any + pre-push gate should call. A rulebook that has never carried the block keeps + all of its existing text: the block is added above it rather than replacing + it, so nothing a provider already relies on is lost by adopting the sync. + """ + source = canonical_rules_path() + if not source.is_file(): + return { + "ok": False, + "reason": "no canonical rules file", + "source": os.fspath(source), + "targets": [], + "drifted": 0, + } + if source.stat().st_size > MAX_RULES_BYTES: + raise CollectionError("the canonical rules file is implausibly large") + try: + body = source.read_text(encoding="utf-8") + except UnicodeDecodeError as error: + raise CollectionError( + f"the canonical rules file is not UTF-8 text: {source}" + ) from error + block = render_rules_block(body) + results = [] + drifted = 0 + for target in rules_targets(config): + path = Path(target["path"]) + # A symlinked rulebook belongs to whatever manages the link target, + # dotfiles usually. Rewriting through the link would edit that other + # system's file, and the backup copy refuses symlinks anyway -- so the + # skip happens here, by name, instead of five targets in as an abort + # that leaves the run half applied. + if path.is_symlink(): + results.append({**target, "state": "skipped", "reason": "symlink"}) + continue + try: + original = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + results.append({**target, "state": "skipped", "reason": "not UTF-8 text"}) + continue + if RULES_BLOCK_RE.search(original): + updated = RULES_BLOCK_RE.sub(lambda _: block, original, count=1) + else: + updated = f"{block}\n\n{original.lstrip()}" + if updated == original: + state = "current" + elif check: + state = "drifted" + drifted += 1 + else: + backup = path.with_name(path.name + ".bak-sync") + _copy_regular(path, backup) + _write_rulebook(path, updated) + state = "updated" + results.append({**target, "state": state}) + return { + "ok": True, + "source": os.fspath(source), + "targets": results, + "drifted": drifted, + } diff --git a/lib/sessionkit_inventory/attention.py b/lib/sessionkit_inventory/attention.py new file mode 100644 index 0000000..ba72ee0 --- /dev/null +++ b/lib/sessionkit_inventory/attention.py @@ -0,0 +1,330 @@ +"""What a Claude session needs, taken from the vendor's own push signal. + +Until now "needs your reply" was decided by one poll: `claude agents --json`, +run for every snapshot, read for a `status` string and a `waitingFor` field. +Two things are wrong with that as the PRIMARY signal. + +The first is timing. The poll only knows what was true when it ran, and it is +the most expensive thing a snapshot does -- it is the reason the picker cannot +simply ask more often. A session that puts a question on screen the instant +after a collection is invisible until the next one. + +The second is vocabulary, and it is the one that has already bitten. The +documented status for a working session is "working". The value a live 2.1.229 +actually reports is "busy" (probed live, see GOLDEN_POLL_ROW). Code written +against the documentation matches nothing, forever, silently. Every string this +module compares against is therefore pinned by a golden test, and the tests +carry the date and version they were taken on. + +So the primary signal becomes Claude Code's own Notification hook, which fires +at the moment attention is wanted, and the poll is demoted to reconciliation -- +still collected, still able to correct a hook record, never the only thing that +can notice a waiting session. The hook writes one small record per session; the +merge below decides which of the two is newer and therefore right. + +Kill switch: SESSION_KIT_ATTENTION_SOURCE=poll restores exactly the old +behaviour (the hook is written but never read). `hook` is the drill setting for +proving the hook path alone. Default `auto` merges, and an estate with no hook +installed simply has no records to merge, which is the old behaviour again. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import time +from typing import Any, Iterable, Mapping + +from .common import clean_text, valid_uuid + + +SCHEMA_VERSION = 1 +ATTENTION_DIRECTORY = "attention/claude" +# Hook records and their readers run on this machine. Five seconds covers a +# small scheduling/NTP correction without accepting a wall clock that has +# stepped far enough backwards to turn old input into evidence of a person now. +RECORD_FUTURE_TOLERANCE_MS = 5_000 + +# --------------------------------------------------------------------------- +# Pinned vendor vocabulary. Every string below was read off a live install, not +# a document; tests/test_attention_truth.py fails if any of them is changed +# without new live evidence. +# --------------------------------------------------------------------------- + +# `claude agents --json`, Claude Code 2.1.229, probed 2026-08-13: an interactive +# session that is working reports "busy". The docs say "working". They are +# wrong, and a `== "working"` comparison silently never fires. +# +# The row is the live SHAPE with every identifying field replaced -- pid, +# conversation id, name and working directory are all invented here, because +# this file ships in the public export and the machine it was read on is +# nobody's business. The pinned facts are the KEYS the command returns and the +# value of `status`, and neither depends on whose machine it was read on. Note what is +# NOT in the row: no `waitingFor`, no `aiTitle`, no `agentName`, no colour and +# no messaging socket. Everything the kit shows beyond these seven fields is +# read from somewhere else. +GOLDEN_POLL_ROW = { + "pid": 424242, + "cwd": "/home/operator/project", + "kind": "interactive", + "startedAt": 1786600761803, + "sessionId": "00000000-0000-4000-8000-000000000001", + "name": "An Operator Session", + "status": "busy", +} +POLL_BUSY_STATUS = "busy" +# Statuses seen on live rows so far: "busy" on a session mid-turn and "idle" on +# one waiting at its prompt (drill session, same day, same version). Neither is +# the documented "working"; both are read here rather than assumed. +GOLDEN_POLL_STATUSES = ("busy", "idle") +# The box updated itself to 2.1.231 during the same session; the command was +# re-run there and returned the identical seven fields and the same "busy". +# Two versions, one vocabulary -- which is worth knowing when the next one +# changes it. +POLL_NEEDS_YOU_STATUSES = frozenset({"waiting", "needs_input", "needs your reply"}) + +# The Notification hook's own matcher enum, read out of the 2.1.229 binary on +# 2026-08-13. Unknown values are recorded and treated as "no opinion" rather +# than dropped, so a new vendor type cannot make the kit lie. +NOTIFICATION_TYPES = ( + "permission_prompt", + "idle_prompt", + "auth_success", + "elicitation_dialog", + "elicitation_complete", + "elicitation_response", + "agent_needs_input", + "agent_completed", +) +# The types that mean a person is being waited on. +NEEDS_YOU_NOTIFICATIONS = frozenset( + {"permission_prompt", "idle_prompt", "elicitation_dialog", "agent_needs_input"} +) +# The types that answer one of those, and the hook events that do the same by +# happening at all: a prompt submitted is a human who has replied. +CLEARING_NOTIFICATIONS = frozenset( + {"elicitation_complete", "elicitation_response", "auth_success", "agent_completed"} +) +# The hook events that answer a raised hand by happening at all. SessionEnd +# removes the record rather than writing one (the session is gone, so is +# anything it needed), which is why only UserPromptSubmit ever appears in a +# record on disk -- both are named here because this frozenset is the contract +# config/claude/attention_hook.sh implements, and tests check the two against +# each other. +CLEARING_EVENTS = frozenset({"UserPromptSubmit", "SessionEnd"}) + + +def attention_root(state_dir: Path) -> Path: + return Path(state_dir) / ATTENTION_DIRECTORY + + +def record_path(state_dir: Path, uuid: str) -> Path | None: + exact = valid_uuid(uuid) + if not exact: + return None + return attention_root(state_dir) / f"{exact}.json" + + +def attention_source(environ: Mapping[str, str]) -> str: + """auto (merge) · poll (pre-hook behaviour) · hook (drills only).""" + value = (environ.get("SESSION_KIT_ATTENTION_SOURCE") or "auto").strip().lower() + return value if value in {"auto", "poll", "hook"} else "auto" + + +def read_record(state_dir: Path, uuid: str) -> dict[str, Any] | None: + """One session's last hook record, or None when there is no usable one. + + Unreadable is not "nothing needs you": the caller is told there is no + evidence here, and the poll -- which is still collected -- decides. + """ + path = record_path(state_dir, uuid) + if path is None: + return None + try: + with open(path, encoding="utf-8") as handle: + record = json.load(handle) + except (OSError, ValueError): + return None + if not isinstance(record, Mapping): + return None + if record.get("schema_version") != SCHEMA_VERSION: + return None + if valid_uuid(record.get("session_id")) != valid_uuid(uuid): + return None + # Typed strictly, because the two natural coercions both lie. `bool("false")` + # is True, so a record whose needs_you arrived as the STRING "false" would + # raise a hand nobody raised; and `isinstance(True, int)` is True in Python, + # so a recorded_at_ms of `true` would pass for the timestamp 1 -- which + # loses every merge comparison and silently disables the record. A record + # that is not exactly what the hook writes is not evidence: the poll, which + # is always collected, answers for that session instead. + recorded_at_ms = record.get("recorded_at_ms") + if not isinstance(recorded_at_ms, int) or isinstance(recorded_at_ms, bool): + return None + if recorded_at_ms <= 0: + return None + if recorded_at_ms > int(time.time() * 1000) + RECORD_FUTURE_TOLERANCE_MS: + # A future record cannot answer what is true now. In particular, after + # a backwards clock step an old UserPromptSubmit must not look freshly + # typed until the wall clock catches up. + return None + needs_you = record.get("needs_you") + if not isinstance(needs_you, bool): + return None + return { + "session_id": valid_uuid(record.get("session_id")), + "hook_event": clean_text(record.get("hook_event"), 40), + "notification_type": clean_text(record.get("notification_type"), 40), + "needs_you": needs_you, + "message": clean_text(record.get("message"), 200), + "recorded_at_ms": recorded_at_ms, + } + + +def read_all( + state_dir: Path, uuids: Iterable[object] | None +) -> dict[str, dict[str, Any]]: + """Hook records for exactly the sessions asked about.""" + records: dict[str, dict[str, Any]] = {} + for uuid in uuids or (): + exact = valid_uuid(uuid) + if not exact: + continue + record = read_record(state_dir, exact) + if record is not None: + records[exact] = record + return records + + +def needs_you_from_notification(notification_type: str) -> bool | None: + """True, False, or None for a type this kit has no opinion about.""" + if notification_type in NEEDS_YOU_NOTIFICATIONS: + return True + if notification_type in CLEARING_NOTIFICATIONS: + return False + return None + + +def poll_attention(raw_status: str, waiting_for: Any) -> tuple[str, bool]: + """The pre-hook reading of one poll row, unchanged and still the fallback.""" + status = (raw_status or "").casefold() + needs_you = bool(waiting_for) or status in POLL_NEEDS_YOU_STATUSES + display = ( + "needs your reply" + if needs_you + else ("working" if status == POLL_BUSY_STATUS else status) + ) + return display or "unknown", needs_you + + +def merge( + *, + poll_status: str, + poll_needs_you: bool, + poll_stamp_ms: int | None, + record: Mapping[str, Any] | None, + source: str = "auto", +) -> dict[str, Any]: + """Decide what one session needs, and say which evidence decided it. + + The rule is "the newer evidence wins", and the poll's age is the vendor's + own ``statusUpdatedAt`` on the session record -- not the time we ran the + poll, which would make the poll permanently the newest thing in the room. + Without a stamp there is nothing to compare against, so the rule changes + shape: only a hook record that RAISES attention outranks the poll, and it + keeps outranking it until the hook itself takes the hand down (a prompt + submitted, a clearing notification, the session ending). Be clear about the + cost of that, because it is not one refresh: a session whose operator + approved a permission prompt with a keypress sends no clearing signal and + stays "needs your reply" until its next turn boundary. That is the + direction to be wrong in -- a needs-you nobody notices costs a person their + evening, a stale one costs a glance -- but it is stickier than a poll, and + only on records with no stamp beside them. + """ + result = { + "agent_status": poll_status, + "needs_you": poll_needs_you, + "attention_source": "poll", + } + if source == "poll" or record is None: + return result + hook_needs_you = bool(record.get("needs_you")) + if source == "hook": + decided_by_hook = True + elif poll_stamp_ms is None: + # No stamp to compare against: only a hook record that RAISES attention + # outranks the poll. + decided_by_hook = hook_needs_you + else: + recorded_at_ms = record.get("recorded_at_ms") + decided_by_hook = ( + isinstance(recorded_at_ms, int) and recorded_at_ms >= poll_stamp_ms + ) + if not decided_by_hook: + return result + result["needs_you"] = hook_needs_you + result["attention_source"] = "hook" + result["notification_type"] = clean_text( + record.get("notification_type"), 40 + ) + if hook_needs_you: + result["agent_status"] = "needs your reply" + elif poll_needs_you: + # The hook says the question was answered and the poll has not caught + # up. The status the poll printed was "needs your reply"; the honest + # replacement is what the session is doing now, which the poll's own + # raw status already carries. + result["agent_status"] = "working" if poll_status == "needs your reply" else poll_status + return result + + +def default_state_dir(environ: Mapping[str, str], home: Path) -> Path: + configured = environ.get("SESSION_KIT_STATE_DIR") + if configured: + return Path(configured) + state_home = environ.get("XDG_STATE_HOME") + base = Path(state_home) if state_home else home / ".local" / "state" + return base / "session-kit" + + +def write_record( + state_dir: Path, + *, + session_id: str, + hook_event: str, + notification_type: str = "", + message: str = "", + needs_you: bool, + recorded_at_ms: int, +) -> Path | None: + """Replace one session's record atomically, owner-only. Used by the hook.""" + path = record_path(state_dir, session_id) + if path is None: + return None + payload = { + "schema_version": SCHEMA_VERSION, + "session_id": valid_uuid(session_id), + "hook_event": clean_text(hook_event, 40), + "notification_type": clean_text(notification_type, 40), + "message": clean_text(message, 200), + "needs_you": bool(needs_you), + "recorded_at_ms": int(recorded_at_ms), + } + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + temporary = path.with_name(f"{path.name}.tmp.{os.getpid()}") + descriptor = os.open( + temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600 + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(payload, handle, sort_keys=True) + handle.write("\n") + except Exception: + try: + os.unlink(temporary) + except OSError: + pass + raise + os.replace(temporary, path) + return path diff --git a/lib/sessionkit_inventory/closed_sessions.py b/lib/sessionkit_inventory/closed_sessions.py new file mode 100644 index 0000000..67fd4af --- /dev/null +++ b/lib/sessionkit_inventory/closed_sessions.py @@ -0,0 +1,795 @@ +"""Every session somebody closed on purpose, kept so it can come back. + +A close used to leave a tombstone that expired after seven days, and the +recovery feed only ever listed CRASHES, so a conversation closed deliberately +was unreachable from every surface the moment it ended, and unrecoverable for +good a week later. That is the opposite of the promise: closing a session ends +the terminal, never the conversation. + +This is the ledger that makes the promise true. Every deliberate close appends +one small record, provider, conversation, name, directory, when, and who +asked for the session, under the durable data directory rather than the state +directory, because state is what a boot may throw away and this may not be. +There is no retention window: an entry is two hundred bytes and stays as +durable evidence after restore. The exact-live projection hides it while the +conversation is open and can reveal it again after an unobserved loss. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass +import hashlib +import json +import os +from pathlib import Path +import sqlite3 +import stat +import tempfile +import time +from typing import Any, Callable, Iterable, Iterator, Mapping + +from .common import CollectionError, clean_text, valid_uuid +from .state_io import StateLock + +LEDGER_NAME = "closed-sessions.jsonl" +LEDGER_LOCK_NAME = "closed-sessions.lock" +MAX_LEDGER_BYTES = 4 * 1024 * 1024 +# This name remains for compatibility with older fixture builders. Rewrites no +# longer use the old 4 MiB bound: they validate and copy one original row at a +# time, so file size no longer changes their memory safety. + +# More than three million ordinary close rows fit under 512 MiB. At ten closes +# every day that is over eight centuries of history, well beyond a plausible +# lifetime for one machine's transcript store. The ceiling protects an +# unattended refresh from a pathological file, not normal history. A person +# can deliberately stream a larger valid file with the recovery override named +# in every refusal. +MAX_LIST_LEDGER_BYTES = 512 * 1024 * 1024 +READ_CHUNK_BYTES = 64 * 1024 +MAX_ROW_BYTES = 64 * 1024 +MAX_ENTRIES: int | None = None +PROVIDERS = ("claude", "codex", "shell") +# What a close can say about who owned the conversation. The third value is +# the one that matters: a session nobody stamped has no origin, and this row +# is read back on every future restore, so a guess written here becomes +# permanent provenance for a conversation that never had any. +ORIGINS = ("human", "machine") +UNKNOWN_ORIGIN = "unknown" + +_LARGE_LEDGER_RECOVERY = ( + "Run `sp recover --allow-large-ledger` to stream the complete ledger, " + "then `sp restore --allow-large-ledger NUMBER` to restore a shown row." +) + + +@dataclass(frozen=True) +class _LedgerScan: + size: int + lines: int + device: int | None + inode: int | None + + +def data_dir(environ: Mapping[str, str] | None = None) -> Path: + """Durable, not state: a closed conversation outlives a state reset.""" + env = environ if environ is not None else os.environ + explicit = env.get("SESSION_KIT_DATA_DIR") + if explicit: + return Path(explicit).expanduser() + base = env.get("XDG_DATA_HOME") or os.path.join( + env.get("HOME") or str(Path.home()), ".local", "share" + ) + return Path(base) / "session-kit" + + +def ledger_path(environ: Mapping[str, str] | None = None) -> Path: + env = environ if environ is not None else os.environ + explicit = env.get("SESSION_KIT_CLOSED_LEDGER") + return Path(explicit).expanduser() if explicit else data_dir(env) / LEDGER_NAME + + +def ledger_lock_path(environ: Mapping[str, str] | None = None) -> Path: + return ledger_path(environ).with_name(LEDGER_LOCK_NAME) + + +def _prepare_directory(environ: Mapping[str, str] | None = None) -> Path: + env = environ if environ is not None else os.environ + directory = ledger_path(environ).parent + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + if not env.get("SESSION_KIT_CLOSED_LEDGER"): + try: + os.chmod(directory, 0o700) + except OSError: + pass + return directory + + +def _entry(value: Any) -> dict[str, Any] | None: + if not isinstance(value, Mapping): + return None + provider = value.get("provider") + if provider not in PROVIDERS: + return None + uuid = valid_uuid(value.get("uuid")) or "" + if provider != "shell" and not uuid: + return None + closed_at = value.get("closed_at_unix_ms") + if not isinstance(closed_at, int) or isinstance(closed_at, bool) or closed_at <= 0: + return None + origin = value.get("origin") + return { + "provider": provider, + "uuid": uuid, + "title": clean_text(value.get("title"), 120), + # Where that title came from, so a later reader can tell a NAME from + # a label the kit generated. Without it a recovery list has to either + # show "Claude in v2 at Aug 14 23:44" as though somebody chose it, or + # guess by shape. Absent on records written before this was kept. + "title_source": clean_text(value.get("title_source"), 40), + "cwd": clean_text(value.get("cwd"), 4096), + "closed_at_unix_ms": closed_at, + # Three values, not two. A session nobody stamped has no origin to + # record, and writing "human" for it would be inventing at the moment + # of writing exactly the durable provenance this ledger exists to + # carry -- an absence would come back on every future restore as a + # positive claim. "unknown" says what is true; every reader treats it + # as the person's, which is what an unproven session is. + "origin": origin if origin in ORIGINS else UNKNOWN_ORIGIN, + "shpool_id": clean_text(value.get("shpool_id"), 64), + "account_alias": clean_text(value.get("account_alias"), 12), + } + + +def record_close( + *, + provider: str, + uuid: str = "", + title: str = "", + title_source: str = "", + cwd: str = "", + origin: str = UNKNOWN_ORIGIN, + shpool_id: str = "", + account_alias: str = "", + environ: Mapping[str, str] | None = None, + now_unix_ms: int | None = None, +) -> dict[str, Any]: + """Append one close only when the resulting ledger is completely listable.""" + entry = _entry( + { + "provider": provider, + "uuid": uuid, + "title": title, + "title_source": title_source, + "cwd": cwd, + "closed_at_unix_ms": int(time.time() * 1000) + if now_unix_ms is None + else now_unix_ms, + "origin": origin, + "shpool_id": shpool_id, + "account_alias": account_alias, + } + ) + if entry is None: + raise CollectionError( + "a closed-session record needs a provider and conversation" + ) + directory = _prepare_directory(environ) + path = ledger_path(environ) + line = (json.dumps(entry, ensure_ascii=False, sort_keys=True) + "\n").encode( + "utf-8" + ) + # One stable sidecar lock owns every ledger read, append and replacement. + # O_APPEND keeps the append itself indivisible, while the lock also keeps + # legal short-write loops from interleaving and keeps a restore rewrite + # from replacing a close that landed after its read. + with StateLock(directory, ledger_lock_path(environ)): + before = _scan_records_unlocked(path, maximum=MAX_LIST_LEDGER_BYTES) + _parse_record(line, before.lines + 1) + after_size = before.size + len(line) + if after_size > MAX_LIST_LEDGER_BYTES: + raise CollectionError( + f"closed-sessions ledger would grow from {before.size} to " + f"{after_size} bytes; the complete-list safety limit (listing " + f"ceiling) is {MAX_LIST_LEDGER_BYTES} bytes. No row or " + f"tombstone was written. {_LARGE_LEDGER_RECOVERY}" + ) + flags = os.O_RDWR | os.O_APPEND | os.O_CREAT + flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(path, flags, 0o600) + try: + opened = os.fstat(descriptor) + if ( + not stat.S_ISREG(opened.st_mode) + or opened.st_uid != os.geteuid() + or stat.S_IMODE(opened.st_mode) != 0o600 + ): + raise CollectionError( + f"closed-sessions ledger must be a mode-0600 current-owner " + f"regular file: {path}" + ) + if opened.st_size != before.size or ( + before.device is not None + and (opened.st_dev, opened.st_ino) != (before.device, before.inode) + ): + raise CollectionError( + f"closed-sessions ledger changed before append: {path}" + ) + try: + _write_all(descriptor, line, "recording a closed session") + os.fsync(descriptor) + appended: bytes | None = None + + def prove_appended_row( + _entry: dict[str, Any] | None, raw: bytes, number: int + ) -> None: + nonlocal appended + if number == before.lines + 1: + appended = raw + + # Prove the post-state through a duplicate of the descriptor + # that received the append. Reopening the pathname here can + # validate an atomically substituted file while the new row is + # stranded on an unlinked inode. + after = _scan_descriptor_unlocked( + descriptor, + path=path, + maximum=MAX_LIST_LEDGER_BYTES, + visit=prove_appended_row, + ) + if ( + after.size != after_size + or after.lines != before.lines + 1 + or appended != line + ): + raise CollectionError( + f"closed-sessions ledger changed after append: {path}" + ) + _fsync_directory(directory) + _prove_descriptor_authoritative(descriptor, path, action="appended") + except BaseException: + # A short write, injected corruption, or post-write read error + # must not leave a new row behind when no tombstone will be + # allowed. The original validated prefix is still exact. + os.ftruncate(descriptor, before.size) + os.fsync(descriptor) + raise + finally: + os.close(descriptor) + return entry + + +def _fsync_directory(directory: Path) -> None: + descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def _write_all(descriptor: int, payload: bytes, action: str) -> None: + view = memoryview(payload) + while view: + written = os.write(descriptor, view) + if written <= 0: + raise OSError(f"short write while {action}") + view = view[written:] + + +def _parse_record(raw_line: bytes, number: int) -> dict[str, Any] | None: + if len(raw_line) > MAX_ROW_BYTES: + raise CollectionError( + f"closed-sessions ledger row {number} exceeds the " + f"{MAX_ROW_BYTES}-byte row safety limit; no partial list was returned" + ) + if not raw_line.strip(): + return None + try: + value = json.loads(raw_line.decode("utf-8")) + except (UnicodeDecodeError, ValueError) as exc: + raise CollectionError( + f"closed-sessions ledger has a malformed row near retained line {number}" + ) from exc + entry = _entry(value) + if entry is None: + raise CollectionError( + f"closed-sessions ledger has an invalid row near retained line {number}" + ) + return entry + + +def _scan_descriptor_unlocked( + descriptor: int, + *, + path: Path, + maximum: int | None, + visit: Callable[[dict[str, Any] | None, bytes, int], None] | None = None, +) -> _LedgerScan: + """Validate the inode behind an existing descriptor through dup + seek.""" + + scan_descriptor = os.dup(descriptor) + try: + os.lseek(scan_descriptor, 0, os.SEEK_SET) + opened = os.fstat(scan_descriptor) + if not stat.S_ISREG(opened.st_mode) or opened.st_uid != os.geteuid(): + raise CollectionError( + f"closed-sessions ledger must be a current-owner regular file: {path}" + ) + size = opened.st_size + if maximum is not None and size > maximum: + raise CollectionError( + f"closed-sessions ledger is {size} bytes; the complete-list " + f"safety limit is {maximum} bytes, so no partial list was " + f"returned. {_LARGE_LEDGER_RECOVERY}" + ) + remaining = size + buffered = bytearray() + number = 0 + while remaining: + chunk = os.read(scan_descriptor, min(READ_CHUNK_BYTES, remaining)) + if not chunk: + raise CollectionError( + f"closed-sessions ledger changed while it was being read: {path}" + ) + remaining -= len(chunk) + buffered.extend(chunk) + while True: + boundary = buffered.find(b"\n") + if boundary < 0: + if len(buffered) > MAX_ROW_BYTES: + raise CollectionError( + f"closed-sessions ledger row {number + 1} exceeds the " + f"{MAX_ROW_BYTES}-byte row safety limit; no partial " + "list was returned" + ) + break + raw_line = bytes(buffered[: boundary + 1]) + del buffered[: boundary + 1] + number += 1 + entry = _parse_record(raw_line, number) + if visit is not None: + visit(entry, raw_line, number) + if buffered: + raise CollectionError( + "closed-sessions ledger does not end at a complete row boundary" + ) + finished = os.fstat(scan_descriptor) + if finished.st_size != opened.st_size or (finished.st_dev, finished.st_ino) != ( + opened.st_dev, + opened.st_ino, + ): + raise CollectionError( + f"closed-sessions ledger changed while it was being read: {path}" + ) + finally: + os.close(scan_descriptor) + return _LedgerScan(size, number, opened.st_dev, opened.st_ino) + + +def _prove_descriptor_authoritative( + descriptor: int, path: Path, *, action: str +) -> None: + """Require path to name the exact inode still held by descriptor.""" + + written = os.fstat(descriptor) + try: + pathname = path.lstat() + except OSError as exc: + raise CollectionError( + f"closed-sessions ledger path no longer names the file that was " + f"{action}: {path}" + ) from exc + if not stat.S_ISREG(pathname.st_mode) or (written.st_dev, written.st_ino) != ( + pathname.st_dev, + pathname.st_ino, + ): + raise CollectionError( + f"closed-sessions ledger path no longer names the file that was " + f"{action}: {path}" + ) + + +def _scan_records_unlocked( + path: Path, + *, + maximum: int | None, + visit: Callable[[dict[str, Any] | None, bytes, int], None] | None = None, +) -> _LedgerScan: + """Validate one authoritative complete ledger using one bounded row.""" + + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except FileNotFoundError: + return _LedgerScan(0, 0, None, None) + except OSError as exc: + raise CollectionError(f"cannot open closed-sessions ledger: {path}") from exc + try: + try: + _prove_descriptor_authoritative(descriptor, path, action="opened") + except CollectionError as exc: + raise CollectionError( + f"closed-sessions ledger must be a current-owner regular file: {path}" + ) from exc + scanned = _scan_descriptor_unlocked( + descriptor, path=path, maximum=maximum, visit=visit + ) + try: + _prove_descriptor_authoritative(descriptor, path, action="read") + except CollectionError as exc: + raise CollectionError( + f"closed-sessions ledger changed while it was being read: {path}" + ) from exc + return scanned + finally: + os.close(descriptor) + + +@contextmanager +def closed_snapshot( + *, + environ: Mapping[str, str] | None = None, + limit: int | None = MAX_ENTRIES, + still_readable: Callable[[str, str], bool] | None = None, + allow_large: bool = False, +) -> Iterator[Iterable[dict[str, Any]]]: + """A complete, disk-backed snapshot, ordered newest first. + + Validation and de-duplication finish before the first result is exposed, + so a malformed final row can never turn a valid prefix into partial truth. + SQLite keeps keys and sort state on private temporary disk rather than in + RAM; Python retains only one bounded ledger row and one result row. + """ + + directory = _prepare_directory(environ) + path = ledger_path(environ) + temporary_descriptor, temporary_name = tempfile.mkstemp( + prefix="session-kit-closed-snapshot-", suffix=".sqlite3" + ) + os.close(temporary_descriptor) + os.chmod(temporary_name, 0o600) + connection = sqlite3.connect(temporary_name) + try: + connection.execute("PRAGMA journal_mode=OFF") + connection.execute("PRAGMA synchronous=OFF") + connection.execute("PRAGMA temp_store=FILE") + connection.execute("PRAGMA cache_size=-2048") + connection.execute( + """CREATE TABLE closed ( + provider TEXT NOT NULL, + identity TEXT NOT NULL, + uuid TEXT NOT NULL, + title TEXT NOT NULL, + cwd TEXT NOT NULL, + closed_at INTEGER NOT NULL, + origin TEXT NOT NULL, + shpool_id TEXT NOT NULL, + account_alias TEXT NOT NULL, + sequence INTEGER NOT NULL, + PRIMARY KEY (provider, identity) + ) WITHOUT ROWID""" + ) + + def retain(entry: dict[str, Any] | None, _raw: bytes, number: int) -> None: + if entry is None: + return + identity = entry["uuid"] or entry["shpool_id"] + connection.execute( + """INSERT INTO closed VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider, identity) DO UPDATE SET + uuid=excluded.uuid, + title=excluded.title, + cwd=excluded.cwd, + closed_at=excluded.closed_at, + origin=excluded.origin, + shpool_id=excluded.shpool_id, + account_alias=excluded.account_alias, + sequence=excluded.sequence + WHERE excluded.closed_at > closed.closed_at""", + ( + entry["provider"], + identity, + entry["uuid"], + entry["title"], + entry["cwd"], + entry["closed_at_unix_ms"], + entry["origin"], + entry["shpool_id"], + entry["account_alias"], + number, + ), + ) + + with StateLock(directory, ledger_lock_path(environ)): + _scan_records_unlocked( + path, + maximum=None if allow_large else MAX_LIST_LEDGER_BYTES, + visit=retain, + ) + connection.commit() + + def rows() -> Iterator[dict[str, Any]]: + yielded = 0 + cursor = connection.execute( + """SELECT provider, uuid, title, cwd, closed_at, origin, + shpool_id, account_alias + FROM closed ORDER BY closed_at DESC, sequence ASC""" + ) + for ( + provider, + uuid, + title, + cwd, + closed_at, + origin, + shpool_id, + account_alias, + ) in cursor: + if limit is not None and yielded >= max(0, limit): + break + if provider == "shell": + restorable = False + else: + readable = ( + True + if still_readable is None + else bool(still_readable(provider, uuid)) + ) + restorable = readable + yielded += 1 + yield { + "provider": provider, + "uuid": uuid, + "title": title, + "cwd": cwd, + "closed_at_unix_ms": closed_at, + "origin": origin, + "shpool_id": shpool_id, + "account_alias": account_alias, + "restorable": restorable, + } + + class SnapshotRows: + """Reiterate the same validated SQLite snapshot without reopening it.""" + + def __iter__(self) -> Iterator[dict[str, Any]]: + return rows() + + def conversation_keys(self) -> Iterator[tuple[str, str]]: + """Exact identities from this snapshot, without transcript probes.""" + + for provider, uuid in connection.execute( + "SELECT provider, uuid FROM closed WHERE uuid != ''" + ): + yield provider, uuid + + yield SnapshotRows() + finally: + connection.close() + try: + os.unlink(temporary_name) + except FileNotFoundError: + pass + + +def ledger_is_readable(environ: Mapping[str, str] | None = None) -> bool: + """Whether the ledger could be read AND understood, apart from what it says. + + A ledger that is not there has nothing to say, and that IS a fact: no + conversation has been closed on this machine yet. A ledger that will not + open, or that opens and does not parse, also says nothing -- and that is + not the same fact. Every reader here degrades both to "no rows", which is + right for a list and wrong for a decision: the ledger is the only thing + that says a conversation was the person's, so its silence is answered from + somewhere else, and a caller that cannot tell an empty ledger from a + damaged one lets one truncated write decide whose session this is. + + Opening it is not enough to answer this. Every line is parsed, and one + line this module would skip is enough to make the whole reading unproven: + a skipped line is a conversation whose origin was lost, and it may be the + one being asked about. + """ + path = ledger_path(environ) + try: + if path.is_symlink(): + return False + if not path.is_file(): + return True + size = path.stat().st_size + with open(path, "rb") as handle: + if size > MAX_LEDGER_BYTES: + # Same window `_read_lines` reads, so the same rows are judged: + # the partial first line of a tail read is not a damaged row. + handle.seek(size - MAX_LEDGER_BYTES) + handle.readline() + payload = handle.read(MAX_LEDGER_BYTES) + except OSError: + return False + for line in payload.decode("utf-8", "replace").splitlines(): + if not line.strip(): + continue + try: + entry = _entry(json.loads(line)) + except ValueError: + return False + if entry is None: + return False + return True + + +def load_closed( + *, + environ: Mapping[str, str] | None = None, + limit: int | None = MAX_ENTRIES, + still_readable: Callable[[str, str], bool] | None = None, + allow_large: bool = False, +) -> list[dict[str, Any]]: + """Newest first, one row per conversation, each saying if it can return. + + A conversation whose transcript this machine can no longer read cannot be + restored, so it is listed as history only rather than dropped: the close + happened, the person remembers the session, and a screen that accounts for + every close but that one is the missing-session bug again. Offering it as + a full restore would be the opposite lie. Shell sessions have no + conversation at all: they are history only, and are never filtered by a + transcript they never had. + """ + with closed_snapshot( + environ=environ, + limit=limit, + still_readable=still_readable, + allow_large=allow_large, + ) as rows: + return list(rows) + + +def forget( + provider: str, + uuid: str, + *, + environ: Mapping[str, str] | None = None, +) -> int: + """Drop a conversation from the closed list once it is open again.""" + exact = valid_uuid(uuid) or "" + if provider not in PROVIDERS or not exact: + return 0 + path = ledger_path(environ) + directory = _prepare_directory(environ) + with StateLock(directory, ledger_lock_path(environ)): + removed = 0 + + def count_target( + entry: dict[str, Any] | None, _raw: bytes, _number: int + ) -> None: + nonlocal removed + if entry is not None and ( + entry["provider"] == provider and entry["uuid"] == exact + ): + removed += 1 + + # Complete validation happens before a temporary file exists. There + # is no file-size exception now that neither pass materializes bytes. + source = _scan_records_unlocked(path, maximum=None, visit=count_target) + if not removed: + return 0 + descriptor, temporary = tempfile.mkstemp( + prefix=".closed-sessions.", dir=directory + ) + try: + os.fchmod(descriptor, 0o600) + copied_removed = 0 + expected_size = 0 + expected_digest = hashlib.sha256() + + def copy_unrelated( + entry: dict[str, Any] | None, raw: bytes, _number: int + ) -> None: + nonlocal copied_removed, expected_size + if entry is not None and ( + entry["provider"] == provider and entry["uuid"] == exact + ): + copied_removed += 1 + return + expected_size += len(raw) + expected_digest.update(raw) + if entry is None or not ( + entry["provider"] == provider and entry["uuid"] == exact + ): + _write_all(descriptor, raw, "rewriting closed sessions") + + # Validate again while copying each unrelated row's original + # bytes. Any error leaves the original pathname untouched. + copied_source = _scan_records_unlocked( + path, maximum=None, visit=copy_unrelated + ) + if (copied_source.device, copied_source.inode) != ( + source.device, + source.inode, + ) or copied_removed != removed: + raise CollectionError( + f"closed-sessions ledger changed before rewrite: {path}" + ) + os.fsync(descriptor) + actual_digest = hashlib.sha256() + + def hash_rewrite( + _entry: dict[str, Any] | None, raw: bytes, _number: int + ) -> None: + actual_digest.update(raw) + + rewritten = _scan_descriptor_unlocked( + descriptor, path=path, maximum=None, visit=hash_rewrite + ) + if ( + rewritten.size != expected_size + or actual_digest.digest() != expected_digest.digest() + ): + raise CollectionError( + f"closed-sessions rewrite changed before publication: {path}" + ) + os.replace(temporary, path) + _fsync_directory(directory) + _prove_descriptor_authoritative(descriptor, path, action="published") + finally: + if descriptor >= 0: + os.close(descriptor) + try: + os.unlink(temporary) + except FileNotFoundError: + pass + return removed + + +def entry_from_inventory( + inventory: Mapping[str, Any] | None, + *, + provider: str, + uuid: str = "", + shpool_id: str = "", +) -> dict[str, str]: + """Name, directory, and origin for a close, from the last known list. + + The picker and `sp close` know the session they are closing; the shell that + closes itself knows only its own ID. Both get the same record by reading + what the kit last saw, so no caller has to carry fields it does not have. + """ + found: dict[str, str] = { + "title": "", + "title_source": "", + "cwd": "", + # Unproven stays UNKNOWN and therefore visible (ses87 ruling): the + # ledger never asserts a creator it cannot prove. + "origin": UNKNOWN_ORIGIN, + "account_alias": "", + } + rows = inventory.get("sessions") if isinstance(inventory, Mapping) else None + if not isinstance(rows, Iterable): + return found + exact = valid_uuid(uuid) or "" + for row in rows: + if not isinstance(row, Mapping) or row.get("provider") != provider: + continue + identity = row.get("identity") + row_uuid = ( + valid_uuid(identity.get("uuid")) if isinstance(identity, Mapping) else None + ) or "" + if exact: + if row_uuid != exact: + continue + elif not shpool_id or row.get("shpool_id_raw") != shpool_id: + continue + found["title"] = clean_text(row.get("title"), 120) + found["title_source"] = clean_text(row.get("title_source"), 40) + found["cwd"] = clean_text(row.get("cwd"), 4096) + # `origin_recorded`, not `origin`: this row outlives the session by + # design and is read back on every future restore, so only the stamp + # belongs in it. `origin` on an unstamped session is what collection + # inferred about who held a socket at one instant, and a close landing + # during a provider restart would freeze that instant into a permanent + # machine record for a conversation nobody ever stamped. No stamp means + # no claim, and no claim reads as the person's. + origin = row.get("origin_recorded") + found["origin"] = origin if origin in ORIGINS else UNKNOWN_ORIGIN + found["account_alias"] = clean_text(row.get("account_alias"), 12) + break + return found diff --git a/lib/sessionkit_inventory/collector.py b/lib/sessionkit_inventory/collector.py new file mode 100644 index 0000000..25087c0 --- /dev/null +++ b/lib/sessionkit_inventory/collector.py @@ -0,0 +1,1995 @@ +"""Bounded read-only joins: session records, recent output, and live collection. + +Every function here composes evidence the caller already gathered, or gathers it +through injected readers. Nothing imports the facade. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import re +import sqlite3 +import stat +import subprocess +import time +from typing import Any, Callable, Iterable, Mapping, Sequence + +from .common import ( + PROVIDERS, + CollectionError, + _utc_now, + _valid_aliases, + _valid_automatic_title_failures, + _valid_automatic_titles, + _valid_name_ownership, + _pending_native_title_matches, + _valid_pending_native_titles, + _valid_pushed_titles, + automatic_naming_enabled, + clean_text, + proc_root as gated_proc_root, + shpool_id_mutation_policy, + valid_uuid, +) +from .model import ( + SHELL_PROCESS_NAMES, + _agent_identity, + _base_agent, + _empty_recovery, + _shell_title, + canonical_session_order_key, + recovery_spec, +) +from .processes import _children_index, _process_age, table_is_complete +from .providers import _parse_shpool_payload +from .providers_claude import _is_native_claude +from .providers_codex import ( + _codex_state_databases, + _codex_turn_state, + _is_native_codex, +) + + +PROVIDER_ORDER = {"claude": 0, "codex": 1, "shell": 2, "unknown": 3} +AVAILABILITY_ORDER = {"ready": 0, "attached": 1} +AGED_CHILD_SECONDS = 60 * 60 + + +def _aged_shell_children( + root_pid: int | None, + tree: Sequence[int], + process_table: Mapping[int, Mapping[str, Any]], + current_time: float, + ignored: frozenset[int], +) -> list[dict[str, Any]]: + """Known child shells at least an hour old, from this census only. + + The shpool root is the session shell and is intentionally excluded. A + churned census may have unrelated holes, but every entry returned here is + positive evidence already present in the session's proven process tree. + """ + if not root_pid: + return [] + result: list[dict[str, Any]] = [] + for pid in tree: + if pid == root_pid or pid in ignored: + continue + process = process_table.get(pid, {}) + comm = clean_text(process.get("comm"), 80) + if comm.casefold() not in SHELL_PROCESS_NAMES: + continue + age = _process_age(pid, process_table, current_time) + if age is None or age < AGED_CHILD_SECONDS: + continue + result.append( + { + "kind": "shell", + "title": comm, + "age_seconds": age, + } + ) + return result + + +def _safe_worker_title(child: Mapping[str, Any], pid: int | None) -> str: + provider = clean_text(child.get("provider"), 20).casefold() + uuid = valid_uuid(child.get("uuid")) + fallbacks = { + fallback + for fallback in ( + f"PID {pid}" if pid is not None else "", + uuid, + uuid[:8] if uuid else "", + ) + if fallback + } + title = clean_text(child.get("title"), 120) + if not title or title in fallbacks: + return f"{provider.title()} worker" if provider in PROVIDERS else "Worker" + return title + + +def _attach_aged_workers( + rows: Sequence[dict[str, Any]], + process_table: Mapping[int, Mapping[str, Any]], + current_time: float, +) -> None: + """Add hour-old workers without changing subagent count or state. + + A Codex spawn edge may be pid-less while the exact child also has its own + live session row. That exact provider+UUID join may reuse the row's + process age; an edge with no matching live process remains ageless. + """ + live_by_identity: dict[tuple[str, str], Mapping[str, Any]] = {} + for row in rows: + identity = row.get("identity") + if not isinstance(identity, Mapping): + continue + provider = clean_text(row.get("provider"), 20).casefold() + uuid = valid_uuid(identity.get("uuid")) + if provider in PROVIDERS and uuid: + live_by_identity[(provider, uuid)] = row + + for row in rows: + existing = row.get("aged_children") + aged = ( + [dict(item) for item in existing if isinstance(item, Mapping)] + if isinstance(existing, Sequence) and not isinstance(existing, (str, bytes)) + else [] + ) + seen: set[tuple[str, object]] = set() + subagents = row.get("subagents") + if isinstance(subagents, Sequence) and not isinstance(subagents, (str, bytes)): + for raw_child in subagents: + if not isinstance(raw_child, Mapping): + continue + raw_pid = raw_child.get("pid") + pid = ( + raw_pid + if isinstance(raw_pid, int) + and not isinstance(raw_pid, bool) + and raw_pid > 0 + else None + ) + provider = clean_text(raw_child.get("provider"), 20).casefold() + uuid = valid_uuid(raw_child.get("uuid")) + age = _process_age(pid, process_table, current_time) if pid else None + if age is None and provider in PROVIDERS and uuid: + matched = live_by_identity.get((provider, uuid)) + matched_age = ( + matched.get("process_age_seconds") if matched else None + ) + if ( + isinstance(matched_age, int) + and not isinstance(matched_age, bool) + and matched_age >= 0 + ): + age = matched_age + if age is None or age < AGED_CHILD_SECONDS: + continue + key: tuple[str, object] + if pid is not None: + key = ("pid", pid) + elif provider in PROVIDERS and uuid: + key = (provider, uuid) + else: + continue + if key in seen: + continue + seen.add(key) + aged.append( + { + "kind": "worker", + "provider": provider if provider in PROVIDERS else "", + "title": _safe_worker_title(raw_child, pid), + "age_seconds": age, + } + ) + aged.sort( + key=lambda item: ( + -int(item.get("age_seconds") or 0), + str(item.get("kind") or ""), + str(item.get("title") or "").casefold(), + ) + ) + row["aged_children"] = aged + + +# Value-taking global options printed by the installed CLI's `codex help`. +# Keep this synchronized with that grammar so option values cannot be mistaken +# for the first positional (the subcommand, when one is present). +CODEX_GLOBAL_OPTIONS_WITH_VALUES = frozenset( + { + "-c", + "--config", + "--enable", + "--disable", + "--remote", + "--remote-auth-token-env", + "-i", + "--image", + "-m", + "--model", + "--local-provider", + "-p", + "--profile", + "-s", + "--sandbox", + "-C", + "--cd", + "--add-dir", + "-a", + "--ask-for-approval", + } +) +CODEX_GLOBAL_FLAG_OPTIONS = frozenset( + { + "--strict-config", + "--oss", + "--dangerously-bypass-approvals-and-sandbox", + "--dangerously-bypass-hook-trust", + "--search", + "--no-alt-screen", + "-h", + "--help", + "-V", + "--version", + } +) +CODEX_VARIADIC_GLOBAL_OPTIONS = frozenset({"-i", "--image"}) +# Subcommands that mean no person converses with this Codex: a headless run, +# a non-interactive review, or the stdio MCP server. From the installed CLI's +# own `codex help` (0.145). Anything not PROVEN machine stays out -- misreading +# a person's session as machine would hide it, which is the worse direction -- +# so management/utility verbs (login, mcp, remote-control, sandbox, resume, +# ...) are deliberately absent. ``app-server`` is deliberately absent too: it +# is the kit's OWN plumbing for every managed Codex session, human windows +# included (X17 -- listing it here hid two real sessions). Whether an +# app-server session is machine-driven is a question about its DRIVER, not +# its argv, and needs driver evidence, not this list. +CODEX_MACHINE_SUBCOMMANDS = frozenset({"exec", "review", "mcp-server"}) +# The npm wrapper starts Codex as `node .../bin/codex ...`: argv[0] is node +# and the real grammar begins at argv[1]. Resolve only that exact, confident +# shape -- anything else keeps the wrapper-blind reading and errs visible. +_CODEX_WRAPPER_INTERPRETERS = frozenset({"node", "nodejs"}) +# The App Server is a socket, and the question X16 asked about it is who holds +# that socket. The kit starts one for EVERY managed Codex session, so its argv +# separates nothing (X17: reading `app-server` as machine hid two of the operator's own +# windows). The client on the other end does separate them: a person's window +# is a Codex TUI attached to that exact socket with `--remote`, and a worker +# nobody types into has only the coordination broker the kit starts with +# `--socket <same path>`. Both are positive readings. Everything else -- no +# client, an argv that will not parse, a server younger than the window that +# follows it -- is not evidence, and an absence of evidence keeps the row in +# the person's list. +CODEX_APP_SERVER_SUBCOMMAND = "app-server" +# The managed login shell starts the server, the broker, and then the window, +# in that order and within the same second. Under this age "no window yet" is +# a session still booting, not a verdict about who drives it. +CODEX_APP_SERVER_WINDOW_GRACE_SECONDS = 60 + + +def _codex_effective_argv(exact_argv: Sequence[Any]) -> Sequence[Any]: + if ( + len(exact_argv) >= 2 + and isinstance(exact_argv[0], str) + and isinstance(exact_argv[1], str) + and Path(exact_argv[0]).name in _CODEX_WRAPPER_INTERPRETERS + and Path(exact_argv[1]).name == "codex" + ): + return exact_argv[1:] + return exact_argv + + +def _codex_first_positional(exact_argv: Sequence[Any]) -> str | None: + """Return Codex's first confidently parsed positional, if there is one. + + Unknown separate-form options and malformed argv are deliberately + ambiguous. Callers must keep those sessions visible rather than risk + hiding an interactive root. + """ + exact_argv = _codex_effective_argv(exact_argv) + index = 1 + while index < len(exact_argv): + argument = exact_argv[index] + if not isinstance(argument, str) or not argument: + return None + if not argument.startswith("-"): + return argument + if "=" in argument: + index += 1 + continue + if argument in CODEX_GLOBAL_OPTIONS_WITH_VALUES: + if index + 1 >= len(exact_argv): + return None + value = exact_argv[index + 1] + if not isinstance(value, str) or not value or value.startswith("-"): + return None + index += 2 + if argument in CODEX_VARIADIC_GLOBAL_OPTIONS: + while index < len(exact_argv): + value = exact_argv[index] + if not isinstance(value, str) or not value: + return None + if value.startswith("-"): + break + index += 1 + continue + if argument in CODEX_GLOBAL_FLAG_OPTIONS: + index += 1 + continue + return None + return None + + +def _unix_endpoint_path(value: Any) -> str: + """The filesystem path an endpoint names, with or without the scheme. + + The server is told `unix:///run/x/app.sock` and the broker is told the + bare path, so the two only compare after the scheme comes off. + """ + if not isinstance(value, str) or not value: + return "" + return value[len("unix://") :] if value.startswith("unix://") else value + + +def _option_value(argv: Sequence[Any], option: str) -> str: + """Read one option's value in either the separate or the joined form.""" + joined = f"{option}=" + for index, argument in enumerate(argv): + if not isinstance(argument, str): + continue + if argument == option: + value = argv[index + 1] if index + 1 < len(argv) else "" + return value if isinstance(value, str) else "" + if argument.startswith(joined): + return argument[len(joined) :] + return "" + + +def _codex_app_server_driver( + provider_pid: Any, + exact_argv: Sequence[Any], + tree: Iterable[int], + process_table: Mapping[int, Mapping[str, Any]], + server_age_seconds: Any, +) -> str: + """Who holds this Codex App Server's socket: a person, a program, nobody. + + Answers "person", "program", or "unknown", reading only the managed + shell's own process tree. Only "program" is a machine verdict; every + uncertain reading answers "unknown" and leaves the row visible, which is + the direction X17 proved is the safe one to be wrong in. + + "Program" is reached by NOT finding a window, so it is only ever as good + as the reading of the tree. A process whose argv the scan could not take + -- denied, or gone by the time it was asked -- may BE the window, and a + broker beside a missed window is observationally identical to a broker + with no window at all. So a tree with a hole in it proves nothing about + absence and answers "unknown". Finding the window still answers "person": + a positive reading is not weakened by a hole elsewhere. + """ + if _codex_first_positional(exact_argv) != CODEX_APP_SERVER_SUBCOMMAND: + return "unknown" + socket = _unix_endpoint_path( + _option_value(_codex_effective_argv(exact_argv), "--listen") + ) + if not socket: + return "unknown" + program = False + complete = True + for pid in tree: + if pid == provider_pid: + continue + process = process_table.get(pid) + argv = process.get("cmdline") if isinstance(process, Mapping) else None + if ( + not isinstance(process, Mapping) + or process.get("argv_unreadable") is True + or not isinstance(argv, Sequence) + or isinstance(argv, (str, bytes)) + ): + complete = False + continue + argv = list(argv) + # A window attached to this exact server is a person, whether the npm + # wrapper or the vendored binary is the one carrying the argument. + if _unix_endpoint_path(_option_value(argv, "--remote")) == socket: + return "person" + if _unix_endpoint_path(_option_value(argv, "--socket")) == socket: + program = True + if not program: + # A server with no client at all has not been driven by anybody yet. + return "unknown" + if not complete or not table_is_complete(process_table): + # Something went unread. The window is the thing whose absence is + # being claimed, so one unread process is enough to withdraw the + # claim -- and a process the scan lost before it could read a parent + # is not even attributable to a tree, so a hole anywhere on the + # machine could be the window that belongs to THIS one. + return "unknown" + if ( + isinstance(server_age_seconds, bool) + or not isinstance(server_age_seconds, int) + or server_age_seconds < CODEX_APP_SERVER_WINDOW_GRACE_SECONDS + ): + return "unknown" + return "program" + + +def _provider_profile_path(raw: Any, fallback: Path) -> Path | None: + """Return one absolute provider profile path without inventing a target.""" + if not isinstance(raw, str) or not raw: + return fallback + candidate = Path(raw) + if not candidate.is_absolute() or ".." in candidate.parts: + return None + return candidate + + +def _profile_key(path: Path) -> str: + return os.fspath(path.resolve(strict=False)) + + +def _exact_account_alias( + pid: Any, + identity: Mapping[str, Any], + process_table: Mapping[int, Mapping[str, Any]], +) -> str: + """Read a display alias only from the exact provider process generation.""" + if ( + not isinstance(pid, int) + or identity.get("confidence") != "exact" + or identity.get("pid") != pid + ): + return "" + alias = clean_text(process_table.get(pid, {}).get("account_alias"), 12) + return alias if re.fullmatch(r"[a-z][a-z0-9_-]{0,11}", alias) else "" + + +def _exact_account_capable( + pid: Any, + identity: Mapping[str, Any], + process_table: Mapping[int, Mapping[str, Any]], +) -> bool: + return bool( + isinstance(pid, int) + and identity.get("confidence") == "exact" + and identity.get("pid") == pid + and process_table.get(pid, {}).get("account_capable") == "1" + ) + + +def regular_file_mtime_ms(path: Path) -> int | None: + """Return an exact regular file's mtime without following a symlink.""" + try: + metadata = path.lstat() + except OSError: + return None + if not stat.S_ISREG(metadata.st_mode): + return None + return metadata.st_mtime_ns // 1_000_000 + + +def recent_output_times( + shpool_ids: Iterable[str], + *, + journal_dir: Path | None = None, + recovery_dir: Path | None = None, + journal_dir_factory: Callable[[], Path], + journal_recovery_dir_factory: Callable[[], Path], + regular_file_mtime_ms: Callable[[Path], int | None], +) -> dict[str, int]: + """Map operational shpool IDs to their exact active journal mtime. + + Legacy sessions may still write through a recovery-map path after their + original inode was moved. A valid exact map entry takes precedence, just + as ``sp history`` does. Otherwise the collector checks the raw-ID legacy + file or every regular segment in the raw-ID directory. Unmanaged IDs, + symlinks, missing files, and ambiguous map entries have no activity time. + """ + wanted = { + value + for value in shpool_ids + if shpool_id_mutation_policy(value) == (True, None) + } + if not wanted: + return {} + active_root = journal_dir or journal_dir_factory() + recovered_root = recovery_dir or journal_recovery_dir_factory() + mapped: dict[str, Path | None] = {} + ambiguous_mappings: set[str] = set() + map_path = recovered_root / "current-map.tsv" + try: + map_metadata = map_path.lstat() + if not stat.S_ISREG(map_metadata.st_mode): + raise OSError("recovery map is not a regular file") + with map_path.open("r", encoding="utf-8", errors="replace") as handle: + for raw_line in handle: + raw_id, separator, raw_path = raw_line.rstrip("\r\n").partition("\t") + if ( + not separator + or raw_id not in wanted + or not raw_path.startswith("/") + ): + continue + candidate = Path(raw_path) + previous = mapped.get(raw_id) + if raw_id in mapped and previous != candidate: + mapped[raw_id] = None + ambiguous_mappings.add(raw_id) + else: + mapped[raw_id] = candidate + except OSError: + pass + + result: dict[str, int] = {} + for raw_id in wanted: + if raw_id in ambiguous_mappings: + continue + mapped_path = mapped.get(raw_id) + if mapped_path is not None: + mapped_mtime = regular_file_mtime_ms(mapped_path) + if mapped_mtime is not None: + result[raw_id] = mapped_mtime + continue + + legacy_mtime = regular_file_mtime_ms(active_root / f"{raw_id}.raw") + if legacy_mtime is not None: + result[raw_id] = legacy_mtime + continue + + segment_dir = active_root / raw_id + try: + directory_metadata = segment_dir.lstat() + if not stat.S_ISDIR(directory_metadata.st_mode): + continue + segment_mtimes = [ + timestamp + for path in segment_dir.iterdir() + if re.fullmatch(r"segment-[0-9]+\.raw", path.name) + for timestamp in [regular_file_mtime_ms(path)] + if timestamp is not None + ] + except OSError: + continue + if segment_mtimes: + result[raw_id] = max(segment_mtimes) + return result + + +def apply_retained_setup_attributions( + inventory: dict[str, Any], + *, + start_dir: Path | None = None, + boot_id: str | None = None, + start_dir_factory: Callable[[], Path], + boot_id_reader: Callable[[], str | None], + launch_fields: Callable[[bytes, int], list[str] | None], + read_private_launch_file: Callable[[int, str], bytes], +) -> dict[str, Any]: + """Add display-only provider hints from exact retained startup proofs.""" + root = start_dir or start_dir_factory() + current_boot_id = boot_id if boot_id is not None else boot_id_reader() + if not current_boot_id or "\t" in current_boot_id or "\n" in current_boot_id: + return inventory + flags = os.O_RDONLY + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_DIRECTORY"): + flags |= os.O_DIRECTORY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + directory_fd = os.open(root, flags) + except OSError: + return inventory + try: + directory = os.fstat(directory_fd) + if ( + not stat.S_ISDIR(directory.st_mode) + or stat.S_IMODE(directory.st_mode) != 0o700 + or directory.st_uid != os.geteuid() + ): + return inventory + generation = inventory.get("daemon_generation") + if not isinstance(generation, Mapping): + return inventory + daemon_pid = generation.get("pid") + daemon_start = generation.get("process_start_ticks") + if ( + isinstance(daemon_pid, bool) + or not isinstance(daemon_pid, int) + or daemon_pid <= 0 + or isinstance(daemon_start, bool) + or not isinstance(daemon_start, int) + or daemon_start <= 0 + ): + return inventory + + for item in inventory.get("sessions", ()): + if not isinstance(item, dict) or item.get("provider") != "unknown": + continue + raw_id = item.get("shpool_id_raw") + if not isinstance(raw_id, str) or shpool_id_mutation_policy(raw_id) != ( + True, + None, + ): + continue + shell = item.get("shpool_shell") + identity = item.get("identity") + started = item.get("started_at_unix_ms") + if ( + not isinstance(shell, Mapping) + or not isinstance(identity, Mapping) + or identity.get("confidence") != "unknown" + or identity.get("uuid") is not None + or identity.get("pid") != shell.get("pid") + or identity.get("process_start_ticks") + != shell.get("process_start_ticks") + or isinstance(started, bool) + or not isinstance(started, int) + or started <= 0 + or isinstance(shell.get("pid"), bool) + or not isinstance(shell.get("pid"), int) + or shell.get("pid", 0) <= 0 + or isinstance(shell.get("process_start_ticks"), bool) + or not isinstance(shell.get("process_start_ticks"), int) + or shell.get("process_start_ticks", 0) <= 0 + ): + continue + try: + start_payload = read_private_launch_file(directory_fd, raw_id) + expected_payload = read_private_launch_file( + directory_fd, f"{raw_id}.expected" + ) + except (CollectionError, OSError): + continue + start = launch_fields(start_payload, 4) + if start is None: + legacy_start = launch_fields(start_payload, 3) + if legacy_start is not None: + legacy_uuid = legacy_start[2] + start = [ + *legacy_start, + "resume" if legacy_uuid else "new", + ] + expected = launch_fields(expected_payload, 10) + if expected is None: + legacy_expected = launch_fields(expected_payload, 9) + if legacy_expected is not None: + legacy_uuid = legacy_expected[8] + expected = [ + *legacy_expected, + "resume" if legacy_uuid else "new", + ] + if start is None or expected is None: + continue + provider, cwd, uuid, launch_mode = start + ( + side_provider, + side_cwd, + side_boot_id, + side_started, + side_shell_pid, + side_shell_start, + side_daemon_pid, + side_daemon_start, + side_uuid, + side_launch_mode, + ) = expected + numeric = ( + side_started, + side_shell_pid, + side_shell_start, + side_daemon_pid, + side_daemon_start, + ) + if any(re.fullmatch(r"[1-9][0-9]*", value) is None for value in numeric): + continue + if ( + provider not in {"claude", "codex"} + or launch_mode not in {"new", "resume", "fork"} + or side_launch_mode != launch_mode + or (launch_mode == "new" and uuid != "") + or (launch_mode in {"resume", "fork"} and valid_uuid(uuid) != uuid) + or side_provider != provider + or side_cwd != cwd + or item.get("cwd") != cwd + or side_boot_id != current_boot_id + or int(side_started) != started + or int(side_shell_pid) != shell.get("pid") + or int(side_shell_start) != shell.get("process_start_ticks") + or int(side_daemon_pid) != daemon_pid + or int(side_daemon_start) != daemon_start + or side_uuid != uuid + or (uuid and valid_uuid(uuid) != uuid) + ): + continue + item["display_provider"] = provider + item["setup_incomplete"] = True + item["display_title"] = f"{provider.title()} pending" + if uuid and launch_mode == "resume": + item["_terminal_identity_hint"] = { + "provider": provider, + "uuid": uuid, + } + finally: + os.close(directory_fd) + return inventory + + +def _census_chain( + process_table: Mapping[int, Mapping[str, Any]], + children: Mapping[int, Sequence[int]] | None = None, +) -> frozenset[int]: + """This process, what spawned it, and what it spawned. + + The census can run inside the very shell it is titling; its own chain + must never count as that session's running program. Its own children are + the same thing one step further out: a collection verb that shells out to + a helper would otherwise report that helper as the session's work for as + long as it runs.""" + chain: set[int] = set() + pid = os.getpid() + for _ in range(64): + if pid in chain or pid <= 1: + break + chain.add(pid) + parent = process_table.get(pid, {}).get("ppid") + if not isinstance(parent, int): + break + pid = parent + if children is not None: + pending = [os.getpid()] + while pending: + current = pending.pop() + for child in children.get(current, ()): # type: ignore[arg-type] + if child not in chain: + chain.add(child) + pending.append(child) + return frozenset(chain) + + +ProviderClaim = tuple[str, int, str] + + +def _proven_ancestor( + ancestor_pid: int, + descendant_pid: int, + process_table: Mapping[int, Mapping[str, Any]], +) -> bool: + """Prove one same-snapshot process lineage without trusting a bare PID. + + Every link must carry a readable generation, and a parent cannot have + started after its alleged child. That last check closes the snapshot race + where an exited parent PID is recycled after the child row was read. + """ + current_pid = descendant_pid + seen: set[int] = set() + for _ in range(128): + if current_pid in seen or current_pid <= 0: + return False + current = process_table.get(current_pid) + if not isinstance(current, Mapping): + return False + recorded_pid = current.get("pid", current_pid) + current_start = current.get("start_ticks") + if ( + isinstance(recorded_pid, bool) + or recorded_pid != current_pid + or isinstance(current_start, bool) + or not isinstance(current_start, int) + or current_start <= 0 + ): + return False + if current_pid == ancestor_pid: + return True + if current.get("argv_unreadable") is True: + return False + parent_pid = current.get("ppid") + if ( + isinstance(parent_pid, bool) + or not isinstance(parent_pid, int) + or parent_pid <= 0 + ): + return False + parent = process_table.get(parent_pid) + if not isinstance(parent, Mapping): + return False + parent_recorded_pid = parent.get("pid", parent_pid) + parent_start = parent.get("start_ticks") + if ( + isinstance(parent_recorded_pid, bool) + or parent_recorded_pid != parent_pid + or isinstance(parent_start, bool) + or not isinstance(parent_start, int) + or parent_start <= 0 + or parent_start > current_start + or parent.get("argv_unreadable") is True + ): + return False + seen.add(current_pid) + current_pid = parent_pid + return False + + +def _lineage_owner( + root_pid: int | None, + claims: Sequence[ProviderClaim], + process_table: Mapping[int, Mapping[str, Any]], +) -> tuple[ProviderClaim, list[ProviderClaim]] | None: + """Choose an exact owner only when all rival-looking claims are workers.""" + if not isinstance(root_pid, int) or not claims: + return None + if len(claims) == 1: + # A sole claim inside this session's own subtree is the answer, not an + # adjudication among rivals. Requiring a hole-free whole-machine census + # before returning it let any unrelated process dying mid-scan + # unresolve every session on the board at once. + return claims[0], [] + if not table_is_complete(process_table): + return None + if any( + not _proven_ancestor(root_pid, candidate_pid, process_table) + for _, candidate_pid, _ in claims + ): + return None + roots = [ + claim + for claim in claims + if not any( + other_pid != claim[1] + and _proven_ancestor(other_pid, claim[1], process_table) + for _, other_pid, _ in claims + ) + ] + if len(roots) != 1: + return None + owner = roots[0] + workers = [claim for claim in claims if claim != owner] + if not all( + worker_pid != owner[1] and _proven_ancestor(owner[1], worker_pid, process_table) + for _, worker_pid, _ in workers + ): + return None + return owner, workers + + +def build_inventory( + shpool_payload: Any, + claude_payload: Any, + process_table: Mapping[int, Mapping[str, Any]], + codex_index: Mapping[int, Sequence[Mapping[str, Any]]], + codex_db_rows: Sequence[Any], + config: Mapping[str, Any], + now: float | None = None, + recent_output_by_shpool_id: Mapping[str, int] | None = None, + *, + descendants: Callable[..., list[int]], + shpool_roots: Callable[ + [Iterable[str], Mapping[int, Mapping[str, Any]]], + tuple[dict[str, int], dict[str, list[str]]], + ], + daemon_generation: Callable[ + [Mapping[int, Mapping[str, Any]]], dict[str, int] | None + ], + parse_claude_payload: Callable[[Any], list[dict[str, Any]]], + native_claude_uuid: Callable[[Mapping[str, Any]], str | None], + claude_subagents: Callable[ + [str, Mapping[int, Mapping[str, Any]]], list[dict[str, Any]] + ], + root_codex_uuid: Callable[ + [Mapping[str, Any], Sequence[Mapping[str, Any]]], list[str] + ], + provider_title_info: Callable[..., tuple[str, str]], + session_color: Callable[..., str | None], + valid_colors: Callable[[Any], dict[str, str]], + adopt_launch_colors: Callable[..., Any], + codex_title_echoes_prompt: Callable[[str, str], bool], + schema_version: int, + claude_palette: Sequence[str], + default_max_proc_nodes: int, +) -> dict[str, Any]: + """Pure inventory composition for fixture tests and the live collector.""" + current_time = time.time() if now is None else now + shpool_sessions = _parse_shpool_payload(shpool_payload) + claude_agents = parse_claude_payload(claude_payload) + claude_by_pid = {item["pid"]: item for item in claude_agents} + aliases = _valid_aliases(config.get("aliases")) + automatic_titles = _valid_automatic_titles(config.get("automatic_titles")) + pushed_titles = _valid_pushed_titles(config.get("pushed_titles")) + pending_native_titles = _valid_pending_native_titles( + config.get("pending_native_titles") + ) + name_ownership = _valid_name_ownership(config.get("name_ownership")) + automatic_failures = _valid_automatic_title_failures( + config.get("automatic_title_failures") + ) + if len(codex_db_rows) < 2: + raise CollectionError("Codex metadata bundle is incomplete") + codex_threads, codex_edges = codex_db_rows[:2] + codex_profiles: Mapping[str, Any] = ( + codex_db_rows[2] + if len(codex_db_rows) >= 3 and isinstance(codex_db_rows[2], Mapping) + else {} + ) + codex_default_home = codex_profiles.get("default_home") + codex_profile_rows = codex_profiles.get("profiles") + if not isinstance(codex_default_home, str) or not isinstance( + codex_profile_rows, Mapping + ): + codex_default_home = "" + codex_profile_rows = {} + + codex_parent_by_child: dict[str, set[str]] = {} + + def remember_codex_edges(edges: Mapping[str, Any]) -> None: + for raw_parent, raw_children in edges.items(): + parent = valid_uuid(raw_parent) + if not parent or not isinstance(raw_children, Sequence): + continue + for child in raw_children: + if not isinstance(child, Mapping): + continue + child_uuid = valid_uuid(child.get("uuid")) + if child_uuid: + codex_parent_by_child.setdefault(child_uuid, set()).add(parent) + + remember_codex_edges(codex_edges) + for profile_rows in codex_profile_rows.values(): + if ( + isinstance(profile_rows, Sequence) + and not isinstance(profile_rows, (str, bytes)) + and len(profile_rows) >= 2 + and isinstance(profile_rows[1], Mapping) + ): + remember_codex_edges(profile_rows[1]) + + def codex_rows_for_pid( + candidate_pid: int, + ) -> tuple[Mapping[str, Mapping[str, Any]], Mapping[str, Sequence[dict[str, Any]]]]: + if not codex_default_home: + return codex_threads, codex_edges + default_path = Path(codex_default_home) + profile = _provider_profile_path( + process_table.get(candidate_pid, {}).get("codex_home"), default_path + ) + if profile is None: + return {}, {} + selected = codex_profile_rows.get(_profile_key(profile)) + if ( + isinstance(selected, Sequence) + and not isinstance(selected, (str, bytes)) + and len(selected) >= 2 + and isinstance(selected[0], Mapping) + and isinstance(selected[1], Mapping) + ): + return selected[0], selected[1] + return {}, {} + + roots, root_diagnostics = shpool_roots( + (item["name"] for item in shpool_sessions), process_table + ) + child_index = _children_index(process_table) + own_processes = _census_chain(process_table, child_index) + recent_outputs = recent_output_by_shpool_id or {} + mapped_claude: set[int] = set() + mapped_codex: set[int] = set() + sessions: list[dict[str, Any]] = [] + + for shpool in shpool_sessions: + name = shpool["name"] + status = shpool["status"] + availability = "ready" if status == "Disconnected" else "attached" + diagnostics = list(root_diagnostics.get(name, ())) + root_pid = roots.get(name) + tree: list[int] = [] + if root_pid: + try: + tree = descendants( + root_pid, + child_index, + max_nodes=int(config.get("max_proc_nodes", default_max_proc_nodes)), + max_depth=int(config.get("max_proc_depth", 32)), + ) + except CollectionError as exc: + diagnostics.append(str(exc)) + claude_candidates = [ + pid + for pid in tree + if pid in claude_by_pid + and claude_by_pid[pid].get("kind") in {"", "interactive"} + ] + native_claude_candidates = [ + (pid, native_uuid) + for pid in tree + if pid not in claude_by_pid + and (native_uuid := native_claude_uuid(process_table.get(pid, {}))) + ] + codex_candidates: list[tuple[int, str]] = [] + for candidate_pid in tree: + for candidate_uuid in root_codex_uuid( + process_table.get(candidate_pid, {}), + codex_index.get(candidate_pid, ()), + ): + codex_candidates.append((candidate_pid, candidate_uuid)) + codex_candidates = sorted(set(codex_candidates)) + provider_claims: list[ProviderClaim] = [ + ("claude-agent", candidate_pid, claude_by_pid[candidate_pid]["uuid"]) + for candidate_pid in claude_candidates + ] + provider_claims.extend( + ("claude-native", candidate_pid, candidate_uuid) + for candidate_pid, candidate_uuid in native_claude_candidates + ) + provider_claims.extend( + ("codex", candidate_pid, candidate_uuid) + for candidate_pid, candidate_uuid in codex_candidates + ) + lineage = _lineage_owner(root_pid, provider_claims, process_table) + owner_claim = lineage[0] if lineage else None + worker_claims = lineage[1] if lineage else [] + + lineage_subagents: list[dict[str, Any]] = [] + for worker_kind, worker_pid, worker_uuid in worker_claims: + if worker_kind == "codex": + worker_threads, _ = codex_rows_for_pid(worker_pid) + worker_thread = worker_threads.get(worker_uuid, {}) + worker_title = ( + clean_text( + worker_thread.get("session_index_name") + or worker_thread.get("name") + or worker_thread.get("title"), + 120, + ) + or f"PID {worker_pid}" + ) + worker_status = _codex_turn_state( + codex_index.get(worker_pid, ()), worker_uuid + ) + mapped_codex.add(worker_pid) + elif worker_kind == "claude-agent": + worker_agent = claude_by_pid[worker_pid] + worker_title = ( + clean_text( + worker_agent.get("agent_name") or worker_agent.get("title"), + 120, + ) + or f"PID {worker_pid}" + ) + worker_status = clean_text(worker_agent.get("status"), 60) or "unknown" + mapped_claude.add(worker_pid) + else: + worker_title = "Claude worker" + worker_status = "running" + lineage_subagents.append( + { + "provider": ("codex" if worker_kind == "codex" else "claude"), + "uuid": worker_uuid, + "pid": worker_pid, + "title": worker_title, + "status": worker_status, + } + ) + lineage_subagents.sort( + key=lambda item: ( + str(item["provider"]), + str(item["title"]).casefold(), + int(item["pid"]), + ) + ) + + def include_lineage_subagents( + existing: Sequence[Any], + ) -> list[dict[str, Any]]: + combined = [dict(item) for item in existing if isinstance(item, Mapping)] + for worker in lineage_subagents: + if any( + item.get("pid") == worker["pid"] + or ( + item.get("provider") == worker["provider"] + and item.get("uuid") == worker["uuid"] + ) + for item in combined + ): + continue + combined.append(dict(worker)) + return combined + + claude_parent_ids: set[str] = set() + claude_parent_evidence = False + + pid: int | None + uuid: str | None + starting_provider = None + provider_title_is_explicit = True + native_name_source = "" + native_name_since: Any = None + provider_title_state = "ready" + claude_color_evidence = "" + if owner_claim and owner_claim[0] == "claude-agent": + pid = owner_claim[1] + agent = claude_by_pid[pid] + uuid = agent["uuid"] + native_title = agent["title"] + native_name_source = agent.get("name_source") or "" + native_name_since = agent.get("name_since") + claude_color_evidence = agent.get("agent_color") or "" + # Claude persists its conversation auto-title as a transcript + # ai-title record while the session record keeps a derived window + # label. The visible conversation title outranks the derived + # label; any explicit rename keeps outranking both. + if agent.get("agent_name"): + native_title = agent["agent_name"] + native_name_source = "" + elif agent.get("name_source") == "derived": + if agent.get("ai_title"): + native_title = agent["ai_title"] + # Transcript hydration prepares the next start, but only the + # live structured agent name proves this process rendered it. + # External writes cannot repaint an already-running TUI. + if agent.get("title") != native_title: + provider_title_state = "pending" + provider = "claude" + cwd = agent["cwd"] or clean_text( + process_table.get(pid, {}).get("cwd"), 4096 + ) + identity = _agent_identity( + provider=provider, + uuid=uuid, + pid=pid, + process_table=process_table, + provenance="claude agents --json", + confidence="exact", + ) + subagents = include_lineage_subagents(claude_subagents(uuid, process_table)) + recovery = recovery_spec(provider, uuid, cwd or None) + mapped_claude.add(pid) + agent_status = agent["status"] + needs_you = agent["needs_you"] + elif owner_claim and owner_claim[0] == "claude-native": + _, pid, uuid = owner_claim + provider = "claude" + native_title = "Claude started" + cwd = clean_text(process_table.get(pid, {}).get("cwd"), 4096) + identity = _agent_identity( + provider=provider, + uuid=uuid, + pid=pid, + process_table=process_table, + provenance="native Claude CLI session argument", + confidence="exact", + ) + subagents = include_lineage_subagents(claude_subagents(uuid, process_table)) + recovery = recovery_spec(provider, uuid, cwd or None) + mapped_claude.add(pid) + agent_status = "running" + needs_you = False + provider_title_is_explicit = False + elif owner_claim and owner_claim[0] == "codex": + _, pid, uuid = owner_claim + profile_threads, profile_edges = codex_rows_for_pid(pid) + thread = profile_threads.get(uuid, {}) + native_title = clean_text( + thread.get("session_index_name") + or thread.get("name") + or thread.get("title"), + 120, + ) + # The session-index name is exact rename evidence. A database + # title also counts as a real name on current Codex, which + # auto-titles threads there, recognizable by the schema carrying + # first_user_message separately. Older stores kept the raw prompt + # in the title, and a title that still echoes the first prompt is + # that same behavior on any schema. + db_title = clean_text(thread.get("title"), 120) + first_message = clean_text(thread.get("first_user_message"), 200) + has_split_prompt_schema = "first_user_message" in thread + title_echoes_prompt = codex_title_echoes_prompt(db_title, first_message) + provider_title_is_explicit = bool(thread.get("session_index_name")) or ( + has_split_prompt_schema and bool(db_title) and not title_echoes_prompt + ) + provider = "codex" + cwd = clean_text( + thread.get("cwd") or process_table.get(pid, {}).get("cwd"), 4096 + ) + identity = _agent_identity( + provider=provider, + uuid=uuid, + pid=pid, + process_table=process_table, + provenance="native Codex PID open exact rollout", + confidence="exact", + ) + subagents = include_lineage_subagents(profile_edges.get(uuid, ())) + recovery = recovery_spec(provider, uuid, cwd or None) + mapped_codex.add(pid) + turn_state = _codex_turn_state(codex_index.get(pid, ()), uuid) + agent_status = turn_state + needs_you = turn_state == "needs your reply" + else: + known_provider_process = any( + _is_native_claude(process_table.get(pid, {})) + or _is_native_codex(process_table.get(pid, {})) + for pid in tree + ) + claude_count = len(claude_candidates) + len(native_claude_candidates) + ambiguous = ( + claude_count > 1 + or len(codex_candidates) > 1 + or (bool(claude_count) and bool(codex_candidates)) + ) + if ambiguous or known_provider_process or not root_pid: + provider = "unknown" + pid = root_pid + native_title = "Unresolved provider session" + cwd = clean_text(process_table.get(root_pid or -1, {}).get("cwd"), 4096) + provider_cwds = { + candidate_cwd + for candidate in tree + if ( + _is_native_claude(process_table.get(candidate, {})) + or _is_native_codex(process_table.get(candidate, {})) + ) + and ( + candidate_cwd := clean_text( + process_table.get(candidate, {}).get("cwd"), 4096 + ) + ).startswith("/") + } + if not cwd and len(provider_cwds) == 1: + cwd = next(iter(provider_cwds)) + diagnostics.append( + f"identity candidates: Claude={claude_count}, Codex={len(codex_candidates)}" + ) + agent_status = "unknown" + # A new Codex session has no rollout until its first message, so + # it cannot be identified, but calling it "unresolved" reads as + # broken when it is simply unused. Name what is actually running + # so the row is recognisable. Identity stays unknown: this is a + # label, and it grants nothing. + if not ambiguous: + running = { + "codex" + if _is_native_codex(process_table.get(candidate, {})) + else "claude" + if _is_native_claude(process_table.get(candidate, {})) + else clean_text( + process_table.get(candidate, {}).get("comm"), 128 + ) + for candidate in tree + } + for candidate_provider in ("codex", "claude"): + if candidate_provider in running: + starting_provider = candidate_provider + native_title = ( + f"{candidate_provider.title()} started, no messages yet" + ) + break + else: + provider = "shell" + native_title, cwd, pid = _shell_title( + tree, root_pid, process_table, own_processes + ) + agent_status = "idle" if native_title == "Idle shell" else "running" + uuid = None + identity = _agent_identity( + provider=provider, + uuid=None, + pid=pid, + process_table=process_table, + provenance="process tree" if root_pid else "none", + confidence="unknown" if provider == "unknown" else "exact", + ) + subagents = [] + recovery = _empty_recovery() + needs_you = False + + exact_argv = process_table.get(pid or -1, {}).get("cmdline") + exact_argv = ( + list(exact_argv) + if isinstance(exact_argv, Sequence) + and not isinstance(exact_argv, (str, bytes)) + else [] + ) + for index, argument in enumerate(exact_argv): + if argument == "--parent-session-id": + claude_parent_evidence = True + raw_parent = ( + exact_argv[index + 1] if index + 1 < len(exact_argv) else "" + ) + elif isinstance(argument, str) and argument.startswith( + "--parent-session-id=" + ): + claude_parent_evidence = True + raw_parent = argument.partition("=")[2] + else: + continue + parent_uuid = valid_uuid(raw_parent) + if parent_uuid: + claude_parent_ids.add(parent_uuid) + headless_codex_machine = ( + provider == "codex" + and _codex_first_positional(exact_argv) in CODEX_MACHINE_SUBCOMMANDS + ) + codex_app_server_driver = ( + _codex_app_server_driver( + pid, + exact_argv, + tree, + process_table, + _process_age(pid, process_table, current_time), + ) + if provider == "codex" + else "unknown" + ) + + title_key = f"{provider}:{uuid}" if uuid else "" + if ( + provider == "claude" + and title_key + and native_title + and _pending_native_title_matches( + pending_native_titles.get(title_key), + native_title, + native_name_since, + native_name_source, + ) + and pushed_titles.get(title_key) + ): + provider_title_state = "pending" + + title, title_source = provider_title_info( + provider, + uuid, + native_title, + aliases, + cwd, + shpool["started_at_unix_ms"], + automatic_titles, + provider_title_is_explicit=provider_title_is_explicit, + native_name_source=native_name_source, + native_name_since=native_name_since, + pushed_titles=pushed_titles, + pending_native_titles=pending_native_titles, + name_ownership=name_ownership, + ) + recent_output_at = recent_outputs.get(name) + if ( + isinstance(recent_output_at, bool) + or not isinstance(recent_output_at, int) + or recent_output_at < 0 + ): + recent_output_at = None + sessions.append( + _base_agent( + row=None, + shpool_id=name, + shpool_status=status, + availability=availability, + provider=provider, + identity=identity, + title=title, + title_source=title_source, + native_title=native_title, + cwd=cwd, + started_at_unix_ms=shpool["started_at_unix_ms"], + process_age_seconds=_process_age( + identity.get("pid"), process_table, current_time + ), + agent_status=agent_status, + needs_you=needs_you, + subagents=subagents, + recovery=recovery, + diagnostics=diagnostics, + shpool_shell=( + { + "pid": root_pid, + "process_start_ticks": process_table.get(root_pid, {}).get( + "start_ticks" + ), + } + if root_pid + else None + ), + ) + ) + sessions[-1]["aged_children"] = _aged_shell_children( + root_pid, + tree, + process_table, + current_time, + own_processes, + ) + account_alias = _exact_account_alias(pid, identity, process_table) + if account_alias: + sessions[-1]["account_alias"] = account_alias + sessions[-1]["account_switch_capable"] = _exact_account_capable( + pid, identity, process_table + ) + codex_parents = codex_parent_by_child.get(uuid or "", set()) + if claude_parent_evidence: + sessions[-1]["is_subagent"] = True + if len(claude_parent_ids) == 1: + sessions[-1]["parent_session"] = { + "provider": "claude", + "uuid": next(iter(claude_parent_ids)), + "provenance": "Claude --parent-session-id", + } + elif len(codex_parents) == 1: + sessions[-1]["is_subagent"] = True + sessions[-1]["parent_session"] = { + "provider": "codex", + "uuid": next(iter(codex_parents)), + "provenance": "Codex thread_spawn_edges", + } + elif codex_parents: + sessions[-1]["is_subagent"] = True + elif headless_codex_machine: + # ``codex exec``, ``codex review``, and the server modes + # (``app-server``, ``mcp-server``) are machine-driven -- no person + # converses with them. Their cross-provider caller is only + # process-ancestry evidence; if no durable provider edge names the + # parent, projection treats it as an orphan child behind the + # machine count. + sessions[-1]["is_subagent"] = True + elif codex_app_server_driver == "program": + # X16: a program holds this conversation and no window does, so it + # is nobody's top-level row. It is marked machine rather than made + # a child: nothing in the process tree names the DRIVING session, + # and a child whose parent cannot be named is projected into the + # orphan class, which no list and no count shows. Machine keeps it + # counted, and one keystroke away. + sessions[-1]["machine_driven"] = True + if codex_app_server_driver == "person": + # The other positive verdict, published because something has to + # be able to say that a provider restart FINISHED. The shell that + # relaunches a provider blocks on it and can never report that, + # so the bounce marker it leaves behind is cleared here instead -- + # by a sighting of the window, not by a timer. + sessions[-1]["app_server_window"] = True + if claude_color_evidence: + sessions[-1]["_claude_agent_color"] = claude_color_evidence + if provider == "claude": + sessions[-1]["provider_title_state"] = provider_title_state + sessions[-1]["blocking_question"] = bool( + agent.get("blocking_question") + if owner_claim and owner_claim[0] == "claude-agent" + else False + ) + elif provider == "codex": + # The rollout proves Codex needs a reply, but the app-server + # protocol records the kit reads expose no separately proven + # currently-open picker/approval marker. False `question` is the + # dangerous direction, so Codex remains `needs you`. + sessions[-1]["blocking_question"] = False + if provider == "unknown" and starting_provider: + # Display only. `provider` stays "unknown", so identity, mutation + # and recovery decisions are completely unaffected. + sessions[-1]["display_provider"] = starting_provider + sessions[-1]["recent_output_at_unix_ms"] = recent_output_at + sessions[-1]["recent_output_age_seconds"] = ( + max(0, int(current_time - (recent_output_at / 1000))) + if recent_output_at is not None + else None + ) + title_key = f"{provider}:{uuid}" if uuid else "" + attempts = automatic_failures.get(title_key, 0) + if provider not in PROVIDERS or not uuid: + automatic_state = "not-applicable" + elif not automatic_naming_enabled(): + automatic_state = "disabled" + elif provider == "claude" and provider_title_state == "pending": + automatic_state = "pending" + elif title_source in {"alias", "native"}: + automatic_state = "not-needed" + elif title_source == "automatic": + automatic_state = "ready" + elif attempts >= 2: + automatic_state = "failed" + else: + automatic_state = "pending" + sessions[-1]["automatic_name_state"] = automatic_state + sessions[-1]["automatic_name_attempts"] = attempts + + outside_agents: list[dict[str, Any]] = [] + for agent in claude_agents: + if ( + agent["pid"] in mapped_claude + or agent["kind"] not in {"", "interactive"} + or agent["pid"] not in process_table + ): + continue + uuid = agent["uuid"] + recovery = recovery_spec("claude", uuid, agent["cwd"] or None) + outside_native_title = agent["title"] + outside_name_source = agent.get("name_source") or "" + if agent.get("agent_name"): + outside_native_title = agent["agent_name"] + outside_name_source = "" + elif agent.get("ai_title") and outside_name_source == "derived": + outside_native_title = agent["ai_title"] + outside_title, outside_title_source = provider_title_info( + "claude", + uuid, + outside_native_title, + aliases, + agent["cwd"], + agent["started_at_unix_ms"], + automatic_titles, + native_name_source=outside_name_source, + pushed_titles=pushed_titles, + pending_native_titles=pending_native_titles, + name_ownership=name_ownership, + ) + outside_agents.append( + _base_agent( + row=None, + shpool_id=None, + shpool_status=None, + availability=None, + provider="claude", + identity=_agent_identity( + provider="claude", + uuid=uuid, + pid=agent["pid"], + process_table=process_table, + provenance="claude agents --json", + confidence="exact", + ), + title=outside_title, + title_source=outside_title_source, + native_title=outside_native_title, + cwd=agent["cwd"], + started_at_unix_ms=agent["started_at_unix_ms"], + process_age_seconds=_process_age( + agent["pid"], process_table, current_time + ), + agent_status=agent["status"], + needs_you=agent["needs_you"], + subagents=claude_subagents(uuid, process_table), + recovery=recovery, + diagnostics=["active provider root is outside shpool"], + shpool_shell=None, + ) + ) + account_alias = _exact_account_alias( + agent["pid"], outside_agents[-1]["identity"], process_table + ) + if account_alias: + outside_agents[-1]["account_alias"] = account_alias + outside_agents[-1]["account_switch_capable"] = _exact_account_capable( + agent["pid"], outside_agents[-1]["identity"], process_table + ) + if agent.get("agent_color"): + outside_agents[-1]["_claude_agent_color"] = agent["agent_color"] + for pid, metadata in codex_index.items(): + if pid in mapped_codex: + continue + identities = root_codex_uuid(process_table.get(pid, {}), metadata) + if len(identities) != 1: + continue + uuid = identities[0] + profile_threads, profile_edges = codex_rows_for_pid(pid) + thread = profile_threads.get(uuid, {}) + native_title = clean_text( + thread.get("session_index_name") + or thread.get("name") + or thread.get("title"), + 120, + ) + cwd = clean_text( + thread.get("cwd") or process_table.get(pid, {}).get("cwd"), 4096 + ) + outside_title, outside_title_source = provider_title_info( + "codex", + uuid, + native_title, + aliases, + cwd, + None, + automatic_titles, + provider_title_is_explicit=bool(thread.get("session_index_name")), + pushed_titles=pushed_titles, + name_ownership=name_ownership, + ) + turn_state = _codex_turn_state(metadata, uuid) + outside_agents.append( + _base_agent( + row=None, + shpool_id=None, + shpool_status=None, + availability=None, + provider="codex", + identity=_agent_identity( + provider="codex", + uuid=uuid, + pid=pid, + process_table=process_table, + provenance="native Codex PID open exact rollout", + confidence="exact", + ), + title=outside_title, + title_source=outside_title_source, + native_title=native_title, + cwd=cwd, + started_at_unix_ms=None, + process_age_seconds=_process_age(pid, process_table, current_time), + agent_status=turn_state, + needs_you=turn_state == "needs your reply", + subagents=list(profile_edges.get(uuid, ())), + recovery=recovery_spec("codex", uuid, cwd or None), + diagnostics=["active provider root is outside shpool"], + shpool_shell=None, + ) + ) + account_alias = _exact_account_alias( + pid, outside_agents[-1]["identity"], process_table + ) + if account_alias: + outside_agents[-1]["account_alias"] = account_alias + outside_agents[-1]["account_switch_capable"] = _exact_account_capable( + pid, outside_agents[-1]["identity"], process_table + ) + _attach_aged_workers(sessions + outside_agents, process_table, current_time) + + # A spawn edge is history, not liveness. A sub-agent entry with no pid is + # only a recorded parent->child edge; once the child finished it kept + # rendering as an open sub-agent for days (operator finding X20, + # 2026-08-14: rows idle since the previous day, pid null). Completion + # means closure: a pid-less entry whose thread is not alive anywhere in + # this snapshot and whose observed turn state is "idle" -- the completed + # state -- is dropped. Everything else stays: a pid is a real process, a + # live session row is a worker process, "working" is a Codex child + # executing inside the parent process (no pid of its own), and the + # question states plus "state unavailable" err VISIBLE rather than hide + # a pending reply or an unreadable rollout (the X16/X17 lesson). + live_thread_uuids = { + (item.get("identity") or {}).get("uuid") for item in sessions + outside_agents + } + live_thread_uuids.discard(None) + for item in sessions + outside_agents: + item["subagents"] = [ + child + for child in item.get("subagents") or [] + if child.get("pid") is not None + or (child.get("uuid") and child["uuid"] in live_thread_uuids) + or str(child.get("status") or "").casefold() != "idle" + ] + sessions.sort(key=canonical_session_order_key) + for row, item in enumerate(sessions, start=1): + item["row"] = row + outside_agents.sort( + key=lambda item: ( + PROVIDER_ORDER.get(item["provider"], 9), + item["title"].casefold(), + item["identity"].get("uuid") or "", + ) + ) + color_overrides = valid_colors(config.get("colors")) + color_overrides = adopt_launch_colors(config, sessions, color_overrides) + for item in sessions + outside_agents: + # A Claude session's transcript agent-color is what the session + # actually renders in its own prompt chip; it must beat the identity + # hash or the list shows a different color than the session itself + # (a stored pink rendered yellow, proven live 2026-08-02). Explicit + # config overrides still win over both. + raw_uuid = (item.get("identity") or {}).get("uuid") + display_uuid = valid_uuid(raw_uuid) + item_provider = item.get("provider") + provider = item_provider if isinstance(item_provider, str) else "unknown" + evidence = item.get("_claude_agent_color") + if ( + item.get("provider") == "claude" + and display_uuid + and evidence in claude_palette + and f"claude:{display_uuid}" not in color_overrides + ): + item["display_color"] = evidence + else: + item["display_color"] = session_color( + provider, + display_uuid, + color_overrides, + ) + item.pop("_claude_agent_color", None) + return { + "schema_version": schema_version, + "generated_at": _utc_now(current_time), + "source": "live", + "stale": False, + "warnings": [], + "daemon_generation": daemon_generation(process_table), + "sessions": sessions, + "outside_agents": outside_agents, + } + + +def collect_live( + config: Mapping[str, Any], + *, + runner: Callable[[Sequence[str], float], str] | None = None, + proc_root: Path | None = None, + environ: Mapping[str, str], + default_runner: Callable[[Sequence[str], float], str], + command_json: Callable[..., Any], + shpool_executable: Callable[[], str], + platform_process_table: Callable[[Path, int], dict[int, dict[str, Any]]], + default_max_proc_nodes: int, + enrich_claude_payload: Callable[[Any], Any], + parse_claude_payload: Callable[[Any], list[dict[str, Any]]], + observe_claude_name: Callable[..., str], + codex_paths: Callable[[], tuple[Path, Path]], + index_codex_processes: Callable[ + [Mapping[int, Mapping[str, Any]], Path, Path], + dict[int, list[dict[str, Any]]], + ], + read_codex_session_index: Callable[[Path, list[str]], dict[str, str]], + read_codex_db: Callable[ + [Path, Mapping[str, str]], + tuple[dict[str, dict[str, Any]], dict[str, list[dict[str, Any]]]], + ], + recent_output_times: Callable[..., dict[str, int]], + build_inventory: Callable[..., dict[str, Any]], + daemon_identity: Callable[[Mapping[int, Mapping[str, Any]]], dict[str, int] | None], + apply_provider_title_states: Callable[[dict[str, Any], Mapping[str, Any]], Any], + apply_retained_setup_attributions: Callable[[dict[str, Any]], Any], +) -> dict[str, Any]: + """Collect a live inventory using one call per external list command.""" + invoke = runner or default_runner + timeout = float(config.get("command_timeout_seconds", 6.0)) + root = proc_root or gated_proc_root(environ) + max_proc_nodes = int(config.get("max_proc_nodes", default_max_proc_nodes)) + before_table = platform_process_table(root, max_proc_nodes) + before_identity = daemon_identity(before_table) + try: + shpool_payload = command_json( + fixture_env="SESSION_KIT_SHPOOL_JSON_FILE", + command_env="SESSION_KIT_SHPOOL_CMD", + default_command=(shpool_executable(), "list", "--json"), + runner=invoke, + timeout=timeout, + ) + except ( + OSError, + ValueError, + subprocess.SubprocessError, + CollectionError, + ) as exc: + raise CollectionError(f"cannot collect shpool snapshot: {exc}") from exc + process_table = platform_process_table(root, max_proc_nodes) + after_identity = daemon_identity(process_table) + payload_binding = ( + before_identity + if before_identity is not None and before_identity == after_identity + else None + ) + _parse_shpool_payload(shpool_payload) + warnings: list[str] = [] + account_home = Path(environ.get("HOME") or Path.home()) + configured_claude_root = environ.get("CLAUDE_CONFIG_DIR", "") + default_claude_root = _provider_profile_path( + configured_claude_root, account_home / ".claude" + ) + if default_claude_root is None: + default_claude_root = account_home / ".claude" + default_claude_key = _profile_key(default_claude_root) + claude_profiles: dict[str, Path] = {default_claude_key: default_claude_root} + claude_process_profiles: dict[int, str | None] = {} + for pid, process in process_table.items(): + if not _is_native_claude(process): + continue + profile = _provider_profile_path( + process.get("claude_config_dir"), default_claude_root + ) + if profile is None: + claude_process_profiles[pid] = None + continue + key = _profile_key(profile) + claude_process_profiles[pid] = key + claude_profiles[key] = profile + + claude_payload: list[Any] = [] + failed_claude_profiles: set[str | None] = set() + ordered_claude_profiles = [ + default_claude_key, + *sorted(key for key in claude_profiles if key != default_claude_key), + ] + for profile_key in ordered_claude_profiles: + profile = claude_profiles[profile_key] + command = ( + ("claude", "agents", "--json") + if profile_key == default_claude_key + else ( + "env", + f"CLAUDE_CONFIG_DIR={profile}", + "claude", + "agents", + "--json", + ) + ) + try: + profile_payload = command_json( + fixture_env="SESSION_KIT_CLAUDE_JSON_FILE", + command_env="SESSION_KIT_CLAUDE_CMD", + default_command=command, + runner=invoke, + timeout=timeout, + ) + if not isinstance(profile_payload, list): + raise CollectionError("claude agents --json returned a non-array") + for item in profile_payload: + if not isinstance(item, Mapping): + continue + agent_pid = item.get("pid") + if not isinstance(agent_pid, int): + continue + if claude_process_profiles.get(agent_pid) != profile_key: + continue + retained = dict(item) + retained["_session_kit_claude_config_dir"] = os.fspath(profile) + claude_payload.append(retained) + except ( + OSError, + ValueError, + subprocess.SubprocessError, + CollectionError, + ) as exc: + failed_claude_profiles.add(profile_key) + warnings.append( + f"Claude inventory unavailable: {clean_text(str(exc), 240)}" + ) + if any(value is None for value in claude_process_profiles.values()): + failed_claude_profiles.add(None) + warnings.append("Claude inventory unavailable: invalid live profile path") + try: + claude_payload = enrich_claude_payload(claude_payload) + parsed_claude = parse_claude_payload(claude_payload) + for agent in parsed_claude: + # This is the PID registry observation itself. Agreement retires + # a lag exception; changed nameSince makes divergence a later + # human act even when the person reused the exact pre-push text. + observe_claude_name( + "claude", + agent["uuid"], + agent["title"], + native_name_source=agent.get("name_source") or "", + native_name_since=agent.get("name_since"), + environ=environ, + ) + except (OSError, ValueError, subprocess.SubprocessError, CollectionError) as exc: + claude_payload = [] + failed_claude_profiles.update(ordered_claude_profiles) + warnings.append(f"Claude inventory unavailable: {clean_text(str(exc), 240)}") + + codex_home, db_path = codex_paths() + codex_index = index_codex_processes(process_table, root, codex_home) + default_codex_key = _profile_key(codex_home) + codex_profiles: dict[str, Path] = {default_codex_key: codex_home} + codex_process_profiles: dict[int, str | None] = {} + for pid, process in process_table.items(): + if not _is_native_codex(process): + continue + profile = _provider_profile_path(process.get("codex_home"), codex_home) + if profile is None: + codex_process_profiles[pid] = None + continue + key = _profile_key(profile) + codex_process_profiles[pid] = key + codex_profiles[key] = profile + + naming_warnings: list[str] = [] + codex_profile_rows: dict[str, tuple[dict[str, Any], dict[str, Any]]] = {} + failed_codex_profiles: set[str | None] = set() + visible_codex_profiles = { + profile_key + for pid, profile_key in codex_process_profiles.items() + if codex_index.get(pid) + } + ordered_codex_profiles = [ + default_codex_key, + *sorted(key for key in codex_profiles if key != default_codex_key), + ] + for profile_key in ordered_codex_profiles: + profile = codex_profiles[profile_key] + profile_db = db_path + if profile_key != default_codex_key: + databases = _codex_state_databases(profile) + profile_db = databases[-1] if databases else profile / "state_5.sqlite" + session_index_names: dict[str, str] = {} + try: + session_index_names = read_codex_session_index( + profile / "session_index.jsonl", naming_warnings + ) + except CollectionError as exc: + naming_warnings.append( + f"Codex session names unavailable: {clean_text(str(exc), 240)}" + ) + try: + profile_rows = read_codex_db(profile_db, session_index_names) + except (OSError, sqlite3.Error, CollectionError) as exc: + profile_rows = ( + { + uuid: { + "id": uuid, + "title": "", + "cwd": "", + "session_index_name": title, + } + for uuid, title in session_index_names.items() + }, + {}, + ) + failed_codex_profiles.add(profile_key) + if profile_key in visible_codex_profiles: + warnings.append( + f"Codex metadata unavailable: {clean_text(str(exc), 240)}" + ) + else: + if not profile_db.is_file(): + failed_codex_profiles.add(profile_key) + if profile_key in visible_codex_profiles: + warnings.append( + "Codex metadata unavailable: database does not exist: " + f"{clean_text(str(profile_db), 240)}" + ) + codex_profile_rows[profile_key] = profile_rows + if any(value is None for value in codex_process_profiles.values()): + failed_codex_profiles.add(None) + warnings.append("Codex metadata unavailable: invalid live profile path") + + merged_codex_threads: dict[str, Any] = {} + merged_codex_edges: dict[str, Any] = {} + for profile_key in ordered_codex_profiles: + profile_threads, profile_edges = codex_profile_rows[profile_key] + for uuid, thread in profile_threads.items(): + merged_codex_threads.setdefault(uuid, thread) + for uuid, edges in profile_edges.items(): + merged_codex_edges.setdefault(uuid, edges) + codex_rows = ( + merged_codex_threads, + merged_codex_edges, + { + "default_home": os.fspath(codex_home), + "profiles": codex_profile_rows, + }, + ) + recent_outputs = recent_output_times( + item["name"] for item in _parse_shpool_payload(shpool_payload) + ) + publish_table = platform_process_table(root, max_proc_nodes) + publish_identity = daemon_identity(publish_table) + publish_binding = ( + payload_binding + if payload_binding is not None and payload_binding == publish_identity + else None + ) + inventory = build_inventory( + shpool_payload, + claude_payload, + process_table, + codex_index, + codex_rows, + config, + recent_output_by_shpool_id=recent_outputs, + daemon_binding=publish_binding, + ) + apply_provider_title_states(inventory, config) + apply_retained_setup_attributions(inventory) + inventory["warnings"].extend(warnings) + inventory["naming_warnings"] = naming_warnings + # Optional providers may be absent on a general-purpose installation. + # A query failure is material only when that provider is visibly running. + claude_failed = any( + profile_key in failed_claude_profiles + for profile_key in claude_process_profiles.values() + ) + codex_failed = any( + profile_key in failed_codex_profiles + for pid, profile_key in codex_process_profiles.items() + if codex_index.get(pid) + ) + inventory["_complete"] = not (claude_failed or codex_failed) + return inventory diff --git a/lib/sessionkit_inventory/colors.py b/lib/sessionkit_inventory/colors.py new file mode 100644 index 0000000..6f801bf --- /dev/null +++ b/lib/sessionkit_inventory/colors.py @@ -0,0 +1,588 @@ +"""Session colour: the palettes, their pure derivations, and colour state. + +The palettes live here together with everything that validates or rotates +against them, so widening one is an edit in this module plus the truecolor table +in ``render``. Provider colour pushes stay with the naming domain: they share +the propagation and retry machinery with title pushes rather than the palette. + +There are two palettes because the providers are not equally free. Claude Code's +``/color`` accepts exactly eight names and rejects everything else, so the Claude +palette is a hard external constraint rather than a preference. Codex resolves a +colour from an ``sk-*.tmTheme`` file this kit ships and applies no allow-list at +all, so its palette is whatever we build themes for. Giving Codex six names that +Claude cannot use means a Claude window and a Codex window can never land on the +same colour, and each still shows its own native tint. + +Every function that consumes a palette takes it as an argument, and the facade +decides which one applies from the provider. That keeps the two constants the +single place the in-force palettes are declared, and it means a caller that +forgets the provider gets an empty palette rather than the wrong one. +""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import time +from typing import Any, Callable, Iterable, Mapping, Sequence + +from .common import PROVIDERS, CollectionError, valid_uuid + + +# Claude Code's /color accepts these eight names and nothing else. Twenty-two +# names were probed against Claude Code 2.1.223 with known-good and known-bad +# controls: every other name is rejected, and gray/grey resolve to `default`, +# which is no colour at all. This tuple is therefore a measurement, not a taste +# decision, do not add to it without re-probing the provider. +CLAUDE_SESSION_COLORS = ( + "red", + "blue", + "green", + "yellow", + "purple", + "orange", + "pink", + "cyan", +) + +# Codex reads a theme file from disk and applies no allow-list, so these six are +# ours to choose. They are deliberately disjoint from the Claude palette: no +# name here is one Claude Code would accept, which is what makes a cross-provider +# collision impossible rather than merely unlikely. Each has a matching +# config/codex-themes/sk-<name>.tmTheme; adding a name without its theme file +# leaves a Codex window untinted and trips the doctor's theme check. +CODEX_SESSION_COLORS = ( + "lime", + "magenta", + "silver", + "sand", + "sky", + "sea", +) + +# Every colour the kit knows. Use this only where the provider genuinely is not +# known, CLI argument choices, the shared reservation ledger, stored-document +# scans. Anywhere the provider IS known, use palette_for_provider: validating a +# Claude override against this union would accept `lime`, which Claude Code +# rejects at the moment it matters and leaves the window with no colour. +SESSION_COLORS = CLAUDE_SESSION_COLORS + CODEX_SESSION_COLORS + +# What a colour name IS on a terminal, as SGR parameters. The picker, `sp`, the +# Claude status line and the in-session prompt all draw the same session, so +# they all draw it from this one table rather than four copies that drift. The +# values are the measured render of each theme accent (see the renderer's note +# on how they were captured). +SESSION_SGR = { + "red": "38;2;237;93;93", + "blue": "38;2;97;166;240", + "green": "38;2;63;221;115", + "yellow": "38;2;249;215;108", + "purple": "38;2;173;115;239", + "orange": "38;2;242;144;81", + "pink": "38;2;240;113;177", + "cyan": "38;2;64;216;209", + "lime": "38;2;170;230;70", + "magenta": "38;2;255;95;255", + "silver": "38;2;205;210;220", + "sand": "38;2;214;178;130", + "sky": "38;2;150;205;255", + "sea": "38;2;95;235;170", +} + + +def palette_for_provider( + provider: str, + *, + claude_palette: Sequence[str], + codex_palette: Sequence[str], +) -> tuple[str, ...]: + """The in-force palette for one provider, or empty for anything else. + + Empty rather than a default is deliberate. A caller that reaches here with + ``shell`` or an unknown provider has no colour to assign, and returning a + populated palette would let it assign one that the provider cannot show. + """ + if provider == "claude": + return tuple(claude_palette) + if provider == "codex": + return tuple(codex_palette) + return () + + +# A brand-new Codex session has no conversation ID until Codex boots, so its +# launch theme cannot come from the identity hash. The launch color is picked +# deterministically from the shpool session name, recorded as a marker, and +# adopted as that conversation's explicit override the first time the live +# collector sees the session's real ID, from then on the window theme, the +# picker row, and every future resume agree. +LAUNCH_COLOR_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 + + +COLOR_RESERVATION_MAX_AGE_SECONDS = 10 * 60 + + +def session_color( + provider: str, + uuid: str | None, + overrides: Mapping[str, str] | None = None, + *, + palette: Sequence[str], +) -> str | None: + """Stable per-conversation color: explicit override, else identity hash.""" + if provider not in PROVIDERS or not uuid: + return None + exact_uuid = valid_uuid(uuid) + if not exact_uuid: + return None + override = (overrides or {}).get(f"{provider}:{exact_uuid}") + if override in palette: + return override + digest = hashlib.sha256(f"{provider}:{exact_uuid}".encode("utf-8")).digest() + return palette[digest[0] % len(palette)] + + +def first_free_color( + preferred: str, + occupied_colors: Iterable[str] = (), + *, + palette: Sequence[str], +) -> str: + """Keep ``preferred``; if it is taken, the next free colour in palette order. + + When every colour in the palette is occupied this returns ``preferred`` + unchanged and allows the repeat. That is the only honest answer: there is + no free colour to give, and it keeps the result a deterministic function of + the identity rather than of arrival order, so a session that has to share + shares with the same partner every time. + + This is the one place the rule lives. It used to exist twice, once in the + launch picker and once inlined in the conversation reservation, which is how + the two drifted: only one of them treated a palette-foreign colour as + unoccupied. + """ + if preferred not in palette: + return preferred + occupied = {color for color in occupied_colors if color in palette} + start = palette.index(preferred) + for offset in range(len(palette)): + candidate = palette[(start + offset) % len(palette)] + if candidate not in occupied: + return candidate + return preferred + + +def launch_color_for( + shpool_id: str, + occupied_colors: Iterable[str] = (), + *, + palette: Sequence[str], +) -> str: + digest = hashlib.sha256(f"launch:{shpool_id}".encode("utf-8")).digest() + return first_free_color( + palette[digest[0] % len(palette)], + occupied_colors, + palette=palette, + ) + + +def _launch_color_dir(config: Mapping[str, Any]) -> Path | None: + state_dir = config.get("state_dir") + if not state_dir: + return None + return Path(state_dir) / "launch-color" + + +def canonical_colors( + config: Mapping[str, Any], + *, + config_path: Callable[[], Path], + private_alias_document: Callable[..., dict[str, Any]], + valid_colors: Callable[[Any], dict[str, str]], +) -> dict[str, str]: + document = private_alias_document(config_path(), allow_missing=True) + return valid_colors(document.get("colors")) + + +def _active_color_reservations( + path: Path, + now: float, + *, + read_state_json: Callable[[Path], Any], + palette: Sequence[str], + reservation_max_age: float, +) -> dict[str, dict[str, Any]]: + raw = read_state_json(path) + entries = raw.get("entries", {}) if isinstance(raw, Mapping) else {} + return { + key: {"color": item["color"], "created_at": float(item["created_at"])} + for key, item in entries.items() + if isinstance(key, str) + and isinstance(item, Mapping) + and item.get("color") in palette + and isinstance(item.get("created_at"), (int, float)) + and not isinstance(item.get("created_at"), bool) + and 0 <= now - float(item["created_at"]) <= reservation_max_age + } + + +def record_launch_color( + config: Mapping[str, Any], + shpool_id: str, + occupied_colors: Iterable[str] = (), + *, + launch_color_dir: Callable[[Mapping[str, Any]], Path | None], + launch_color: Callable[..., str], + active_color_reservations: Callable[[Path, float], dict[str, dict[str, Any]]], + state_paths: Callable[[Mapping[str, Any]], Mapping[str, Path]], + state_lock: Callable[..., Any], + atomic_write_json: Callable[[Path, Any], None], + schema_version: int, +) -> str | None: + """Pick and persist a launch color for a session with no conversation ID.""" + if ( + not shpool_id + or "/" in shpool_id + or shpool_id.startswith(".") + or len(shpool_id) > 128 + ): + return None + directory = launch_color_dir(config) + if directory is None: + return launch_color(shpool_id, occupied_colors) + paths = state_paths(config) + with state_lock(paths["root"], paths["lock"]): + now = time.time() + reservations = active_color_reservations(paths["color_reservations"], now) + reservation_key = f"launch:{shpool_id}" + existing = reservations.get(reservation_key) + if existing: + color = str(existing["color"]) + else: + occupied = set(occupied_colors) + occupied.update(item["color"] for item in reservations.values()) + color = launch_color(shpool_id, occupied) + reservations[reservation_key] = {"color": color, "created_at": now} + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(directory / shpool_id, flags, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(color + "\n") + handle.flush() + os.fsync(handle.fileno()) + atomic_write_json( + paths["color_reservations"], + {"schema_version": schema_version, "entries": reservations}, + ) + return color + + +def _reserve_conversation_color( + config: Mapping[str, Any], + provider: str, + uuid: str, + occupied_colors: Iterable[str], + *, + state_paths: Callable[[Mapping[str, Any]], Mapping[str, Path]], + config_path: Callable[[], Path], + state_lock: Callable[..., Any], + active_color_reservations: Callable[[Path, float], dict[str, dict[str, Any]]], + color_for_session: Callable[..., str | None], + free_color: Callable[..., str], + private_alias_parent: Callable[[Path], None], + private_alias_document: Callable[..., dict[str, Any]], + valid_colors: Callable[[Any], dict[str, str]], + atomic_write_json: Callable[[Path, Any], None], + schema_version: int, + palette: Sequence[str], +) -> str: + exact_uuid = valid_uuid(uuid) + if provider not in PROVIDERS or not exact_uuid: + raise CollectionError("conversation color requires an exact provider UUID") + paths = state_paths(config) + path = config_path() + key = f"{provider}:{exact_uuid}" + with state_lock(paths["root"], paths["config_lock"]): + with state_lock(paths["root"], paths["lock"]): + now = time.time() + reservations = active_color_reservations(paths["color_reservations"], now) + occupied = set(occupied_colors) + occupied.update( + item["color"] + for reservation_key, item in reservations.items() + if reservation_key != f"conversation:{key}" + ) + preferred = color_for_session(provider, exact_uuid) + assert preferred is not None + color = free_color(preferred, occupied, palette=palette) + private_alias_parent(path) + document = private_alias_document(path, allow_missing=True) + colors = valid_colors(document.get("colors")) + colors[key] = color + document["colors"] = dict(sorted(colors.items())) + atomic_write_json(path, document) + reservations[f"conversation:{key}"] = {"color": color, "created_at": now} + atomic_write_json( + paths["color_reservations"], + {"schema_version": schema_version, "entries": reservations}, + ) + return color + + +def reconcile_conversation_colors( + config: Mapping[str, Any], + sessions: Sequence[Mapping[str, Any]], + *, + config_path: Callable[[], Path], + state_paths: Callable[[Mapping[str, Any]], Mapping[str, Path]], + state_lock: Callable[..., Any], + private_alias_parent: Callable[[Path], None], + private_alias_document: Callable[..., dict[str, Any]], + valid_colors: Callable[[Any], dict[str, str]], + atomic_write_json: Callable[[Path, Any], None], + color_for_session: Callable[..., str | None], + free_color: Callable[..., str], + palette_for: Callable[[str], Sequence[str]], + revalidate_sessions: Callable[[], Sequence[Mapping[str, Any]]] | None = None, +) -> dict[str, Any]: + """Separate the live sessions that already share a colour, in one pass. + + Sessions that were already running when a palette changed would otherwise + keep their collision until each one happened to relaunch. This walks the + live rows instead and settles all of them at once. + + Running it twice must not shuffle anything a second time, and it cannot, + by construction rather than by a guard. Rows are settled in a fixed + ``(provider, uuid)`` order rather than in snapshot order, and each row + prefers the colour it currently shows. The first pass therefore hands every + row exactly the colour it will prefer on the second pass, every preference + is free at the moment it is asked for, and nothing moves. That holds at the + exhaustion boundary too: rows that had to repeat a colour prefer the + repeated one next time, find the palette full again, and keep it. + + An override is written only where the palette order moved a row off its own + identity hash. A row sitting on its hash colour needs no record, so the + stored set stays a list of deviations rather than a copy of the inventory. + Nothing is ever deleted here except entries that name a colour outside the + palette now in force, which are already ignored on read. + """ + + def ordered_from( + source: Sequence[Mapping[str, Any]], + ) -> list[tuple[str, str]]: + ordered: list[tuple[str, str]] = [] + seen: set[tuple[str, str]] = set() + for item in source: + if not isinstance(item, Mapping): + continue + provider = str(item.get("provider") or "") + if provider not in PROVIDERS or not palette_for(provider): + continue + identity = item.get("identity") + raw = identity.get("uuid") if isinstance(identity, Mapping) else None + exact = valid_uuid(str(raw or "")) + if not exact or (provider, exact) in seen: + continue + seen.add((provider, exact)) + ordered.append((provider, exact)) + ordered.sort() + return ordered + + ordered = ordered_from(sessions) + + path = config_path() + paths = state_paths(config) + with state_lock(paths["root"], paths["config_lock"]): + with state_lock(paths["root"], paths["lock"]): + if revalidate_sessions is not None: + ordered = ordered_from(revalidate_sessions()) + private_alias_parent(path) + document = private_alias_document(path, allow_missing=True) + raw_colors = document.get("colors") + before = dict(raw_colors) if isinstance(raw_colors, Mapping) else {} + colors = valid_colors(raw_colors) + dropped = sorted( + key for key in before if key not in colors or before[key] != colors[key] + ) + taken: dict[str, set[str]] = {} + moved: dict[str, str] = {} + for provider, exact in ordered: + key = f"{provider}:{exact}" + preferred = color_for_session(provider, exact, colors) + hashed = color_for_session(provider, exact, {}) + if preferred is None or hashed is None: + continue + held = taken.setdefault(provider, set()) + color = free_color(preferred, held, palette=palette_for(provider)) + held.add(color) + if color == hashed and key not in colors: + continue + if colors.get(key) != color: + colors[key] = color + moved[key] = color + if before != colors: + if colors: + document["colors"] = dict(sorted(colors.items())) + else: + document.pop("colors", None) + atomic_write_json(path, document) + return { + "moved": dict(sorted(moved.items())), + "dropped": dropped, + "colors": dict(sorted(colors.items())), + } + + +def _release_conversation_color( + config: Mapping[str, Any], + provider: str, + uuid: str, + color: str, + *, + state_paths: Callable[[Mapping[str, Any]], Mapping[str, Path]], + config_path: Callable[[], Path], + state_lock: Callable[..., Any], + active_color_reservations: Callable[[Path, float], dict[str, dict[str, Any]]], + private_alias_document: Callable[..., dict[str, Any]], + valid_colors: Callable[[Any], dict[str, str]], + atomic_write_json: Callable[[Path, Any], None], + schema_version: int, + palette: Sequence[str], +) -> bool: + """Roll back one failed pre-bake only when reservation and override match.""" + exact_uuid = valid_uuid(uuid) + if provider not in PROVIDERS or not exact_uuid or color not in palette: + return False + paths = state_paths(config) + path = config_path() + key = f"{provider}:{exact_uuid}" + reservation_key = f"conversation:{key}" + with state_lock(paths["root"], paths["config_lock"]): + with state_lock(paths["root"], paths["lock"]): + reservations = active_color_reservations( + paths["color_reservations"], time.time() + ) + reserved = reservations.get(reservation_key) + document = private_alias_document(path, allow_missing=True) + colors = valid_colors(document.get("colors")) + if ( + not reserved + or reserved.get("color") != color + or colors.get(key) != color + ): + return False + reservations.pop(reservation_key, None) + colors.pop(key, None) + if colors: + document["colors"] = dict(sorted(colors.items())) + else: + document.pop("colors", None) + atomic_write_json(path, document) + atomic_write_json( + paths["color_reservations"], + {"schema_version": schema_version, "entries": reservations}, + ) + return True + + +def _adopt_launch_colors( + config: Mapping[str, Any], + sessions: Sequence[Mapping[str, Any]], + overrides: Mapping[str, str], + *, + launch_color_dir: Callable[[Mapping[str, Any]], Path | None], + mutate_color: Callable[..., dict[str, str]], + palette: Sequence[str], + launch_color_max_age: float, +) -> dict[str, str]: + """Turn launch-color markers into explicit overrides once IDs are known.""" + current = dict(overrides) + directory = launch_color_dir(config) + if directory is None or not directory.is_dir(): + return current + now = time.time() + by_shpool_id: dict[str, Mapping[str, Any]] = { + str(item.get("shpool_id") or ""): item + for item in sessions + if item.get("provider") == "codex" + } + try: + markers = list(directory.iterdir()) + except OSError: + return current + for marker in markers: + try: + if marker.is_symlink() or not marker.is_file(): + continue + stat_result = marker.stat() + if now - stat_result.st_mtime > launch_color_max_age: + marker.unlink(missing_ok=True) + continue + item = by_shpool_id.get(marker.name) + if item is None: + continue + uuid = valid_uuid(str((item.get("identity") or {}).get("uuid") or "")) + if not uuid: + continue + color = marker.read_text(encoding="utf-8", errors="strict").strip() + if color not in palette: + marker.unlink(missing_ok=True) + continue + key = f"codex:{uuid}" + if key not in current: + # The session rows and marker were read before this helper's + # publishing lock. Re-read-and-add is safe; overwriting a key + # that appeared meanwhile would let that older reading replace + # a newer explicit choice. + current = mutate_color( + config, "codex", uuid, color, only_if_absent=True + ) + marker.unlink(missing_ok=True) + except (OSError, ValueError, CollectionError): + continue + return current + + +def mutate_canonical_color( + config: Mapping[str, Any], + provider: str, + uuid: str, + color: str | None, + *, + config_path: Callable[[], Path], + state_paths: Callable[[Mapping[str, Any]], Mapping[str, Path]], + state_lock: Callable[..., Any], + private_alias_parent: Callable[[Path], None], + private_alias_document: Callable[..., dict[str, Any]], + valid_colors: Callable[[Any], dict[str, str]], + atomic_write_json: Callable[[Path, Any], None], + palette: Sequence[str], + only_if_absent: bool = False, +) -> dict[str, str]: + exact_uuid = valid_uuid(uuid) + if provider not in PROVIDERS or not exact_uuid: + raise CollectionError("color requires provider claude|codex and an exact UUID") + if color is not None and color not in palette: + raise CollectionError("color must be one of: " + ", ".join(palette)) + path = config_path() + paths = state_paths(config) + with state_lock(paths["root"], paths["config_lock"]): + with state_lock(paths["root"], paths["lock"]): + private_alias_parent(path) + document = private_alias_document(path, allow_missing=True) + colors = valid_colors(document.get("colors")) + key = f"{provider}:{exact_uuid}" + if only_if_absent and key in colors: + return dict(colors) + if color is None: + colors.pop(key, None) + else: + colors[key] = color + if colors: + document["colors"] = dict(sorted(colors.items())) + else: + document.pop("colors", None) + atomic_write_json(path, document) + return dict(colors) diff --git a/lib/sessionkit_inventory/common.py b/lib/sessionkit_inventory/common.py index 6f9af79..7705baf 100644 --- a/lib/sessionkit_inventory/common.py +++ b/lib/sessionkit_inventory/common.py @@ -2,10 +2,16 @@ from __future__ import annotations +import datetime as dt +import json +import math import os from pathlib import Path import re -from typing import Any, Callable, Mapping +import shlex +import subprocess +import time +from typing import Any, Callable, Mapping, Sequence import unicodedata @@ -13,8 +19,17 @@ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE, ) +DEFAULT_PROC_ROOT = Path("/proc") PROVIDERS = ("claude", "codex") +# Who a session's name belongs to. "human" is permanent; "automatic" is the +# first-prompt claim one automatic pass takes and later passes respect. +NAME_OWNERS = ("human", "automatic") +# The one selection grammar, and the one sentence that describes it. +ASCII_DIGITS_RE = re.compile(r"[0-9]+") +MAX_SELECTION_SPAN = 200 +SELECTION_GRAMMAR = "use numbers such as 1,3,5, ranges such as 2-4, or all" MAX_OPERATIONAL_ID_BYTES = 128 +STALL_DEFAULT_SECONDS = 2700 LEGACY_OPERATIONAL_ID_RE = re.compile(r"^main(?:[1-9][0-9]*)?$") GENERATED_OPERATIONAL_ID_RE = re.compile( r"^s[0-9]{8}-[0-9]{6}-[1-9][0-9]*(?:-[1-9][0-9]*)?$" @@ -33,6 +48,18 @@ def _positive_int(value: Any, default: int, low: int, high: int) -> int: return parsed if low <= parsed <= high else default +def stall_threshold_seconds(environ: Mapping[str, str] | None = None) -> int: + """Return the one configured quiet-session threshold used by every surface.""" + + source = environ if environ is not None else os.environ + return _positive_int( + source.get("SESSION_KIT_STALL_SECONDS"), + STALL_DEFAULT_SECONDS, + 60, + 86400, + ) + + def _positive_float(value: Any, default: float, low: float, high: float) -> float: try: parsed = float(value) @@ -41,6 +68,11 @@ def _positive_float(value: Any, default: float, low: float, high: float) -> floa return parsed if low <= parsed <= high else default +def _load_json_file(path: Path) -> Any: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + def _home( *, environ: Mapping[str, str], @@ -127,6 +159,260 @@ def default_start_dir( return xdg_path("XDG_STATE_HOME", home() / ".local" / "state") / "shpool-start" +def _valid_aliases(raw: Any) -> dict[str, str]: + aliases: dict[str, str] = {} + if not isinstance(raw, Mapping): + return aliases + for key, value in raw.items(): + if not isinstance(key, str) or not isinstance(value, str): + continue + provider, separator, uuid = key.partition(":") + title = clean_text(value, 100) + if separator and provider in PROVIDERS and UUID_RE.fullmatch(uuid) and title: + aliases[f"{provider}:{uuid.lower()}"] = title + return aliases + + +def _valid_automatic_titles(raw: Any) -> dict[str, str]: + """Validate retained, provider/UUID-bound automatic display titles.""" + titles: dict[str, str] = {} + if not isinstance(raw, Mapping): + return titles + for key, value in raw.items(): + if not isinstance(key, str) or not isinstance(value, str): + continue + provider, separator, uuid = key.partition(":") + try: + title = normalize_automatic_title(value) + except CollectionError: + continue + if separator and provider in PROVIDERS and valid_uuid(uuid): + titles[f"{provider}:{uuid.lower()}"] = title + return titles + + +def _valid_automatic_title_failures(raw: Any) -> dict[str, int]: + failures: dict[str, int] = {} + if not isinstance(raw, Mapping): + return failures + for key, value in raw.items(): + provider, separator, uuid = ( + key.partition(":") if isinstance(key, str) else ("", "", "") + ) + if ( + separator + and provider in PROVIDERS + and valid_uuid(uuid) + and not isinstance(value, bool) + and isinstance(value, int) + and 0 < value <= 2 + ): + failures[f"{provider}:{uuid.lower()}"] = value + return failures + + +def parse_number_selection(answer: Any, count: int) -> list[int]: + """One grammar for every place a person types session numbers. + + Accepts ``all`` (and its ``a`` shorthand), comma- or space-separated + numbers, and inclusive ranges written ``2-5``, in any mixture. Returns the + chosen numbers in ascending order with duplicates collapsed; an empty + answer chooses nothing. Anything else raises with the one sentence every + surface says, so a person who learns the grammar in one place has learnt + it everywhere. + + Only ASCII digits count. Unicode digits pass ``str.isdigit()`` and then + either mean something surprising or raise inside ``int()``, and a picker + is no place for a traceback. + """ + if not isinstance(answer, str): + raise CollectionError(SELECTION_GRAMMAR) + text = answer.strip().casefold() + if not text: + return [] + if text in {"a", "all"}: + return list(range(1, count + 1)) + picked: set[int] = set() + for chunk in text.split(","): + # An empty entry between commas is a typo, not an empty choice. + parts = chunk.split() + if not parts: + raise CollectionError(SELECTION_GRAMMAR) + for part in parts: + first, separator, last = part.partition("-") + if separator: + if not ( + ASCII_DIGITS_RE.fullmatch(first) and ASCII_DIGITS_RE.fullmatch(last) + ): + raise CollectionError(SELECTION_GRAMMAR) + low, high = int(first), int(last) + if low > high: + raise CollectionError(SELECTION_GRAMMAR) + if high - low >= MAX_SELECTION_SPAN: + # Refuse before building the set: a mistyped 1-99999999 + # must cost a sentence, not the memory of the machine. + raise CollectionError( + f"a range covers at most {MAX_SELECTION_SPAN} numbers" + ) + picked.update(range(low, high + 1)) + elif ASCII_DIGITS_RE.fullmatch(part): + picked.add(int(part)) + else: + raise CollectionError(SELECTION_GRAMMAR) + if not picked: + raise CollectionError(SELECTION_GRAMMAR) + if any(number < 1 or number > count for number in picked): + raise CollectionError(f"choose between 1 and {count}") + return sorted(picked) + + +def _valid_pushed_titles(raw: Any) -> dict[str, str]: + """The exact title the kit last wrote into each provider's own store. + + This is the discriminator for a provider-native human rename. Session Kit + pushes its own titles into Claude's transcript records and Codex's thread + table, so "the native store disagrees with us" means nothing on its own, + half the time the kit put that value there. A native title that differs + from the last value the kit itself pushed is somebody typing /rename. + """ + titles: dict[str, str] = {} + if not isinstance(raw, Mapping): + return titles + for key, value in raw.items(): + if not isinstance(key, str) or not isinstance(value, str): + continue + provider, separator, uuid = key.partition(":") + title = clean_text(value, 120) + if separator and provider in PROVIDERS and valid_uuid(uuid) and title: + titles[f"{provider}:{uuid.lower()}"] = title + return titles + + +def _valid_name_since(value: Any) -> str | int | float | None: + """One exact Claude ``nameSince`` value suitable for comparison.""" + if isinstance(value, bool): + return None + if isinstance(value, int) and value >= 0: + return value + if isinstance(value, float) and math.isfinite(value) and value >= 0: + return value + if isinstance(value, str) and 0 < len(value) <= 120: + return value + return None + + +def _valid_pending_native_titles(raw: Any) -> dict[str, dict[str, Any]]: + """Claude registry observations made immediately before a title push. + + A bare pre-push string cannot prove lag: only an unchanged ``nameSince`` + can. Old entries and records made by an older Claude without that field + are therefore ignored instead of suppressing a later human rename. + """ + pending: dict[str, dict[str, Any]] = {} + if not isinstance(raw, Mapping): + return pending + for key, value in raw.items(): + if not isinstance(key, str) or not isinstance(value, Mapping): + continue + provider, separator, uuid = key.partition(":") + title = clean_text(value.get("title"), 120) + name_since = _valid_name_since(value.get("nameSince")) + raw_source = value.get("nameSource", "") + name_source = clean_text(raw_source, 20) + if ( + separator + and provider == "claude" + and valid_uuid(uuid) + and title + and name_since is not None + and isinstance(raw_source, str) + ): + pending[f"claude:{uuid.lower()}"] = { + "title": title, + "nameSince": name_since, + "nameSource": name_source, + } + return pending + + +def _pending_native_title_matches( + observation: Mapping[str, Any] | None, + title: str, + name_since: Any, + name_source: str, +) -> bool: + """Whether a native value is still the exact pre-push registry record.""" + if not isinstance(observation, Mapping): + return False + current_since = _valid_name_since(name_since) + return bool( + current_since is not None + and observation.get("title") == title + and observation.get("nameSince") == current_since + ) + + +def _valid_name_ownership(raw: Any) -> dict[str, dict[str, str]]: + """Validate the durable record of who named each session. + + ``human`` is an override: a person renamed the session through `sp name` + or the picker, and no automatic pass may ever rename it again, not after + a `sp name reset`, not after a restart. ``automatic`` is the one-shot + claim taken at a thread's first prompt, so no second automatic pass + re-titles work a first pass already named. + """ + owners: dict[str, dict[str, str]] = {} + if not isinstance(raw, Mapping): + return owners + for key, value in raw.items(): + if not isinstance(key, str) or not isinstance(value, Mapping): + continue + provider, separator, uuid = key.partition(":") + owner = value.get("owner") + if ( + separator + and provider in PROVIDERS + and valid_uuid(uuid) + and owner in NAME_OWNERS + ): + owners[f"{provider}:{uuid.lower()}"] = { + "owner": str(owner), + "at": clean_text(value.get("at"), 40), + } + return owners + + +def _valid_colors( + raw: Any, + *, + palette_for: Callable[[str], Sequence[str]], +) -> dict[str, str]: + """Validate provider/UUID-bound colors against each provider's own palette. + + The key carries the provider, so every entry is checked against the palette + that provider can actually display rather than against the union. An entry + naming a colour outside its provider's palette is dropped, not corrected: + that is what silently migrates a stored override left behind by an earlier + palette, since the caller then falls through to the identity hash and lands + inside the palette now in force. + """ + colors: dict[str, str] = {} + if not isinstance(raw, Mapping): + return colors + for key, value in raw.items(): + if not isinstance(key, str) or not isinstance(value, str): + continue + provider, separator, uuid = key.partition(":") + if ( + separator + and provider in PROVIDERS + and UUID_RE.fullmatch(uuid) + and value in palette_for(provider) + ): + colors[f"{provider}:{uuid.lower()}"] = value + return colors + + def load_config( *, config_path: Callable[[], Path], @@ -196,6 +482,25 @@ def valid_uuid(value: Any) -> str | None: return None +def proc_root(environ: Mapping[str, str] | None = None) -> Path: + """Return the process table root, honouring the fixture root only in tests. + + ``/proc`` is where every identity decision in the kit ultimately gets its + answer: which PIDs exist, when each started, what each is running, who its + parent is. ``SESSION_KIT_PROC_ROOT`` points that at a directory tree the + caller controls, which a test needs and production must never accept -- + an attacker who can set one environment variable would otherwise be able + to hand-author the process table the kit proves session identity against. + The ``SESSION_KIT_TESTING`` gate is applied here so no reader can forget + it, and every reader in the tree goes through this function. + """ + values = environ if environ is not None else os.environ + override = values.get("SESSION_KIT_PROC_ROOT") + if override and values.get("SESSION_KIT_TESTING") == "1": + return Path(override) + return DEFAULT_PROC_ROOT + + def automatic_naming_enabled(environ: Mapping[str, str] | None = None) -> bool: """Return false only for the explicit automatic-name kill switch.""" values = environ if environ is not None else os.environ @@ -257,3 +562,78 @@ def shpool_id_mutation_policy(raw: Any) -> tuple[bool, str | None]: ): return True, None return False, "unmanaged" + + +def display_shpool_id(raw: str, limit: int = 32) -> str: + display_source = "".join( + " " if unicodedata.category(character).startswith("C") else character + for character in raw + ) + visible = clean_text(display_source, 10000) + if not visible: + visible = "(non-printing ID)" + return visible if len(visible) <= limit else f"{visible[: limit - 1]}…" + + +def _utc_now(now: float | None = None) -> str: + instant = dt.datetime.fromtimestamp( + now if now is not None else time.time(), dt.timezone.utc + ) + return instant.isoformat(timespec="seconds").replace("+00:00", "Z") + + +def _command_from_env( + env_name: str, + default: str, + *, + environ: Mapping[str, str], +) -> list[str]: + value = environ.get(env_name) + if value: + command = shlex.split(value) + if command: + return command + return [default] + + +def default_runner(argv: Sequence[str], timeout: float) -> str: + completed = subprocess.run( + list(argv), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=timeout, + check=False, + ) + if completed.returncode != 0: + detail = clean_text(completed.stderr, 240) or f"exit {completed.returncode}" + raise CollectionError(f"{shlex.join(argv)} failed: {detail}") + return completed.stdout + + +def _command_json( + *, + fixture_env: str, + command_env: str, + default_command: Sequence[str], + runner: Callable[[Sequence[str], float], str], + timeout: float, + environ: Mapping[str, str], + load_json_file: Callable[[Path], Any], + command_from_env: Callable[[str, str], list[str]], +) -> Any: + """Read one provider snapshot from a fixture or the configured command. + + The fixture variable replaces the provider's own answer with a file of the + caller's choosing, and that answer is what the inventory treats as the + truth about which sessions exist. Outside the isolated test harness it is + ignored, so no environment variable can substitute a fabricated roster for + what shpool and the provider CLIs actually report. ``command_env`` stays + ungated: pointing at a differently installed binary is real configuration. + """ + fixture = environ.get(fixture_env) + if fixture and environ.get("SESSION_KIT_TESTING") == "1": + return load_json_file(Path(fixture).expanduser()) + prefix = command_from_env(command_env, default_command[0]) + return json.loads(runner([*prefix, *default_command[1:]], timeout)) diff --git a/lib/sessionkit_inventory/history_search.py b/lib/sessionkit_inventory/history_search.py new file mode 100644 index 0000000..089f12b --- /dev/null +++ b/lib/sessionkit_inventory/history_search.py @@ -0,0 +1,337 @@ +"""Search recorded history without exposing its storage layout.""" +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from datetime import datetime +import gzip +import json +from pathlib import Path +import re +import unicodedata +from typing import Iterable, Mapping + +MAX_SHOWN_LINES = 8 +MAX_MATCH_BYTES = 512 +MAX_CAPTURE_CHARS = 2048 +MAX_METADATA_LINES = 4096 +ARCHIVE_ID = re.compile(r"^\d{8}-\d{6}-(.+?)\.raw\.gz$") +RECORDING_DATE = re.compile(r"(?:^|[^0-9])(\d{8})-(\d{6})(?:[^0-9]|$)") +SCRIPT_DATE = re.compile(r"Script started on (\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})") +ANSI_SEQUENCE = re.compile( + r"\x1b(?:" + r"\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]" + r"|\][^\x07\x1b]*(?:\x07|\x1b\\)" + r"|[PX\^_][^\x1b\x07]*(?:\x07|\x1b\\)" + r"|[\x20-\x2f]+[\x30-\x7e]" + r"|[\x30-\x4f\x51-\x57\x59\x5a\x5c\x60-\x7e]" + r")" +) + + +@dataclass(frozen=True) +class RecordingText: + count: int + shown: list[str] + prompt: str = "" + started: str = "" + + +def _clean(value: object, limit: int = 120) -> str: + text = " ".join(str(value or "").split()) + return "".join(ch for ch in text if ch >= " " and ch != "\x7f")[:limit] + + +def _read_json(path: Path) -> object: + try: + with open(path, encoding="utf-8") as handle: + return json.load(handle) + except (OSError, ValueError): + return {} + + +def session_names( + inventory: Path | None, recovery: Path, data: Path +) -> dict[tuple[str, str], str]: + """Names keyed by both store and session identity. + + Shpool names are reused. In particular, a live ``main2`` title says + nothing about an older ``main2`` archive, so title provenance is part of + the key rather than one global id-to-name table. + """ + names: dict[tuple[str, str], str] = {} + document = _read_json(inventory) if inventory else {} + if isinstance(document, Mapping): + for row in document.get("sessions") or (): + if not isinstance(row, Mapping): + continue + session_id = row.get("shpool_id_raw") + title = _clean(row.get("title")) + if isinstance(session_id, str) and session_id and title: + names[("journal", session_id)] = title + manifest = _read_json(recovery / "recovery-manifest.json") + rows = manifest.get("sessions") if isinstance(manifest, Mapping) else None + manifest_rows: Iterable[tuple[object, object]] + if isinstance(rows, Mapping): + manifest_rows = rows.items() + else: + manifest_rows = (("", row) for row in (rows or ())) + for manifest_id, row in manifest_rows: + if not isinstance(row, Mapping): + continue + session_id = ( + row.get("shpool_id") or row.get("shpool_id_raw") or manifest_id + ) + title = _clean(row.get("title")) + if isinstance(session_id, str) and session_id and title: + names.setdefault(("recovery", session_id), title) + # Closed-session ledger rows have a reusable shpool id but no recording + # identity, so they cannot safely name archives. An archive is named only + # by metadata inside that recording, then its own date and first prompt. + return names + + +def _source_id(path: Path, journal: Path, recovery: Path, archive: Path) -> str: + if journal in path.parents and path.name.endswith(".rendered.txt"): + return path.name.removesuffix(".rendered.txt") + if journal in path.parents: + relative = path.relative_to(journal) + return relative.parts[0].removesuffix(".raw") + if recovery in path.parents: + try: + for line in (recovery / "current-map.tsv").read_text( + encoding="utf-8" + ).splitlines(): + session_id, recorded = line.split("\t", 1) + if Path(recorded) == path: + return session_id + except (OSError, ValueError): + pass + if archive in path.parents: + match = ARCHIVE_ID.match(path.name) + return match.group(1) if match else "" + return path.stem.removesuffix(".raw") + + +def _source_store(path: Path, journal: Path, recovery: Path, archive: Path) -> str: + if journal == path.parent or journal in path.parents: + return "journal" + if recovery == path.parent or recovery in path.parents: + return "recovery" + if archive == path.parent or archive in path.parents: + return "archive" + return "recording" + + +def _recording_key( + path: Path, journal: Path, recovery: Path, archive: Path +) -> tuple[str, str]: + """A storage-private identity; it is deliberately never rendered.""" + store = _source_store(path, journal, recovery, archive) + if store == "journal": + relative = path.relative_to(journal) + if relative.name == "rendered.txt": + return store, f"directory:{relative.parent.as_posix()}" + if relative.name.endswith(".rendered.txt"): + return store, f"sidecar:{relative.as_posix()}" + if len(relative.parts) > 1: + return store, f"directory:{relative.parent.as_posix()}" + return store, f"raw:{relative.name}" + # Recovery and archive files are snapshots. A recycled shpool id must not + # merge two independently recorded snapshots. + root = recovery if store == "recovery" else archive + try: + return store, path.relative_to(root).as_posix() + except ValueError: + return store, path.name + + +def sources(journal: Path, recovery: Path, archive: Path) -> Iterable[Path]: + rendered = sorted(journal.glob("**/rendered.txt")) + rendered += sorted(journal.glob("**/*.rendered.txt")) + rendered = list(dict.fromkeys(rendered)) + yield from rendered + for path in sorted(journal.glob("**/*.raw")): + sidecar = path.with_suffix(".rendered.txt") + directory_sidecar = path.parent / "rendered.txt" + if sidecar.is_file() or directory_sidecar.is_file(): + continue + yield path + yield from sorted(recovery.glob("**/*.raw")) + yield from sorted(archive.glob("**/*.gz")) + + +def _safe_line(value: str, limit: int = MAX_MATCH_BYTES) -> str: + text = ANSI_SEQUENCE.sub("", value.rstrip("\r\n")) + text = "".join( + character + for character in text + if not unicodedata.category(character).startswith("C") + ) + payload = text.encode("utf-8") + if len(payload) > limit: + text = payload[:limit].decode("utf-8", "ignore") + return text + + +def _prompt_from_line(line: str, waiting_for_operator: bool) -> tuple[str, bool]: + stripped = line.strip() + if not stripped: + return "", waiting_for_operator + if stripped.startswith("══ OPERATOR"): + return "", True + if waiting_for_operator: + return stripped, False + if stripped.startswith(("❯", "›")): + prompt = stripped[1:].strip() + # Startup convenience commands are not the subject of the recording. + if prompt and not prompt.startswith("/"): + return prompt, False + return "", waiting_for_operator + + +def _matching_lines(path: Path, query: str) -> RecordingText: + if path.suffix == ".gz": + handle = gzip.open(path, mode="rt", encoding="utf-8", errors="replace") + else: + handle = open(path, encoding="utf-8", errors="replace") + with handle: + count = 0 + shown: list[str] = [] + prompt = "" + started = "" + waiting_for_operator = False + for line_number, line in enumerate(handle, start=1): + if line_number <= MAX_METADATA_LINES and not started: + match = SCRIPT_DATE.search(line) + if match: + started = f"{match.group(1)} {match.group(2)}" + if line_number <= MAX_METADATA_LINES and not prompt: + candidate, waiting_for_operator = _prompt_from_line( + line, waiting_for_operator + ) + if candidate: + prompt = candidate[:MAX_MATCH_BYTES] + if query not in line.casefold(): + continue + count += 1 + if len(shown) < MAX_SHOWN_LINES: + # Retain a small amount of slack for output-time sanitation + # without keeping an arbitrarily large matched line alive. + shown.append(line[:MAX_CAPTURE_CHARS]) + return RecordingText(count, shown, prompt, started) + + +def _recording_date(path: Path, text: RecordingText) -> str: + if text.started: + return text.started + for part in reversed(path.parts): + match = RECORDING_DATE.search(part) + if match: + try: + stamp = datetime.strptime("".join(match.groups()), "%Y%m%d%H%M%S") + except ValueError: + continue + return stamp.strftime("%Y-%m-%d %H:%M") + try: + return datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M") + except OSError: + return "unknown date" + + +def _recording_label(path: Path, text: RecordingText) -> str: + label = f"Recorded {_recording_date(path, text)}" + if text.prompt: + prompt = _clean(_safe_line(text.prompt, 80), 80) + if prompt: + label += f", {prompt}" + return label + + +def render_matches( + query: str, + *, + journal: Path, + recovery: Path, + archive: Path, + inventory: Path | None = None, + data: Path, +) -> str: + names = session_names(inventory, recovery, data) + groups: dict[tuple[str, str], tuple[str, int, list[str]]] = {} + failures = 0 + for path in sources(journal, recovery, archive): + try: + matched = _matching_lines(path, query.casefold()) + session_id = _source_id(path, journal, recovery, archive) + store = _source_store(path, journal, recovery, archive) + key = _recording_key(path, journal, recovery, archive) + except Exception: + failures += 1 + continue + if not matched.count: + continue + label = names.get((store, session_id), "") or _recording_label(path, matched) + previous_label, previous_count, previous_lines = groups.get( + key, (label, 0, []) + ) + remaining = MAX_SHOWN_LINES - len(previous_lines) + groups[key] = ( + previous_label, + previous_count + matched.count, + previous_lines + + [_safe_line(line) for line in matched.shown[:remaining]], + ) + total = sum(count for _label, count, _lines in groups.values()) + if not total: + out = ["Matches: none."] + if failures: + noun = "recording" if failures == 1 else "recordings" + out.append(f"Notice: {failures} {noun} could not be searched.") + return "\n".join(out) + "\n" + out = [f"Matches: {total}"] + label_totals: dict[str, int] = {} + for label, _count, _lines in groups.values(): + label_totals[label] = label_totals.get(label, 0) + 1 + label_ordinals: dict[str, int] = {} + for label, count, lines in groups.values(): + if label_totals[label] > 1: + ordinal = label_ordinals.get(label, 0) + 1 + label_ordinals[label] = ordinal + label = f"{label} (recording {ordinal} of {label_totals[label]})" + noun = "match" if count == 1 else "matches" + out.extend(("", f"{label} · {count} {noun}")) + out.extend(lines) + if count > MAX_SHOWN_LINES: + out.append(f" … ({count - MAX_SHOWN_LINES} more matches)") + if failures: + noun = "recording" if failures == 1 else "recordings" + out.extend(("", f"Notice: {failures} {noun} could not be searched.")) + return "\n".join(out) + "\n" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="history_search.py") + parser.add_argument("query") + parser.add_argument("--journal", type=Path, required=True) + parser.add_argument("--recovery", type=Path, required=True) + parser.add_argument("--archive", type=Path, required=True) + parser.add_argument("--inventory", type=Path) + parser.add_argument("--data", type=Path, required=True) + args = parser.parse_args(argv) + print( + render_matches( + args.query, + journal=args.journal, + recovery=args.recovery, + archive=args.archive, + inventory=args.inventory, + data=args.data, + ), + end="", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lib/sessionkit_inventory/idle_state.py b/lib/sessionkit_inventory/idle_state.py new file mode 100644 index 0000000..b83e1e1 --- /dev/null +++ b/lib/sessionkit_inventory/idle_state.py @@ -0,0 +1,179 @@ +"""Evidence-based ageing of sessions already waiting on the operator. + +The provider status does not decide idleness. A conversation becomes aged +only after its own transcript's path, size and nanosecond mtime remain unchanged +for the configured window. The prior published inventory is the observation +record, so no second scanner or private clock store is needed. +""" + +from __future__ import annotations + +import math +from pathlib import Path +import stat +import time +from typing import Any, Callable, Mapping, cast + +from .common import stall_threshold_seconds, valid_uuid +from . import labels + + +IDLE_MINUTES_FILE = "session-idle-minutes" +DEFAULT_IDLE_MINUTES = 30.0 +_EVIDENCE_FIELD = "_transcript_idle_evidence" +IDLE_FIELD = "transcript_idle" + + +def idle_minutes(state_dir: Path) -> float | None: + """Configured minutes, or None when idling is explicitly/refusably off. + + A missing file selects the default. Once the pathname exists, every + failure is a refusal: symlink, irregular file, read error, empty content or + malformed number. Falling back to thirty after an unreadable attempted + override could shorten the operator's intended window, so it is forbidden. + Zero follows the established sweep-file convention and disables idling. + """ + path = state_dir / IDLE_MINUTES_FILE + try: + info = path.lstat() + except FileNotFoundError: + return DEFAULT_IDLE_MINUTES + except OSError: + return None + if not stat.S_ISREG(info.st_mode): + return None + try: + payload = path.read_bytes() + except OSError: + return None + if len(payload) > 64: + return None + raw = payload.decode("utf-8", "replace").strip() + if not raw: + return None + try: + value = float(raw) + except ValueError: + return None + if not math.isfinite(value) or value <= 0 or not math.isfinite(value * 60_000): + return None + return value + + +def _rows_by_conversation(inventory: Mapping[str, Any] | None) -> dict[str, Mapping[str, Any]]: + rows: dict[str, Mapping[str, Any]] = {} + if not isinstance(inventory, Mapping): + return rows + sessions = inventory.get("sessions") + if not isinstance(sessions, list): + return rows + for row in sessions: + if not isinstance(row, Mapping): + continue + provider = row.get("provider") + identity = row.get("identity") + uuid = identity.get("uuid") if isinstance(identity, Mapping) else None + exact = valid_uuid(uuid) + if provider in {"claude", "codex"} and exact: + rows[f"{provider}:{exact}"] = row + return rows + + +def _signature(value: object) -> dict[str, object] | None: + if not isinstance(value, Mapping): + return None + path = value.get("path") + size = value.get("size") + mtime_ns = value.get("mtime_ns") + if ( + not isinstance(path, str) + or not path.startswith("/") + or isinstance(size, bool) + or not isinstance(size, int) + or size < 0 + or isinstance(mtime_ns, bool) + or not isinstance(mtime_ns, int) + or mtime_ns < 0 + ): + return None + return {"path": path, "size": size, "mtime_ns": mtime_ns} + + +def apply_idle_evidence( + inventory: dict[str, Any], + previous: Mapping[str, Any] | None, + *, + state_dir: Path, + transcript_snapshot: Callable[[str, str], Mapping[str, object] | None], + now_ms: int | None = None, +) -> dict[str, Any]: + """Attach one conservative transcript-idle verdict to each live row.""" + minutes = idle_minutes(state_dir) + sessions = inventory.get("sessions") + if not isinstance(sessions, list): + return inventory + moment = ( + now_ms + if isinstance(now_ms, int) and not isinstance(now_ms, bool) + else int(time.time() * 1000) + ) + prior_rows = _rows_by_conversation(previous) + window_ms = minutes * 60_000 if minutes is not None else None + for row in sessions: + if not isinstance(row, dict): + continue + row[IDLE_FIELD] = False + row.pop(_EVIDENCE_FIELD, None) + if window_ms is None: + continue + # Resolve no transcript unless this row can actually age into idle. + # In particular, Codex path discovery may inspect a large rollout + # estate; running that for working/question/pending rows would add a + # scanner-shaped cost while producing evidence the state rule ignores. + if ( + labels.session_state(row, stall_seconds=stall_threshold_seconds()) + != labels.WAITING_ON_YOU + ): + continue + provider = row.get("provider") + identity = row.get("identity") + uuid = identity.get("uuid") if isinstance(identity, Mapping) else None + exact = valid_uuid(uuid) + if provider not in {"claude", "codex"} or not exact: + continue + current = _signature(transcript_snapshot(str(provider), exact)) + if current is None: + continue + key = f"{provider}:{exact}" + prior = prior_rows.get(key, {}) + prior_evidence = prior.get(_EVIDENCE_FIELD) + last_moved = moment + if isinstance(prior_evidence, Mapping): + prior_signature = _signature(prior_evidence) + prior_last_moved = prior_evidence.get("last_moved_at_unix_ms") + prior_observed = prior_evidence.get("observed_at_unix_ms") + prior_window = prior_evidence.get("window_seconds") + comparable = ( + prior_signature == current + and isinstance(prior_last_moved, int) + and not isinstance(prior_last_moved, bool) + and isinstance(prior_observed, int) + and not isinstance(prior_observed, bool) + and isinstance(prior_window, (int, float)) + and not isinstance(prior_window, bool) + and prior_window > 0 + and window_ms / 1000 >= float(prior_window) + and prior_last_moved <= prior_observed <= moment + and moment - prior_observed <= window_ms + ) + if comparable: + last_moved = cast(int, prior_last_moved) + evidence = { + **current, + "last_moved_at_unix_ms": last_moved, + "observed_at_unix_ms": moment, + "window_seconds": window_ms / 1000, + } + row[_EVIDENCE_FIELD] = evidence + row[IDLE_FIELD] = moment - last_moved >= window_ms + return inventory diff --git a/lib/sessionkit_inventory/journal_render.py b/lib/sessionkit_inventory/journal_render.py new file mode 100644 index 0000000..628913b --- /dev/null +++ b/lib/sessionkit_inventory/journal_render.py @@ -0,0 +1,818 @@ +#!/usr/bin/env python3 +"""Render raw terminal journals into settled plain text. + +The kit's journals hold the RAW bytes a Claude Code TUI wrote to its pty +(`script -qfa`-style capture). A TUI repaints lines in place: it moves the +cursor up, jumps to a column, overwrites a few characters, and moves on. +Played back as plain text with the escapes merely stripped, two drafts of the +same screen line braid into mush ("WidgetiSampledTwohwasiflaggedrbyua"). + +The fix is to replay the recording through a real simulated screen, obeying +every cursor and erase instruction, and to emit a line only once it has +SETTLED -- that is, once it has scrolled off the top of the screen and can no +longer be repainted. Rows still on the live screen are never settled; they +persist in the checkpoint and are re-rendered on the next pass. + +Everything here is stdlib only. The renderer is incremental: feed it any +chunking of the same byte stream and the settled output is byte identical. + +CLI +--- + python3 journal_render.py render --journal DIR_OR_FILE \\ + --out sidecar.txt --state state.json [--max-bytes N] + + python3 journal_render.py flush ...same flags... + +`render` appends newly settled lines to the sidecar and commits progress to +the state file. `flush` does the same and then appends the current live screen +as a trailing block behind a marker line, WITHOUT settling it -- read-time use, +so history includes the newest screen. The live block is not committed, so the +next run truncates it away before appending. +""" + +from __future__ import annotations + +import argparse +import datetime as _datetime +import hashlib +import json +import os +from pathlib import Path +import re +import sys +import time + +# Journals do not record window resizes, so the replay picks a fixed geometry. +# A generous width minimises wrap artifacts on the wide TUI frames Claude Code +# draws; anything narrower than the recording window would fold real lines. +DEFAULT_WIDTH = 220 +DEFAULT_HEIGHT = 60 + +CHECKPOINT_VERSION = 1 +STATE_VERSION = 1 +DEFAULT_MAX_BYTES = 32 * 1024 * 1024 +READ_CHUNK = 1 << 20 + +LIVE_MARKER_PREFIX = "── live screen at " +LIVE_MARKER_SUFFIX = " ──" + +# An escape sequence held across a feed() boundary. 64 KiB is far past any real +# OSC string; beyond it the bytes are treated as garbage and swallowed rather +# than leaked into the text. +MAX_PENDING = 1 << 16 + +# Ordered alternation: CSI and the string-terminated families must win over the +# single-character branch, whose class therefore excludes their introducers +# ('[' ']' 'P' 'X' '^' '_'). +_ESCAPE_RE = re.compile( + rb"\x1b(?:" + rb"\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]" + rb"|\][^\x07\x1b]*(?:\x07|\x1b\\)" + rb"|[PX\x5e_][^\x1b\x07]*(?:\x07|\x1b\\)" + rb"|[\x20-\x2f]+[\x30-\x7e]" + rb"|[\x30-\x4f\x51-\x57\x59\x5a\x5c\x60-\x7e]" + rb")" +) + +_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]") + +_STRING_INTRODUCERS = frozenset(b"]PX^_") +_ALT_SCREEN_MODES = frozenset((47, 1047, 1049)) + + +def _clamp(value: int, low: int, high: int) -> int: + if value < low: + return low + if value > high: + return high + return value + + +def _incomplete_escape(data: bytes, start: int) -> bool: + """True when data[start:] could still grow into a valid escape sequence.""" + rest = data[start:] + if len(rest) == 1: + return True + kind = rest[1] + if kind == 0x5B: # CSI: parameters and intermediates, final not yet seen + return all(0x20 <= byte <= 0x3F for byte in rest[2:]) + if kind in _STRING_INTRODUCERS: # OSC/DCS/SOS/PM/APC: waiting for ST + return True + if 0x20 <= kind <= 0x2F: # nF escape: waiting for its final byte + return all(0x20 <= byte <= 0x2F for byte in rest[2:]) + return False + + +def _utf8_tail(chunk: bytes) -> int: + """Length of the trailing bytes that form an incomplete UTF-8 sequence.""" + size = len(chunk) + limit = 4 if size >= 4 else size + for back in range(1, limit + 1): + byte = chunk[size - back] + if byte < 0x80: + return 0 + if byte >= 0xC0: + if byte < 0xE0: + need = 2 + elif byte < 0xF0: + need = 3 + else: + need = 4 + return back if back < need else 0 + return 0 + + +class JournalRenderer: + """An incremental VT replay that emits only settled screen lines.""" + + def __init__(self, width: int = DEFAULT_WIDTH, height: int = DEFAULT_HEIGHT): + if width < 2 or height < 2: + raise ValueError("screen must be at least 2x2") + self.width = width + self.height = height + self._blank = " " * width + self._grid = [self._blank] * height + self._row = 0 + self._col = 0 + self._top = 0 + self._bottom = height - 1 + self._saved: tuple[int, int] | None = None + self._alt = False + self._alt_backup: tuple[list[str], int, int] | None = None + self._pending = b"" + self._settled: list[str] = [] + self.settled_lines = 0 + self.bytes_fed = 0 + + # ---------------------------------------------------------------- state + + @classmethod + def resume(cls, checkpoint: dict | None) -> "JournalRenderer": + """Rebuild a renderer from checkpoint() output (None starts fresh).""" + if not checkpoint: + return cls() + version = checkpoint.get("version") + if version != CHECKPOINT_VERSION: + raise ValueError(f"unsupported checkpoint version: {version!r}") + width = int(checkpoint.get("width", DEFAULT_WIDTH)) + height = int(checkpoint.get("height", DEFAULT_HEIGHT)) + self = cls(width=width, height=height) + self._grid = self._restore_rows(checkpoint.get("rows"), height, width) + cursor = checkpoint.get("cursor") or {} + self._row = _clamp(int(cursor.get("row", 0)), 0, height - 1) + self._col = _clamp(int(cursor.get("col", 0)), 0, width) + saved = checkpoint.get("saved_cursor") + if saved: + self._saved = ( + _clamp(int(saved[0]), 0, height - 1), + _clamp(int(saved[1]), 0, width), + ) + region = checkpoint.get("scroll_region") or [0, height - 1] + self._top = _clamp(int(region[0]), 0, height - 1) + self._bottom = _clamp(int(region[1]), self._top, height - 1) + self._alt = bool(checkpoint.get("alt_screen")) + backup = checkpoint.get("alt_backup") + if backup: + self._alt_backup = ( + self._restore_rows(backup.get("rows"), height, width), + _clamp(int(backup.get("row", 0)), 0, height - 1), + _clamp(int(backup.get("col", 0)), 0, width), + ) + pending = checkpoint.get("pending") or "" + self._pending = bytes.fromhex(pending) if pending else b"" + self.settled_lines = int(checkpoint.get("settled_lines", 0)) + self.bytes_fed = int(checkpoint.get("bytes_fed", 0)) + return self + + @staticmethod + def _restore_rows(rows, height: int, width: int) -> list[str]: + blank = " " * width + grid = [blank] * height + for index, text in enumerate(rows or []): + if index >= height: + break + line = str(text)[:width] + grid[index] = line + " " * (width - len(line)) + return grid + + def checkpoint(self) -> dict: + """A JSON-safe snapshot; feed()ing on top of resume() is seamless.""" + state = { + "version": CHECKPOINT_VERSION, + "width": self.width, + "height": self.height, + # Trailing blanks are invisible to every reader (settled lines and + # live rows are both rstripped), so they are dropped here. + "rows": [row.rstrip() for row in self._grid], + "cursor": {"row": self._row, "col": self._col}, + "saved_cursor": list(self._saved) if self._saved else None, + "scroll_region": [self._top, self._bottom], + "alt_screen": self._alt, + "alt_backup": None, + "pending": self._pending.hex(), + "settled_lines": self.settled_lines, + "bytes_fed": self.bytes_fed, + } + if self._alt_backup is not None: + rows, row, col = self._alt_backup + state["alt_backup"] = { + "rows": [line.rstrip() for line in rows], + "row": row, + "col": col, + } + return state + + # --------------------------------------------------------------- output + + def take_settled(self) -> list[str]: + """Drain the lines that have scrolled off the screen since last call.""" + drained = self._settled + self._settled = [] + return drained + + def live_rows(self) -> list[str]: + """Non-empty rows still on the live screen (never settled).""" + rows = [row.rstrip() for row in self._grid] + while rows and not rows[-1]: + rows.pop() + return rows + + # ---------------------------------------------------------------- input + + def feed(self, data: bytes) -> None: + if not data: + return + self.bytes_fed += len(data) + if self._pending: + data = self._pending + data + self._pending = b"" + index = 0 + size = len(data) + find = data.find + match_escape = _ESCAPE_RE.match + while index < size: + hit = find(0x1B, index) + if hit == -1: + run = data[index:] + tail = _utf8_tail(run) + if tail: + self._pending = run[len(run) - tail :] + run = run[: len(run) - tail] + if run: + self._put(run.decode("utf-8", "replace")) + return + if hit > index: + self._put(data[index:hit].decode("utf-8", "replace")) + index = hit + found = match_escape(data, index) + if found is None: + if _incomplete_escape(data, index): + rest = data[index:] + if len(rest) <= MAX_PENDING: + self._pending = rest + return + index += 1 # stray ESC: swallow the byte, never leak it + continue + self._dispatch(data[index : found.end()]) + index = found.end() + + # ------------------------------------------------------------ plain text + + def _put(self, text: str) -> None: + position = 0 + for found in _CONTROL_RE.finditer(text): + start = found.start() + if start > position: + self._write(text[position:start]) + self._control(found.group()) + position = found.end() + if position < len(text): + self._write(text[position:]) + + def _write(self, chunk: str) -> None: + width = self.width + grid = self._grid + while chunk: + if self._col >= width: + self._col = 0 + self._index() + col = self._col + part = chunk[: width - col] + chunk = chunk[len(part) :] + row = self._row + line = grid[row] + grid[row] = line[:col] + part + line[col + len(part) :] + self._col = col + len(part) + + def _control(self, char: str) -> None: + if char == "\n": + self._index() + elif char == "\r": + self._col = 0 + elif char == "\b": + if self._col > 0: + self._col -= 1 + elif char == "\t": + self._col = min(self.width - 1, (self._col // 8 + 1) * 8) + elif char in ("\x0b", "\x0c"): + self._index() + # BEL, NUL, SO/SI and friends have no effect on plain-text output. + + # --------------------------------------------------------------- screen + + def _index(self) -> None: + if self._row == self._bottom: + self._scroll_up(1) + elif self._row < self.height - 1: + self._row += 1 + + def _reverse_index(self) -> None: + if self._row == self._top: + self._scroll_down(1) + elif self._row > 0: + self._row -= 1 + + def _scroll_up(self, count: int) -> None: + grid = self._grid + top = self._top + bottom = self._bottom + settles = top == 0 and not self._alt + for _ in range(min(count, bottom - top + 1)): + line = grid[top] + if settles: + self._settled.append(line.rstrip()) + self.settled_lines += 1 + del grid[top] + grid.insert(bottom, self._blank) + + def _scroll_down(self, count: int) -> None: + grid = self._grid + top = self._top + bottom = self._bottom + for _ in range(min(count, bottom - top + 1)): + del grid[bottom] + grid.insert(top, self._blank) + + def _erase_row(self, row: int, start: int, end: int) -> None: + line = self._grid[row] + start = _clamp(start, 0, self.width) + end = _clamp(end, start, self.width) + if end > start: + self._grid[row] = line[:start] + " " * (end - start) + line[end:] + + # ------------------------------------------------------------- dispatch + + def _dispatch(self, sequence: bytes) -> None: + kind = sequence[1] + if kind == 0x5B: # CSI + final = sequence[-1] + if final == 0x6D: # SGR: colour only, irrelevant to plain text + return + self._csi(sequence, final) + return + if kind in _STRING_INTRODUCERS: # OSC/DCS/SOS/PM/APC: swallowed whole + return + if kind == 0x37: # ESC 7 + self._saved = (self._row, self._col) + elif kind == 0x38: # ESC 8 + self._restore_cursor() + elif kind == 0x44: # ESC D index + self._index() + elif kind == 0x4D: # ESC M reverse index + self._reverse_index() + elif kind == 0x45: # ESC E next line + self._col = 0 + self._index() + elif kind == 0x63: # ESC c hard reset + self._reset() + # Charset designations (ESC ( B), keypad modes and the rest carry no + # text, so they are swallowed. + + def _restore_cursor(self) -> None: + if self._saved is not None: + self._row, self._col = self._saved + self._row = _clamp(self._row, 0, self.height - 1) + self._col = _clamp(self._col, 0, self.width) + + def _private_mode(self, body: bytes, enable: bool) -> None: + for value in self._params(body): + if value in _ALT_SCREEN_MODES: + if enable: + self._enter_alt() + else: + self._exit_alt() + # Every other private mode (cursor visibility, bracketed paste, + # synchronised output) leaves the text untouched. + + def _enter_alt(self) -> None: + """Alt-screen content is a transient overlay: it never settles.""" + if self._alt: + return + self._alt_backup = (list(self._grid), self._row, self._col) + self._alt = True + self._grid = [self._blank] * self.height + self._row = 0 + self._col = 0 + + def _exit_alt(self) -> None: + if not self._alt: + return + self._alt = False + if self._alt_backup is not None: + grid, row, col = self._alt_backup + self._grid = list(grid) + self._row = _clamp(row, 0, self.height - 1) + self._col = _clamp(col, 0, self.width) + self._alt_backup = None + + def _reset(self) -> None: + self._grid = [self._blank] * self.height + self._row = 0 + self._col = 0 + self._top = 0 + self._bottom = self.height - 1 + self._saved = None + + def _csi(self, sequence: bytes, final: int) -> None: + body = sequence[2:-1] + while body and 0x20 <= body[-1] <= 0x2F: # drop intermediates + body = body[:-1] + if body[:1] in (b"?", b"<", b"=", b">"): + if body[:1] == b"?" and final in (0x68, 0x6C): + self._private_mode(body[1:], final == 0x68) + return + params = self._params(body) + first = params[0] if params else 0 + + if final == 0x43: # C cursor forward + self._col = min(self.width - 1, self._col + max(1, first)) + elif final == 0x44: # D cursor back + self._col = max(0, self._col - max(1, first)) + elif final == 0x41: # A cursor up + limit = self._top if self._row >= self._top else 0 + self._row = max(limit, self._row - max(1, first)) + elif final == 0x42: # B cursor down + limit = self._bottom if self._row <= self._bottom else self.height - 1 + self._row = min(limit, self._row + max(1, first)) + elif final in (0x47, 0x60): # G / ` cursor to column + self._col = _clamp(max(1, first) - 1, 0, self.width - 1) + elif final == 0x64: # d cursor to row + self._row = _clamp(max(1, first) - 1, 0, self.height - 1) + elif final in (0x48, 0x66): # H / f cursor position + second = params[1] if len(params) > 1 else 0 + self._row = _clamp(max(1, first) - 1, 0, self.height - 1) + self._col = _clamp(max(1, second) - 1, 0, self.width - 1) + elif final == 0x4B: # K erase in line + if first == 0: + self._erase_row(self._row, self._col, self.width) + elif first == 1: + self._erase_row(self._row, 0, self._col + 1) + else: + self._erase_row(self._row, 0, self.width) + elif final == 0x4A: # J erase in display + self._erase_display(first) + elif final == 0x45: # E cursor next line + self._col = 0 + for _ in range(max(1, first)): + self._index() + elif final == 0x46: # F cursor previous line + self._col = 0 + for _ in range(max(1, first)): + self._reverse_index() + elif final == 0x4C: # L insert lines + self._insert_lines(max(1, first)) + elif final == 0x4D: # M delete lines + self._delete_lines(max(1, first)) + elif final == 0x40: # @ insert characters + self._insert_chars(max(1, first)) + elif final == 0x50: # P delete characters + self._delete_chars(max(1, first)) + elif final == 0x58: # X erase characters + self._erase_row(self._row, self._col, self._col + max(1, first)) + elif final == 0x53: # S scroll up + self._scroll_up(max(1, first)) + elif final == 0x54: # T scroll down + self._scroll_down(max(1, first)) + elif final == 0x72: # r set scrolling region + second = params[1] if len(params) > 1 else 0 + top = _clamp(max(1, first) - 1, 0, self.height - 1) + bottom = _clamp((second or self.height) - 1, top, self.height - 1) + self._top = top + self._bottom = bottom + self._row = top + self._col = 0 + elif final == 0x73: # s save cursor + self._saved = (self._row, self._col) + elif final == 0x75: # u restore cursor + self._restore_cursor() + # Everything else (device reports, SGR handled above, tab stops) has no + # effect on the plain text this renderer produces. + + @staticmethod + def _params(body: bytes) -> list[int]: + if not body: + return [] + values = [] + for piece in body.split(b";"): + piece = piece.split(b":")[0] + try: + values.append(int(piece) if piece else 0) + except ValueError: + values.append(0) + return values + + def _erase_display(self, mode: int) -> None: + if mode == 0: + self._erase_row(self._row, self._col, self.width) + for row in range(self._row + 1, self.height): + self._grid[row] = self._blank + elif mode == 1: + for row in range(0, self._row): + self._grid[row] = self._blank + self._erase_row(self._row, 0, self._col + 1) + else: + # A full clear settles nothing: the screen is discarded, not + # scrolled into history. + self._grid = [self._blank] * self.height + + def _insert_lines(self, count: int) -> None: + if not (self._top <= self._row <= self._bottom): + return + grid = self._grid + for _ in range(min(count, self._bottom - self._row + 1)): + del grid[self._bottom] + grid.insert(self._row, self._blank) + + def _delete_lines(self, count: int) -> None: + if not (self._top <= self._row <= self._bottom): + return + grid = self._grid + for _ in range(min(count, self._bottom - self._row + 1)): + del grid[self._row] + grid.insert(self._bottom, self._blank) + + def _insert_chars(self, count: int) -> None: + line = self._grid[self._row] + col = self._col + count = min(count, self.width - col) + self._grid[self._row] = ( + line[:col] + " " * count + line[col : self.width - count] + ) + + def _delete_chars(self, count: int) -> None: + line = self._grid[self._row] + col = self._col + count = min(count, self.width - col) + self._grid[self._row] = line[:col] + line[col + count :] + " " * count + + +# ----------------------------------------------------------------- journals + + +def journal_segments(journal: Path) -> list[Path]: + """Ordered raw segments: a directory of segment-*.raw, or a single file.""" + journal = Path(journal) + if journal.is_dir(): + return sorted(journal.glob("segment-*.raw")) + if journal.is_file(): + return [journal] + return [] + + +def _load_state(path: Path) -> dict | None: + try: + with open(path, encoding="utf-8") as handle: + state = json.load(handle) + except (OSError, ValueError): + return None + if not isinstance(state, dict) or state.get("version") != STATE_VERSION: + return None + return state + + +def _save_state(path: Path, state: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(path.name + ".tmp") + with open(temporary, "w", encoding="utf-8") as handle: + json.dump(state, handle) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + + +def _head_digest(segments: list[Path]) -> str: + """Fingerprint the start of the journal so a same-size rewrite is caught.""" + if not segments: + return "" + try: + with open(segments[0], "rb") as handle: + head = handle.read(4096) + except OSError: + return "" + return hashlib.sha256(head).hexdigest() + + +def _plan(segments: list[Path], state: dict | None) -> tuple[dict, bool]: + """Match stored progress against what is on disk now. + + Returns the per-segment consumed map and whether the journal has to be + re-rendered from the start (a segment shrank, the head bytes changed, or + the sequence diverged -- rotation, truncation, or a different journal + reusing the state file). + """ + consumed = {} + restart = False + stored_head = (state or {}).get("head") + if stored_head and stored_head != _head_digest(segments): + restart = True + stored = (state or {}).get("segments") or [] + stored_map = {} + for entry in stored: + if isinstance(entry, dict) and "name" in entry: + stored_map[str(entry["name"])] = int(entry.get("consumed", 0)) + names = [segment.name for segment in segments] + for index, entry in enumerate(stored): + if not isinstance(entry, dict): + restart = True + break + if index >= len(names) or names[index] != str(entry.get("name")): + restart = True + break + for segment in segments: + done = stored_map.get(segment.name, 0) + try: + size = segment.stat().st_size + except OSError: + size = 0 + if done > size: + restart = True + consumed[segment.name] = 0 if restart else min(done, size) + if restart: + consumed = {segment.name: 0 for segment in segments} + return consumed, restart + + +def render_journal( + journal: Path, + out_path: Path, + state_path: Path, + *, + max_bytes: int = DEFAULT_MAX_BYTES, + width: int = DEFAULT_WIDTH, + height: int = DEFAULT_HEIGHT, + flush_live: bool = False, + now: str | None = None, +) -> dict: + """Render new journal bytes into the sidecar and commit progress.""" + journal = Path(journal) + out_path = Path(out_path) + state_path = Path(state_path) + segments = journal_segments(journal) + state = _load_state(state_path) + if state and state.get("journal") != str(journal): + state = None + consumed, restart = _plan(segments, state) + + if restart or state is None: + renderer = JournalRenderer(width=width, height=height) + committed = 0 + else: + checkpoint = state.get("renderer") + try: + renderer = JournalRenderer.resume(checkpoint) + except (ValueError, TypeError, KeyError): + renderer = JournalRenderer(width=width, height=height) + consumed = {segment.name: 0 for segment in segments} + restart = True + committed = 0 + else: + committed = int(state.get("sidecar_bytes", 0)) + if restart: + committed = 0 + + out_path.parent.mkdir(parents=True, exist_ok=True) + # Truncating to the committed length makes the append idempotent: it drops + # a half-written batch from a crashed run and any live block a previous + # flush appended past the commit point. + with open(out_path, "a+b") as handle: + if handle.tell() > committed: + handle.truncate(committed) + + started = time.monotonic() + read_bytes = 0 + budget = max(0, int(max_bytes)) + lines_written = 0 + + with open(out_path, "ab") as sink: + for segment in segments: + if read_bytes >= budget: + break + offset = consumed.get(segment.name, 0) + try: + size = segment.stat().st_size + except OSError: + continue + if offset >= size: + continue + with open(segment, "rb") as source: + source.seek(offset) + while read_bytes < budget: + want = min(READ_CHUNK, budget - read_bytes) + block = source.read(want) + if not block: + break + read_bytes += len(block) + offset += len(block) + renderer.feed(block) + settled = renderer.take_settled() + if settled: + lines_written += len(settled) + sink.write(("\n".join(settled) + "\n").encode("utf-8")) + consumed[segment.name] = offset + settled = renderer.take_settled() + if settled: + lines_written += len(settled) + sink.write(("\n".join(settled) + "\n").encode("utf-8")) + sink.flush() + os.fsync(sink.fileno()) + # fstat rather than tell(): append-mode position is only guaranteed to + # sit at end-of-file after a write, and a no-op run writes nothing. + committed = os.fstat(sink.fileno()).st_size + + elapsed = time.monotonic() - started + total_offset = sum(consumed.get(segment.name, 0) for segment in segments) + new_state = { + "version": STATE_VERSION, + "journal": str(journal), + "segments": [ + {"name": segment.name, "consumed": consumed.get(segment.name, 0)} + for segment in segments + ], + "head": _head_digest(segments), + "byte_offset": total_offset, + "sidecar_bytes": committed, + "renderer": renderer.checkpoint(), + "updated_at": now or _datetime.datetime.now().astimezone().isoformat(), + } + _save_state(state_path, new_state) + + live_lines = 0 + if flush_live: + rows = renderer.live_rows() + stamp = now or _datetime.datetime.now().astimezone().isoformat( + timespec="seconds" + ) + live_block = [LIVE_MARKER_PREFIX + stamp + LIVE_MARKER_SUFFIX] + rows + live_lines = len(rows) + with open(out_path, "ab") as sink: + sink.write(("\n".join(live_block) + "\n").encode("utf-8")) + sink.flush() + os.fsync(sink.fileno()) + + return { + "journal": str(journal), + "segments": len(segments), + "restarted": restart, + "bytes_read": read_bytes, + "byte_offset": total_offset, + "settled_lines": lines_written, + "live_lines": live_lines, + "sidecar_bytes": committed, + "seconds": round(elapsed, 4), + "bytes_per_second": int(read_bytes / elapsed) if elapsed > 0 else 0, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="journal_render.py", + description="Replay a raw terminal journal into settled plain text.", + ) + parser.add_argument("mode", choices=("render", "flush")) + parser.add_argument("--journal", required=True, help="segment dir or .raw file") + parser.add_argument("--out", required=True, help="sidecar text file to append") + parser.add_argument("--state", required=True, help="progress checkpoint JSON") + parser.add_argument("--max-bytes", type=int, default=DEFAULT_MAX_BYTES) + parser.add_argument("--width", type=int, default=DEFAULT_WIDTH) + parser.add_argument("--height", type=int, default=DEFAULT_HEIGHT) + args = parser.parse_args(argv) + + journal = Path(args.journal) + if not journal_segments(journal): + print(f"no journal segments under {journal}", file=sys.stderr) + return 2 + result = render_journal( + journal, + Path(args.out), + Path(args.state), + max_bytes=args.max_bytes, + width=args.width, + height=args.height, + flush_live=args.mode == "flush", + ) + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lib/sessionkit_inventory/labels.py b/lib/sessionkit_inventory/labels.py new file mode 100644 index 0000000..9b9dd1a --- /dev/null +++ b/lib/sessionkit_inventory/labels.py @@ -0,0 +1,625 @@ +"""Every word the kit shows a person, and the collector words they translate. + +`docs/voice.md` is the contract; this module is that contract as code. A screen +that needs a word imports it from here, so `sp list`, `sp detail`, the picker +and the machine snapshot can never drift into three spellings of one idea +again. Nothing in this file reads a file, a clock, or the environment: every +name is a constant and every function is pure, which is why the whole module +can be asserted directly in tests instead of through a rendered screen. + +Two vocabularies live here and they are not the same thing: + +* **Screen words**, what a person reads: `question`, `needs you`, `working`, `idle`, + `Ready`, `Open elsewhere`. Changing one changes what the kit says. +* **Collector words**, what the collectors write into an inventory row: + `needs your reply`, `running`, `attached`. They are evidence, not copy. They + are named here only so the renderers compare against one spelling, and they + are never renamed at the source. + +The translation between the two happens at the render boundary, in +`state_word()` and `session_state()`, and nowhere else. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Mapping + +from .common import STALL_DEFAULT_SECONDS as STALL_DEFAULT_SECONDS +from .common import clean_text + + +# --------------------------------------------------------------- placeholders + +# A value the kit has not pulled yet. One rule for every column (operator +# ruling, 2026-08-16): until a section has a valid read, it says `pending`, +# never `unknown`, never a dash, never an empty cell. +MISSING = "pending" + +# Punctuation the screens share: what a truncated string ends with, and what +# separates two facts on one line. +ELLIPSIS = "…" +SEPARATOR = " | " + +# A session with no number of its own: the column still holds its place, and +# the row says plainly that there is nothing to type. +UNNUMBERED = "-" + +# How long a session may print nothing before the kit stops describing it as +# working. The value is defined beside the shared environment resolver in +# ``common`` and re-exported here for the state vocabulary's callers. + + +# ---------------------------------------------------------- collector words + +AVAILABILITY_READY = "ready" +AVAILABILITY_ATTACHED = "attached" +# Display order for the two availability groups, and the only two values a +# session row may carry. +AVAILABILITY_ORDER = (AVAILABILITY_READY, AVAILABILITY_ATTACHED) + +PROVIDER_CLAUDE = "claude" +PROVIDER_CODEX = "codex" +PROVIDER_SHELL = "shell" +PROVIDER_UNKNOWN = "unknown" +PROVIDER_ORDER = (PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_SHELL, PROVIDER_UNKNOWN) + +STATUS_NEEDS_YOUR_REPLY = "needs your reply" +STATUS_REPLY_OPTIONAL = "reply optional" +STATUS_RUNNING = "running" +STATUS_STATE_UNAVAILABLE = "state unavailable" +STATUS_PROVIDER_EXITED = "provider exited" +# Both collectors write this one, and it is the same fact from either: a Codex +# rollout whose last lifecycle event is `task_complete`, and a `claude agents +# --json` row parked at its prompt, both say `idle`. Neither is a state a +# person can act on, and both mean the session is waiting for them. +STATUS_IDLE = "idle" +# What a live Claude poll actually calls a session mid-turn is `busy`, which +# `attention.poll_attention` already rewrites; `working` is the documented +# spelling and is translated here so a vendor that starts sending it cannot +# leak a raw collector word onto the screen. +STATUS_WORKING = "working" +# The collector's own "I could not resolve this session" word, written at the +# unresolved-provider branch and as the base row's default. +STATUS_UNKNOWN = "unknown" + +TITLE_STATE_READY = "ready" +TITLE_STATE_PENDING = "pending" + +CONFIDENCE_EXACT = "exact" + +# Fleet stall degrees. `docs/voice.md`: unsurfaced, unanswered and silent are +# degrees of **needs you** and render as that one state. `orphan` is the fourth +# reason the fleet flagger can write and is deliberately absent: an orphaned +# session is a dead session, not a person's turn. +STALL_REASONS_NEEDS_YOU = ("unsurfaced", "unanswered", "silent") + + +# ------------------------------------------------------------ session states + +# `idle` used to be a third word and was retired (operator ruling, +# 2026-08-15). It never meant anything a person could act on, and it made the +# two providers describe +# one situation in two ways: a Claude session parked at its prompt raises the +# vendor's own `idle_prompt` notification and read as `needs you`, while a +# Codex session parked at exactly the same prompt writes `task_complete` to its +# rollout, has no notification hook at all, and read as `idle`. Same screen, +# same situation, two words. Both first become `needs you` at this boundary. +# +# The new `idle` (operator ruling, 2026-08-16) is a different fact. It is never +# derived from either vendor's notification vocabulary. It means a session that +# would read `needs you` whose own transcript path, size and nanosecond mtime +# have remained unchanged for the operator's full ageing window. +WAITING_ON_YOU = "needs you" +QUESTION = "question" +WORKING = "working" +IDLE = "idle" +SETUP_INCOMPLETE = "pending" +STATUS_UNAVAILABLE = "pending" + +# The historical spelling. Kept as one name for one string so nothing that +# still imports it silently starts printing a different word than the picker. +NEEDS_YOU = WAITING_ON_YOU + +# Both can't-read cases say `pending` (operator rule, 2026-08-16: every +# section without a valid pulled value says pending). They are not a third state of the +# working/waiting pair: they are the kit saying it cannot read this session. +# A row it cannot read must not be described as either. +SESSION_STATES = ( + QUESTION, + WAITING_ON_YOU, + WORKING, + IDLE, + SETUP_INCOMPLETE, + STATUS_UNAVAILABLE, +) + +# The full translation, used for the machine snapshot's `state` field and by +# every screen that shows one state word per session. +# +# The provider's own exit is a person's turn, not a state of its own: the +# provider is gone and only they can restore or close the shell. It used to +# print `provider exited` straight onto the screen. +STATE_WORDS = { + STATUS_NEEDS_YOUR_REPLY: WAITING_ON_YOU, + STATUS_REPLY_OPTIONAL: WORKING, + STATUS_RUNNING: WORKING, + STATUS_IDLE: WAITING_ON_YOU, + STATUS_WORKING: WORKING, + STATUS_PROVIDER_EXITED: WAITING_ON_YOU, + STATUS_STATE_UNAVAILABLE: STATUS_UNAVAILABLE, + STATUS_UNKNOWN: STATUS_UNAVAILABLE, +} + +# `sp list` prints the collector's own status beside a session, and has only +# ever renamed the one word that contradicted the term sheet. It is a subset of +# STATE_WORDS, named rather than inlined, so the day the two converge is a +# deliberate edit here and not an accident somewhere else. +LIST_STATE_WORDS = { + STATUS_NEEDS_YOUR_REPLY: NEEDS_YOU, +} + +# Every word a collector may write into `agent_status`. `state_word` must have +# an entry for each: the test that walks this tuple is what keeps the mapping +# total as providers add vocabulary. +COLLECTOR_STATUSES = ( + STATUS_NEEDS_YOUR_REPLY, + STATUS_REPLY_OPTIONAL, + STATUS_RUNNING, + STATUS_WORKING, + STATUS_IDLE, + STATUS_PROVIDER_EXITED, + STATUS_STATE_UNAVAILABLE, + STATUS_UNKNOWN, +) + + +def state_word(text: str, *, terms: Mapping[str, str] | None = None) -> str: + """One screen word for one collector word. TOTAL, never the word itself. + + This used to end `table.get(text, text)`, so any status the table did not + name printed verbatim: `unknown`, `provider exited`, the vendor's `busy`, + and anything a future provider release invents. That is a collector word on + a person's screen, which this module's own docstring forbids, and it meant + the vocabulary was open-ended rather than the four action words named here. + + A word this file does not recognise is not a state; it is the kit failing + to read one, and it says so. + """ + table = STATE_WORDS if terms is None else terms + return table.get(text.casefold(), STATUS_UNAVAILABLE) + + +def session_state( + row: Mapping[str, Any], + *, + stall_seconds: int, +) -> str: + """The one state word for a session row. + + The precedence is the picker's, because the picker is the screen a person + reads first: an unfinished launch is named before anything else, a person's + turn beats every other description, an optional reply is still the model + working, and a session that has printed nothing for the whole stall window + has stopped working whatever the provider calls itself, which leaves the + person, so it says so. + """ + if row.get("setup_incomplete"): + return SETUP_INCOMPLETE + # An open picker or permission approval is one keystroke from unblocking + # the whole session, so it outranks every other readable state, including + # an old transcript which has also crossed the idle window. + if row.get("blocking_question") is True: + return QUESTION + status = clean_text(row.get("agent_status"), 64) or STATUS_STATE_UNAVAILABLE + if row.get("needs_you"): + return IDLE if row.get("transcript_idle") is True else WAITING_ON_YOU + reply_optional = ( + bool(row.get("reply_optional")) + or status.casefold() == STATUS_REPLY_OPTIONAL + ) + if reply_optional: + return WORKING + state = state_word(status) + quiet = row.get("recent_output_age_seconds") + if ( + state == WORKING + and isinstance(quiet, int) + and not isinstance(quiet, bool) + and quiet >= stall_seconds + ): + state = WAITING_ON_YOU + if state == WAITING_ON_YOU and row.get("transcript_idle") is True: + return IDLE + return state + + +# ------------------------------------------------------------ provider names + +# Provider names are product names and are always capitalized. A plain shell is +# a common noun; the group heading capitalizes it the way any heading would. +PROVIDER_NAMES = { + PROVIDER_CLAUDE: "Claude", + PROVIDER_CODEX: "Codex", + PROVIDER_SHELL: "Shell", + PROVIDER_UNKNOWN: "Unknown", +} + + +def provider_name(value: object) -> str: + """The display name for a provider, or the placeholder when there is none.""" + text = clean_text(value, 40) + if not text: + return MISSING + return PROVIDER_NAMES.get(text.casefold(), text.title()) + + +# --------------------------------------------------------------- group words + +GROUP_READY = "Ready" +GROUP_OPEN_ELSEWHERE = "Open elsewhere" +GROUP_OUTSIDE_THE_KIT = "Outside the kit" + +WHERE_READY = "ready" +WHERE_OPEN_ELSEWHERE = "open elsewhere" + + +def group_heading(availability: str) -> str: + """The heading a group of sessions sits under.""" + return GROUP_READY if availability == AVAILABILITY_READY else GROUP_OPEN_ELSEWHERE + + +def where_word(availability: object) -> str: + """Where a session can be opened, in the words every screen uses.""" + text = clean_text(availability, 40).casefold() + if text == AVAILABILITY_READY: + return WHERE_READY + if text == AVAILABILITY_ATTACHED: + return WHERE_OPEN_ELSEWHERE + return text or MISSING + + +# -------------------------------------------------------------- title states + +TITLE_STATE_SHOWING = "showing in the provider" +TITLE_STATE_WAITING = "waits for the session to restart" + + +def title_state(value: object) -> str: + """Whether the provider is already showing this session's title. + + Never the word `ready`: that is reserved for "openable in this window", and + a reader who has learned it would read this row as a place to open. + """ + state = clean_text(value, 40).casefold() + if state == TITLE_STATE_READY: + return TITLE_STATE_SHOWING + if state == TITLE_STATE_PENDING: + return TITLE_STATE_WAITING + return state + + +# ---------------------------------------------------------------- age phrases + +JUST_NOW = "just now" +LOCAL_TIME = "local time" + + +def plural(count: int, singular: str, suffix: str = "s") -> str: + """`1 day`, `2 days`, the kit never prints `1 days` or `day(s)`.""" + return singular if count == 1 else f"{singular}{suffix}" + + +def relative_time(timestamp_ms: int, now_ms: int) -> str: + """`just now`, `12 min ago`, `1 hr ago`, `3 days ago`, then a date.""" + seconds = max(0, (now_ms - timestamp_ms) // 1000) + if seconds < 60: + return JUST_NOW + if seconds < 3600: + return f"{seconds // 60} min ago" + if seconds < 86400: + return f"{seconds // 3600} hr ago" + if seconds < 30 * 86400: + days = seconds // 86400 + return f"{days} {plural(days, 'day')} ago" + local = datetime.fromtimestamp(timestamp_ms / 1000).astimezone() + current = datetime.fromtimestamp(now_ms / 1000).astimezone() + date = f"{local.strftime('%b')} {local.day}" + return date if local.year == current.year else f"{date}, {local.year}" + + +def precise_local_time(timestamp_ms: int) -> str: + """`Aug 12, 2026 at 4:05 PM CDT`, the exact moment, in the reader's zone.""" + local = datetime.fromtimestamp(timestamp_ms / 1000).astimezone() + hour = local.hour % 12 or 12 + zone = local.tzname() or LOCAL_TIME + return ( + f"{local.strftime('%b')} {local.day}, {local.year} at " + f"{hour}:{local.minute:02d} {local.strftime('%p')} {zone}" + ) + + +def exact_and_relative(timestamp_ms: int, now_ms: int) -> str: + """The detail view's one time form: the moment, then how long ago.""" + return f"{precise_local_time(timestamp_ms)} ({relative_time(timestamp_ms, now_ms)})" + + +def duration(seconds: int) -> str: + """A span, in the compact form the lists use: `5m`, `2h 3m`, `1d 4h`.""" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes}m" + hours, minutes = divmod(minutes, 60) + if hours < 24: + return f"{hours}h {minutes}m" + days, hours = divmod(hours, 24) + return f"{days}d {hours}h" + + +def brief_age(seconds: int) -> str: + """`duration()` with a word for the first minute: `now`, then `5m`.""" + if seconds < 60: + return "now" + return duration(seconds) + + +def waiting_phrase(seconds: int) -> str: + """How long a session has been a person's turn: `needs you 12 min`.""" + if seconds < 60: + return f"{NEEDS_YOU} under 1 min" + if seconds < 3600: + return f"{NEEDS_YOU} {seconds // 60} min" + if seconds < 86400: + return f"{NEEDS_YOU} {seconds // 3600} hr" + days = seconds // 86400 + return f"{NEEDS_YOU} {days} {plural(days, 'day')}" + + +def waiting_since(timestamp_ms: int, now_ms: int) -> str: + """`needs you 12 min` while that reads, then `needs you since Jul 4`.""" + seconds = max(0, (now_ms - timestamp_ms) // 1000) + if seconds < 30 * 86400: + return waiting_phrase(seconds) + return f"{NEEDS_YOU} since {relative_time(timestamp_ms, now_ms)}" + + +# ------------------------------------------------------------- list wording + +SESSION = "session" +SESSIONS_EMPTY = "Sessions: none." +NO_MATCHES = "No matches." + + +def empty_state(thing: str) -> str: + """`Accounts: none.`, the one empty-state form. A count line at zero + disappears rather than printing `0`.""" + return f"{thing}: {NONE}." + + +def session_count(total: int, ready: int, elsewhere: int) -> str: + """`3 sessions: 2 ready, 1 open elsewhere`, the line above the list.""" + return ( + f"{total} {plural(total, SESSION)}: " + f"{ready} {WHERE_READY}, {elsewhere} {WHERE_OPEN_ELSEWHERE}" + ) + + +def stale_warning(source: object) -> str: + """The list is not live, and says which copy it is showing.""" + return f"Warning: showing {source} inventory." + + +def quiet_status(age: str) -> str: + """A provider that claims to be running but has printed nothing.""" + return f"quiet {age}" + + +# ------------------------------------------------------ the one time phrase + +# Every session row carries exactly one time, and it answers one question: +# when did this session last do something. It used to carry two, `recent +# output 3h 9m ago` beside `process age 3h 9m`, which is one fact more than +# any row had room for, so every row truncated at a different point and the +# column stopped being a column (operator ruling, 2026-08-15). The second fact, how long +# the process has been alive, is still on `sp detail`, where there is room for +# it and nothing else is competing for the line. +LAST_ACTIVE = "last active" +LAST_ACTIVE_UNKNOWN = f"{LAST_ACTIVE} pending" +LAST_ACTIVE_JUST_NOW = f"{LAST_ACTIVE} just now" + + +def last_active(seconds: int | None) -> str: + """`last active 3h 9m ago`, the same words on every row, always. + + One phrase and one shape, so the column lines up down the whole list at + any width instead of alternating between `3 hr ago` and `opened 3 hr ago`. + """ + if seconds is None or isinstance(seconds, bool) or not isinstance(seconds, int): + return LAST_ACTIVE_UNKNOWN + if seconds < 0: + return LAST_ACTIVE_UNKNOWN + if seconds < 60: + return LAST_ACTIVE_JUST_NOW + return f"{LAST_ACTIVE} {duration(seconds)} ago" + + +# ------------------------------------------------------------- model wording + +# What the model column says when there is no model to name. A dash told the +# person the field exists and is empty, which for this column was the one thing +# they already knew; each of these says WHY it is empty instead. +# +# The collector records the reason (`model_state`) and the screens turn it into +# a word here, a placeholder is copy, and copy does not belong in a row. +MODEL_STATE_NOT_APPLICABLE = "not-applicable" +MODEL_STATE_NO_REPLY_YET = "no-reply-yet" +MODEL_STATE_UNREADABLE = "unreadable" + +MODEL_NOT_APPLICABLE = "no model" +MODEL_NO_REPLY_YET = "pending" +MODEL_UNREADABLE = "pending" + +MODEL_STATE_WORDS = { + MODEL_STATE_NOT_APPLICABLE: MODEL_NOT_APPLICABLE, + MODEL_STATE_NO_REPLY_YET: MODEL_NO_REPLY_YET, + MODEL_STATE_UNREADABLE: MODEL_UNREADABLE, +} + + +def model_state_word(state: object, provider: object) -> str: + """Why this row has no model, in words, or a guess from the provider. + + A row assembled before the collector recorded a reason still has to say + something true: a shell has no model to show, and a provider session whose + own record could not be read is exactly that. + """ + word = MODEL_STATE_WORDS.get(clean_text(state, 40)) + if word: + return word + family = clean_text(provider, 40).casefold() + if family in {PROVIDER_CLAUDE, PROVIDER_CODEX}: + return MODEL_UNREADABLE + return MODEL_NOT_APPLICABLE + + +def model_cell(row: Mapping[str, Any]) -> str: + """What the model column says for one row, on EVERY surface. + + Both stacks used to answer this separately and they disagreed: the picker + honoured the collector's recorded reason and said `no reply yet` for a + brand-new conversation, while the cursor picker never read that field, + guessed from the provider alone, and called the same healthy session + `unreadable`, the kit reporting a broken transcript with nothing wrong. + One function, so there is one answer. + """ + display = clean_text(row.get("display_model"), 80) + if display: + return display + model = clean_text(row.get("model"), 160) + if model: + return model + return model_state_word( + row.get("model_state"), row.get("display_provider") or row.get("provider") + ) + + +# The furthest into the past a recorded moment may sit and still be believed. +# Ten years: longer than any session, and the shape a bad stamp takes. +MAX_PLAUSIBLE_AGE_SECONDS = 10 * 365 * 86400 + + +def last_active_seconds(row: Mapping[str, Any], now_ms: int) -> int | None: + """How long since this session last did anything, on EVERY surface. + + The recorded moment is preferred, because a list can be drawn many times + from one snapshot and the collector's stored age froze when it was written. + Two ways a stamp is refused rather than believed: + + * it is in the FUTURE. That used to be clamped to zero, so a box whose + clock had skewed forward, or a stamp source ahead of it, reported every + row as `just now`, which turns a screen full of sessions waiting on a + person into a screen full of busy ones. Silence read as activity is the + failure this column exists to prevent. + * it is implausibly old, which is what a few-milliseconds-past-the-epoch + stamp subtracts to. + + In both cases the collector's recorded age answers instead. + """ + stamp = row.get("recent_output_at_unix_ms") + if isinstance(stamp, int) and not isinstance(stamp, bool) and stamp > 0: + age = (now_ms - stamp) // 1000 + if 0 <= age <= MAX_PLAUSIBLE_AGE_SECONDS: + return age + seconds = row.get("recent_output_age_seconds") + if isinstance(seconds, int) and not isinstance(seconds, bool) and seconds >= 0: + return seconds + return None + + +def row_last_active(row: Mapping[str, Any], now_ms: int) -> str: + """The one time phrase a session row shows, on EVERY surface.""" + return last_active(last_active_seconds(row, now_ms)) + + +def row_last_active_compact(row: Mapping[str, Any], now_ms: int) -> str: + """An identifiable time token for a row that cannot carry the full label.""" + + seconds = last_active_seconds(row, now_ms) + if seconds is None: + return "pending" + return brief_age(seconds) + + +def subagent_detail(count: int) -> str: + return f"{count} {plural(count, 'subagent')}" + + +def worktree_detail(branch: str) -> str: + return f"worktree {branch}" + + +# The footer of `sp list`: verb first, one action per clause. +# '#' is the number placeholder (operator ruling 2026-08-16): a bare n reads +# as a letter key; '#' reads as "a number goes here". +LIST_HINTS = ( + "sp go # open · sp new start", + 'sp close # close · sp find "text" search history', +) + + +# ------------------------------------------------------------ detail wording + +DETAIL_HEADING = "Session detail" + +DETAIL_SESSION = "Session" +DETAIL_TITLE = "Title" +DETAIL_PROVIDER = "Provider" +DETAIL_ACCOUNT_ALIAS = "Account alias" +DETAIL_ACCOUNT_EMAIL = "Account email" +DETAIL_ACCOUNT_PLAN = "Account plan" +DETAIL_MODEL = "Model" +DETAIL_STATE = "State" +DETAIL_WHERE = "Where" +DETAIL_LAST_RESPONSE = "Last response" +DETAIL_WAITING_SINCE = "Waiting since" +DETAIL_OPENED = "Opened" +# The list rows carry one time each; this is where the other one still lives. +DETAIL_PROCESS_AGE = "Running for" +DETAIL_PROJECT = "Project" +DETAIL_WORKTREE = "Worktree" +DETAIL_COLOR = "Color" +DETAIL_CONVERSATION = "Conversation" +DETAIL_SUBAGENTS = "Subagents" +DETAIL_CHILD_SHELL = "Child shell" +DETAIL_CHILD_WORKER = "Child worker" +CHILD_KIND_WORKER = "worker" +DETAIL_TITLE_STATE = "Title state" + +NOT_NUMBERED = "not numbered" +CONVERSATION_EXACT = "exact" +CONVERSATION_INEXACT = "not yet exact" +NONE = "none" + + +# --------------------------------------------------- refusals and outcomes + +# One prefix for a genuine failure, on stderr. Screen copy is the UI and prints +# unprefixed on stdout. +ERROR_PREFIX = "session-kit:" + +NO_MATCHING_SESSION = f"{ERROR_PREFIX} no session matches that selector" + +# The only cancel wording in the kit. +CANCEL = "Nothing changed." + +NUMBERS_HINT = "Numbers shown here work." + + +def error(fact: str) -> str: + """`session-kit: <fact>.`, the one error form, for stderr.""" + return f"{ERROR_PREFIX} {fact}" + + +def no_such_session(number: object) -> str: + """State the fact, then the way forward, in one line.""" + return f"There is no session {number} on this screen. {NUMBERS_HINT}" diff --git a/lib/sessionkit_inventory/lifecycle.py b/lib/sessionkit_inventory/lifecycle.py index 95a15a5..c90946e 100644 --- a/lib/sessionkit_inventory/lifecycle.py +++ b/lib/sessionkit_inventory/lifecycle.py @@ -10,23 +10,47 @@ import secrets import stat import time -from typing import Any, Mapping +from typing import Any, Iterable, Mapping from .common import CollectionError, PROVIDERS, valid_uuid +from .model import recovery_spec from .state_io import ( + StateLock, atomic_write_private_json, create_private_json, + ensure_private_directory, read_private_json, ) +from .terminal import TERMINAL_NUMBER_QUARANTINE_SECONDS +# A closed conversation stops being interesting exactly when the number it +# freed stops being quarantined. One clock for both, so nothing can survive +# in one register and be forgotten in the other. +CLOSE_INTENT_RETENTION_SECONDS = TERMINAL_NUMBER_QUARANTINE_SECONDS -LIFECYCLE_SCHEMA_VERSION = 2 +# Exact recovery is useful after the session has disappeared, so it has a +# seven-day housekeeping lifetime of its own. This is not a stale-reading +# guard: stale-reading safety is provided by captured record generations below +# and does not depend on wall time. +EXACT_RECOVERY_RETENTION_SECONDS = TERMINAL_NUMBER_QUARANTINE_SECONDS + + +LIFECYCLE_SCHEMA_VERSION = 4 MAX_LIFECYCLE_BYTES = 16 * 1024 _SESSION_ID_RE = re.compile( r"(?:main(?:[1-9][0-9]*)?|" r"s[0-9]{8}-[0-9]{6}-[1-9][0-9]*(?:-[1-9][0-9]*)?)" ) _BOOT_ID_RE = re.compile(r"[0-9a-fA-F-]{8,128}") +_RECORD_GENERATION_RE = re.compile(r"[0-9a-f]{64}") + +LifecycleGeneration = tuple[str, str] + + +def _lifecycle_lock(state_dir: Path) -> StateLock: + directory = state_dir / "lifecycle" + ensure_private_directory(directory) + return StateLock(directory, directory / "lock") def _checked_session_id(session_id: str) -> str: @@ -97,6 +121,145 @@ def _last_exact_path( return None if key is None else state_dir / "lifecycle" / f"{key}.exact.json" +# ---- intentional close --------------------------------------------------- +# A session that somebody closed on purpose must never come back as a crash +# offer. Intent is recorded as a tombstone keyed by provider:uuid, written by +# every deliberate close -- a clean provider exit, the exit menu's close, and +# `k` from the picker -- and read by the crash queue, which refuses to enqueue +# a conversation that carries one and drops any entry it already holds for it. +# +# The tombstone outlives the session it describes (that is the whole point: +# the session is gone), so it cannot live in the per-generation lifecycle +# document, which is pruned the moment its session disappears. It expires on +# the SAME clock as the terminal number it freed -- one policy, not two. +CLOSE_INTENT_SCHEMA_VERSION = 1 +MAX_CLOSE_INTENT_BYTES = 256 * 1024 + + +def close_intent_path(state_dir: Path) -> Path: + # Beside the recovery queue it speaks to, NOT inside lifecycle/, whose + # every other file is one keyed generation and whose pruner walks the + # directory by name. + return state_dir / "closed-conversations.json" + + +def close_intent_lock_path(state_dir: Path) -> Path: + # One document, many sessions. Every session on the machine tombstones + # into the same file, so two closing at the same moment is ordinary rather + # than exotic -- and this is the file whose loss makes a deliberate close + # come back as unclaimed lost work. + return state_dir / "closed-conversations.lock" + + +def close_intent_key(provider: str, uuid: str) -> str: + checked = valid_uuid(uuid) + if provider not in PROVIDERS or not checked: + raise CollectionError("close intent requires a provider and an exact UUID") + return f"{provider}:{checked}" + + +def _validated_close_intents(value: Any) -> dict[str, Any]: + if ( + not isinstance(value, Mapping) + or value.get("schema_version") != CLOSE_INTENT_SCHEMA_VERSION + or not isinstance(value.get("closed"), Mapping) + ): + return {"schema_version": CLOSE_INTENT_SCHEMA_VERSION, "closed": {}} + closed: dict[str, Any] = {} + for key, entry in value["closed"].items(): + if ( + not isinstance(key, str) + or not isinstance(entry, Mapping) + or not isinstance(entry.get("at_unix_ms"), int) + or isinstance(entry.get("at_unix_ms"), bool) + ): + continue + closed[key] = {"at_unix_ms": entry["at_unix_ms"]} + return {"schema_version": CLOSE_INTENT_SCHEMA_VERSION, "closed": closed} + + +def load_close_intents( + state_dir: Path, + *, + now_unix_ms: int | None = None, + retention_seconds: float = CLOSE_INTENT_RETENTION_SECONDS, +) -> dict[str, Any]: + """Every unexpired tombstone. Reading one never writes anything.""" + document = _validated_close_intents( + read_private_json( + close_intent_path(state_dir), + max_bytes=MAX_CLOSE_INTENT_BYTES, + allow_missing=True, + ) + ) + now = int(time.time() * 1000) if now_unix_ms is None else now_unix_ms + horizon = now - int(retention_seconds * 1000) + document["closed"] = { + key: entry + for key, entry in document["closed"].items() + if entry["at_unix_ms"] >= horizon + } + return document + + +def record_close_intent( + state_dir: Path, + *, + provider: str, + uuid: str, + now_unix_ms: int | None = None, + retention_seconds: float = CLOSE_INTENT_RETENTION_SECONDS, +) -> dict[str, Any]: + """Mark one conversation as closed on purpose, and expire the stale ones. + + UNDER A LOCK, and the lock is the point. This is a read/modify/replace of + ONE document that every session on the machine tombstones into, so two + sessions closing in the same second is ordinary. Unlocked, both read the + same predecessor and each replaces it: the last writer erases the other's + intent, and a conversation somebody deliberately closed comes back offered + as unclaimed lost work. Atomic replacement stops a torn file; it does not + stop a lost update, and only one of those two failures had been thought + about (found in review, 2026-08-15). + """ + key = close_intent_key(provider, uuid) + now = int(time.time() * 1000) if now_unix_ms is None else now_unix_ms + ensure_private_directory(state_dir) + with StateLock(state_dir, close_intent_lock_path(state_dir)): + document = load_close_intents( + state_dir, + now_unix_ms=now, + retention_seconds=retention_seconds, + ) + document["closed"][key] = {"at_unix_ms": now} + atomic_write_private_json(close_intent_path(state_dir), document) + return document + + +def closed_on_purpose( + state_dir: Path, + *, + provider: Any, + uuid: Any, + intents: Mapping[str, Any] | None = None, + now_unix_ms: int | None = None, + retention_seconds: float = CLOSE_INTENT_RETENTION_SECONDS, +) -> bool: + """True when this exact conversation carries an unexpired tombstone.""" + checked = valid_uuid(uuid) + if provider not in PROVIDERS or not checked: + return False + document = ( + _validated_close_intents(intents) + if intents is not None + else load_close_intents( + state_dir, + now_unix_ms=now_unix_ms, + retention_seconds=retention_seconds, + ) + ) + return f"{provider}:{checked}" in document["closed"] + + def _positive_number(value: Any, label: str) -> int: if isinstance(value, bool) or not isinstance(value, int) or value <= 0: raise CollectionError(f"lifecycle {label} is invalid") @@ -104,32 +267,55 @@ def _positive_number(value: Any, label: str) -> int: def _validated_state(value: Any, expected_key: str | None = None) -> dict[str, Any]: - if not isinstance(value, Mapping) or set(value) != { + if ( + isinstance(value, Mapping) + and value.get("schema_version") == 2 + and "conversation_uuid" not in value + ): + value = {**value, "schema_version": 3, "conversation_uuid": None} + legacy_fields = { "schema_version", "session_key", "boot_id", "shell_pid", "shell_start_ticks", "provider", + "conversation_uuid", "provider_exited_at_monotonic_ns", "exit_code", "input_tracking", "user_input_after_exit", "keep", - }: + } + if not isinstance(value, Mapping): + raise CollectionError("lifecycle state has an invalid schema") + schema_version = value.get("schema_version") + expected_fields = ( + legacy_fields | {"record_generation"} + if schema_version == LIFECYCLE_SCHEMA_VERSION + else legacy_fields + ) + if ( + schema_version not in {3, LIFECYCLE_SCHEMA_VERSION} + or set(value) != expected_fields + ): raise CollectionError("lifecycle state has an invalid schema") key = value.get("session_key") boot_id = value.get("boot_id") provider = value.get("provider") + conversation_uuid = value.get("conversation_uuid") exit_code = value.get("exit_code") if ( - value.get("schema_version") != LIFECYCLE_SCHEMA_VERSION - or not isinstance(key, str) + not isinstance(key, str) or not re.fullmatch(r"[0-9a-f]{64}", key) or (expected_key is not None and key != expected_key) or not isinstance(boot_id, str) or not _BOOT_ID_RE.fullmatch(boot_id) or provider not in PROVIDERS + or ( + conversation_uuid is not None + and valid_uuid(conversation_uuid) != conversation_uuid + ) or isinstance(exit_code, bool) or not isinstance(exit_code, int) or exit_code < 0 @@ -137,6 +323,13 @@ def _validated_state(value: Any, expected_key: str | None = None) -> dict[str, A or not isinstance(value.get("input_tracking"), bool) or not isinstance(value.get("user_input_after_exit"), bool) or not isinstance(value.get("keep"), bool) + or ( + schema_version == LIFECYCLE_SCHEMA_VERSION + and ( + not isinstance(value.get("record_generation"), str) + or not _RECORD_GENERATION_RE.fullmatch(value["record_generation"]) + ) + ) ): raise CollectionError("lifecycle state has invalid fields") for name in ( @@ -162,6 +355,42 @@ def load_state(state_dir: Path, session_id: str) -> dict[str, Any] | None: return None if value is None else _validated_state(value, key) +def cleanup_protected( + state_dir: Path, + session_ids: Iterable[str], +) -> set[str]: + """Session IDs whose own lifecycle record forbids unattended cleanup. + + The exit menu answers "Automatic cleanup is disabled for this terminal" + with a durable `keep`, and typing in the shell after the provider exited + is the same promise made by using the terminal. Both bind every engine + that closes a session without a person present, not only `--auto-close`. + Fails closed: a record that exists but will not read or validate protects + its session. + """ + protected: set[str] = set() + if _lifecycle_secret(state_dir, create=False) is None: + return protected + for session_id in session_ids: + try: + checked = _checked_session_id(session_id) + except CollectionError: + continue + path = lifecycle_path(state_dir, checked) + if path is None or not path.exists(): + continue + try: + state = load_state(state_dir, checked) + except CollectionError: + protected.add(checked) + continue + if state is None: + continue + if state["keep"] or state["user_input_after_exit"]: + protected.add(checked) + return protected + + def record_provider_exit( state_dir: Path, *, @@ -170,44 +399,71 @@ def record_provider_exit( shell_pid: int, shell_start_ticks: int, provider: str, + conversation_uuid: str | None = None, exit_code: int, input_tracking: bool, now_monotonic_ns: int | None = None, ) -> dict[str, Any]: """Record one exact provider exit while preserving prior safety blockers.""" - key = session_key(state_dir, session_id, create=True) - assert key is not None - previous = load_state(state_dir, session_id) - same_generation = bool( - previous - and previous["boot_id"] == boot_id - and previous["shell_pid"] == shell_pid - and previous["shell_start_ticks"] == shell_start_ticks - ) - value = { - "schema_version": LIFECYCLE_SCHEMA_VERSION, - "session_key": key, - "boot_id": boot_id, - "shell_pid": shell_pid, - "shell_start_ticks": shell_start_ticks, - "provider": provider, - "provider_exited_at_monotonic_ns": ( - time.monotonic_ns() - if now_monotonic_ns is None - else now_monotonic_ns - ), - "exit_code": exit_code, - "input_tracking": input_tracking, - "user_input_after_exit": bool( - same_generation and previous["user_input_after_exit"] - ), - "keep": bool(same_generation and previous["keep"]), - } - checked = _validated_state(value, key) - path = lifecycle_path(state_dir, session_id) - assert path is not None - atomic_write_private_json(path, checked) - return checked + if ( + conversation_uuid is not None + and valid_uuid(conversation_uuid) != conversation_uuid + ): + raise CollectionError("provider-exit conversation UUID is invalid") + with _lifecycle_lock(state_dir): + key = session_key(state_dir, session_id, create=True) + assert key is not None + previous = load_state(state_dir, session_id) + same_generation = bool( + previous + and previous["boot_id"] == boot_id + and previous["shell_pid"] == shell_pid + and previous["shell_start_ticks"] == shell_start_ticks + ) + if same_generation and previous is not None: + prior_conversation = previous.get("conversation_uuid") + if previous.get("provider") != provider: + raise CollectionError( + "provider-exit provider changed within one shell generation" + ) + if ( + prior_conversation is not None + and conversation_uuid is not None + and prior_conversation != conversation_uuid + ): + raise CollectionError( + "provider-exit conversation changed within one shell generation" + ) + if conversation_uuid is None: + conversation_uuid = prior_conversation + value = { + "schema_version": LIFECYCLE_SCHEMA_VERSION, + "record_generation": secrets.token_hex(32), + "session_key": key, + "boot_id": boot_id, + "shell_pid": shell_pid, + "shell_start_ticks": shell_start_ticks, + "provider": provider, + "conversation_uuid": valid_uuid(conversation_uuid) or None, + "provider_exited_at_monotonic_ns": ( + time.monotonic_ns() if now_monotonic_ns is None else now_monotonic_ns + ), + "exit_code": exit_code, + "input_tracking": input_tracking, + "user_input_after_exit": bool( + previous["user_input_after_exit"] + if same_generation and previous is not None + else False + ), + "keep": bool( + previous["keep"] if same_generation and previous is not None else False + ), + } + checked = _validated_state(value, key) + path = lifecycle_path(state_dir, session_id) + assert path is not None + atomic_write_private_json(path, checked) + return checked def update_state( @@ -221,28 +477,31 @@ def update_state( keep: bool | None = None, ) -> dict[str, Any]: """Record permanent shell use or an explicit keep choice.""" - current = load_state(state_dir, session_id) - if current is None: - raise CollectionError("provider-exit lifecycle state is unavailable") - if ( - current["boot_id"] != boot_id - or current["shell_pid"] != shell_pid - or current["shell_start_ticks"] != shell_start_ticks - ): - raise CollectionError("provider-exit lifecycle generation changed") - if event == "user-input": - current["user_input_after_exit"] = True - elif event == "keep" and isinstance(keep, bool): - current["keep"] = keep - else: - raise CollectionError("unsupported lifecycle update") - key = session_key(state_dir, session_id, create=False) - assert key is not None - checked = _validated_state(current, key) - path = lifecycle_path(state_dir, session_id) - assert path is not None - atomic_write_private_json(path, checked) - return checked + with _lifecycle_lock(state_dir): + current = load_state(state_dir, session_id) + if current is None: + raise CollectionError("provider-exit lifecycle state is unavailable") + if ( + current["boot_id"] != boot_id + or current["shell_pid"] != shell_pid + or current["shell_start_ticks"] != shell_start_ticks + ): + raise CollectionError("provider-exit lifecycle generation changed") + if event == "user-input": + current["user_input_after_exit"] = True + elif event == "keep" and isinstance(keep, bool): + current["keep"] = keep + else: + raise CollectionError("unsupported lifecycle update") + current["schema_version"] = LIFECYCLE_SCHEMA_VERSION + current["record_generation"] = secrets.token_hex(32) + key = session_key(state_dir, session_id, create=False) + assert key is not None + checked = _validated_state(current, key) + path = lifecycle_path(state_dir, session_id) + assert path is not None + atomic_write_private_json(path, checked) + return checked def _same_shell_generation( @@ -324,7 +583,8 @@ def _last_exact_document( session_hash = session_key(state_dir, session_id, create=True) assert session_hash is not None return { - "schema_version": 1, + "schema_version": 2, + "record_generation": secrets.token_hex(32), "session_key": session_hash, "boot_id": boot_id, "shell_pid": shell.get("pid"), @@ -346,7 +606,7 @@ def _validated_last_exact( shell_pid: Any, shell_start_ticks: Any, ) -> dict[str, Any] | None: - if not isinstance(value, Mapping) or set(value) != { + legacy_fields = { "schema_version", "session_key", "boot_id", @@ -358,14 +618,20 @@ def _validated_last_exact( "display_title", "title_source", "recovery", - }: + } + if not isinstance(value, Mapping): + return None + schema_version = value.get("schema_version") + expected_fields = ( + legacy_fields | {"record_generation"} if schema_version == 2 else legacy_fields + ) + if schema_version not in {1, 2} or set(value) != expected_fields: return None provider = value.get("provider") uuid = valid_uuid(value.get("uuid")) recovery = value.get("recovery") if ( - value.get("schema_version") != 1 - or value.get("session_key") != expected_key + value.get("session_key") != expected_key or value.get("boot_id") != boot_id or value.get("shell_pid") != shell_pid or value.get("shell_start_ticks") != shell_start_ticks @@ -375,6 +641,13 @@ def _validated_last_exact( or recovery.get("available") is not True or recovery.get("provider") != provider or valid_uuid(recovery.get("uuid")) != uuid + or ( + schema_version == 2 + and ( + not isinstance(value.get("record_generation"), str) + or not _RECORD_GENERATION_RE.fullmatch(value["record_generation"]) + ) + ) ): return None return dict(value) @@ -388,44 +661,42 @@ def persist_last_exact( boot_id: str, ) -> None: """Preserve exact recovery across the provider-exit/cache handoff race.""" - prior_by_id = { - item.get("shpool_id_raw"): item - for item in ( - previous_inventory.get("sessions", ()) - if isinstance(previous_inventory, Mapping) - else () - ) - if isinstance(item, Mapping) and isinstance(item.get("shpool_id_raw"), str) - } - for item in inventory.get("sessions", ()): - if not isinstance(item, Mapping): - continue - # The canonical inventory already retains exact identity while the - # provider is live. Write a separate handoff record only at the moment - # a same-generation live row would otherwise be replaced by an idle - # shell row. - if item.get("provider") in PROVIDERS: - continue - document = None - prior = prior_by_id.get(item.get("shpool_id_raw")) - if ( - isinstance(prior, Mapping) - and _same_shell_generation(item, prior) - ): - document = _last_exact_document( - prior, - state_dir=state_dir, - boot_id=boot_id, + with _lifecycle_lock(state_dir): + prior_by_id = { + item.get("shpool_id_raw"): item + for item in ( + previous_inventory.get("sessions", ()) + if isinstance(previous_inventory, Mapping) + else () ) - if document is None: - continue - path = _last_exact_path( - state_dir, - str(item["shpool_id_raw"]), - create_key=True, - ) - assert path is not None - atomic_write_private_json(path, document) + if isinstance(item, Mapping) and isinstance(item.get("shpool_id_raw"), str) + } + for item in inventory.get("sessions", ()): + if not isinstance(item, Mapping): + continue + # The canonical inventory already retains exact identity while the + # provider is live. Write a separate handoff record only at the + # moment a same-generation live row would otherwise be replaced by + # an idle shell row. + if item.get("provider") in PROVIDERS: + continue + document = None + prior = prior_by_id.get(item.get("shpool_id_raw")) + if isinstance(prior, Mapping) and _same_shell_generation(item, prior): + document = _last_exact_document( + prior, + state_dir=state_dir, + boot_id=boot_id, + ) + if document is None: + continue + path = _last_exact_path( + state_dir, + str(item["shpool_id_raw"]), + create_key=True, + ) + assert path is not None + atomic_write_private_json(path, document) def load_last_exact( @@ -456,49 +727,114 @@ def load_last_exact( ) +def _capturable_generation(path: Path) -> LifecycleGeneration | None: + value = read_private_json( + path, + max_bytes=MAX_LIFECYCLE_BYTES, + allow_missing=True, + ) + if not isinstance(value, Mapping): + return None + generation = value.get("record_generation") + if not isinstance(generation, str) or not _RECORD_GENERATION_RE.fullmatch( + generation + ): + # Records written by an older release have no exact generation. They + # remain readable, but no collector is allowed to retire them. + return None + return (path.name, generation) + + +def capture_lifecycle_generations( + state_dir: Path, +) -> frozenset[LifecycleGeneration]: + """Exact record generations present before the next process reading.""" + lifecycle_dir = state_dir / "lifecycle" + if not lifecycle_dir.exists(): + return frozenset() + captured: set[LifecycleGeneration] = set() + try: + with _lifecycle_lock(state_dir): + for path in lifecycle_dir.iterdir(): + if not re.fullmatch( + r"[0-9a-f]{64}(?:\.exact)?\.json", + path.name, + ): + continue + try: + generation = _capturable_generation(path) + except (CollectionError, OSError): + continue + if generation is not None: + captured.add(generation) + except (CollectionError, OSError): + return frozenset() + return frozenset(captured) + + def prune_inactive_state( state_dir: Path, active_session_ids: list[str], + *, + retire_generations: Iterable[LifecycleGeneration] = (), ) -> int: - """Remove generation state only after its managed session disappears.""" - secret = _lifecycle_secret(state_dir, create=False) + """Retire only generations seen before the process reading began.""" + allowed = frozenset(retire_generations) + if not allowed: + return 0 lifecycle_dir = state_dir / "lifecycle" - if secret is None or not lifecycle_dir.exists(): + if not lifecycle_dir.exists(): return 0 - active_keys = { - hmac.new( - secret, - _checked_session_id(session_id).encode("utf-8"), - hashlib.sha256, - ).hexdigest() - for session_id in active_session_ids - } - removed = 0 - for path in lifecycle_dir.iterdir(): - match = re.fullmatch( - r"([0-9a-f]{64})(?:\.exact)?\.json", - path.name, - ) - if not match or match.group(1) in active_keys: - continue - metadata = path.lstat() - if ( - not stat.S_ISREG(metadata.st_mode) - or metadata.st_uid != os.geteuid() - or stat.S_IMODE(metadata.st_mode) != 0o600 - ): - raise CollectionError( - "inactive lifecycle state is not a private regular file" + with _lifecycle_lock(state_dir): + secret = _lifecycle_secret(state_dir, create=False) + if secret is None: + return 0 + active_keys = { + hmac.new( + secret, + _checked_session_id(session_id).encode("utf-8"), + hashlib.sha256, + ).hexdigest() + for session_id in active_session_ids + } + removed = 0 + now = time.time() + for path in lifecycle_dir.iterdir(): + match = re.fullmatch( + r"([0-9a-f]{64})(?:\.exact)?\.json", + path.name, ) - path.unlink() - removed += 1 - if removed: - directory = os.open(lifecycle_dir, os.O_RDONLY | os.O_DIRECTORY) - try: - os.fsync(directory) - finally: - os.close(directory) - return removed + if not match or match.group(1) in active_keys: + continue + metadata = path.lstat() + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or stat.S_IMODE(metadata.st_mode) != 0o600 + ): + raise CollectionError( + "inactive lifecycle state is not a private regular file" + ) + generation = _capturable_generation(path) + if generation not in allowed: + continue + # Exact recovery intentionally outlives its session for seven days + # as housekeeping policy. Ordinary lifecycle/KEEP records have no + # age test: their race guard is the captured generation above. + if ( + path.name.endswith(".exact.json") + and now - metadata.st_mtime < EXACT_RECOVERY_RETENTION_SECONDS + ): + continue + path.unlink() + removed += 1 + if removed: + directory = os.open(lifecycle_dir, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + return removed def apply_provider_exit_states( @@ -540,8 +876,7 @@ def apply_provider_exit_states( lifecycle is None or lifecycle["boot_id"] != boot_id or lifecycle["shell_pid"] != shell.get("pid") - or lifecycle["shell_start_ticks"] - != shell.get("process_start_ticks") + or lifecycle["shell_start_ticks"] != shell.get("process_start_ticks") ): continue provider = lifecycle["provider"] @@ -571,6 +906,17 @@ def apply_provider_exit_states( for key in ("title", "display_title", "title_source"): if isinstance(retained.get(key), str) and retained[key]: item[key] = retained[key] + committed_uuid = valid_uuid(lifecycle.get("conversation_uuid")) + if committed_uuid: + identity_hint = identity_hint or { + "provider": provider, + "uuid": committed_uuid, + } + recovery = recovery or recovery_spec( + provider, + committed_uuid, + item.get("cwd") if isinstance(item.get("cwd"), str) else None, + ) item["display_provider"] = provider item["agent_status"] = "provider exited" item["needs_you"] = False @@ -580,15 +926,17 @@ def apply_provider_exit_states( ] item["provider_exit_code"] = lifecycle["exit_code"] item["provider_exit_input_tracking"] = lifecycle["input_tracking"] - item["user_input_after_provider_exit"] = lifecycle[ - "user_input_after_exit" - ] + item["user_input_after_provider_exit"] = lifecycle["user_input_after_exit"] item["provider_exit_keep"] = lifecycle["keep"] if identity_hint is not None: item["_terminal_identity_hint"] = identity_hint item["exited_identity"] = { "uuid": identity_hint["uuid"], - "provenance": "last exact live inventory for this shell generation", + "provenance": ( + "committed provider-exit conversation for this shell generation" + if identity_hint["uuid"] == committed_uuid + else "last exact live inventory for this shell generation" + ), "confidence": "historical-exact", } if recovery is not None: diff --git a/lib/sessionkit_inventory/migration.py b/lib/sessionkit_inventory/migration.py new file mode 100644 index 0000000..d8d04f3 --- /dev/null +++ b/lib/sessionkit_inventory/migration.py @@ -0,0 +1,1066 @@ +"""One-time legacy transitions: runtime aliases and the recovery manifest. + +Two migrations live here because they share a shape rather than a subject. Each +runs once per installation, each rewrites private state that a pinned older +release may still read, and each is written to be abandonable, plan, publish, +apply, roll back, rather than applied in place. + +Keeping them together also keeps them out of ``recovery``. The recovery module +handles state that changes every time a session appears or disappears; nothing +here runs again once an installation has crossed the version it was written for. +""" + +from __future__ import annotations + +import datetime as dt +import json +import os +from pathlib import Path +import re +import stat as statmod +from typing import Any, Callable, Mapping + +from . import state_io as _state_io +from .common import ( + PROVIDERS, + CollectionError, + _utc_now, + _valid_aliases, + natural_name_key, + proc_root as gated_proc_root, + valid_uuid, +) +from .processes import _proc_stat +from .state_io import ( + StateLock, + _atomic_json_bytes, + _read_bounded_owner_file, + _state_paths, +) + + +def _strict_legacy_aliases( + path: Path, + *, + max_private_json_bytes: int, + schema_version: int, +) -> tuple[bytes, dict[str, str]] | None: + payload = _read_bounded_owner_file( + path, + label="legacy runtime alias file", + max_bytes=max_private_json_bytes, + exact_mode=0o600, + allow_missing=True, + ) + if payload is None: + return None + try: + raw = json.loads(payload.decode("utf-8", "strict")) + except (UnicodeDecodeError, ValueError) as exc: + raise CollectionError("legacy runtime alias file is invalid JSON") from exc + aliases = raw.get("aliases") if isinstance(raw, Mapping) else None + normalized = _valid_aliases(aliases) + if ( + not isinstance(raw, Mapping) + or raw.get("schema_version") != schema_version + or not isinstance(aliases, Mapping) + or len(normalized) != len(aliases) + ): + raise CollectionError("legacy runtime alias file has an invalid schema") + return payload, normalized + + +def _create_private_backup( + path: Path, + payload: bytes, + *, + max_private_json_bytes: int, +) -> None: + _state_io._create_private_backup( + path, + payload, + read_bounded_owner_file=_read_bounded_owner_file, + max_bytes=max_private_json_bytes, + ) + + +def _alias_migration_images( + backup_bytes: bytes, + runtime_aliases: Mapping[str, str], + *, + absent_alias_backup: bytes, + schema_version: int, + alias_document_from_bytes: Callable[..., dict[str, Any]], +) -> tuple[bytes | None, dict[str, Any], bytes]: + if backup_bytes == absent_alias_backup: + preimage = { + "schema_version": schema_version, + "aliases": {}, + } + preimage_bytes = None + else: + preimage = alias_document_from_bytes( + backup_bytes, label="alias migration backup" + ) + preimage_bytes = backup_bytes + merged = { + **_valid_aliases(preimage.get("aliases")), + **dict(runtime_aliases), + } + postimage = dict(preimage) + postimage["aliases"] = dict(sorted(merged.items())) + return preimage_bytes, postimage, _atomic_json_bytes(postimage) + + +def migrate_runtime_aliases( + config: Mapping[str, Any], + *, + absent_alias_backup: bytes, + max_private_json_bytes: int, + schema_version: int, + private_alias_parent: Callable[[Path], None], + atomic_write_json: Callable[[Path, Any], None], + canonical_aliases: Callable[[Mapping[str, Any]], dict[str, str]], + config_path: Callable[[], Path], + alias_migration_images_fn: Callable[..., Any], + create_private_backup_fn: Callable[..., Any], + strict_legacy_aliases_fn: Callable[..., Any], +) -> dict[str, Any]: + """Explicitly preserve the old effective runtime-wins values in config.""" + paths = _state_paths(config) + config_file = config_path() + backup = config_file.with_name(f"{config_file.name}.pre-runtime-alias-migration-v1") + with StateLock(paths["root"], paths["config_lock"]): + with StateLock(paths["root"], paths["lock"]): + legacy = strict_legacy_aliases_fn(paths["aliases"]) + archived = strict_legacy_aliases_fn(paths["aliases_archive"]) + if legacy is None: + if archived is not None: + source_backup = _read_bounded_owner_file( + paths["aliases_source_backup"], + label="legacy runtime alias source backup", + max_bytes=max_private_json_bytes, + exact_mode=0o600, + ) + if source_backup != archived[0]: + raise CollectionError( + "archived runtime aliases diverge from migration evidence" + ) + backup_bytes = _read_bounded_owner_file( + backup, + label="alias migration backup", + max_bytes=max_private_json_bytes, + exact_mode=0o600, + ) + if backup_bytes is None: + raise CollectionError("alias migration backup is unavailable") + before = _read_bounded_owner_file( + config_file, + label="canonical alias config", + max_bytes=max_private_json_bytes, + exact_mode=0o600, + allow_missing=True, + ) + ( + preimage_bytes, + postimage, + postimage_bytes, + ) = alias_migration_images_fn(backup_bytes, archived[1]) + repaired = False + if before == postimage_bytes: + pass + elif before == preimage_bytes: + private_alias_parent(config_file) + atomic_write_json(config_file, postimage) + repaired = True + else: + raise CollectionError( + "canonical alias config diverged from the migration postimage" + ) + directory = os.open(paths["root"], os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + return { + "schema_version": schema_version, + "migrated": bool(archived is not None and repaired), + "already_migrated": bool(archived is not None and not repaired), + "aliases": canonical_aliases(config), + } + rollback_retry = archived is not None + if archived is not None and archived[0] != legacy[0]: + raise CollectionError("active and archived runtime aliases differ") + private_alias_parent(config_file) + before = _read_bounded_owner_file( + config_file, + label="canonical alias config", + max_bytes=max_private_json_bytes, + exact_mode=0o600, + allow_missing=True, + ) + backup_bytes = _read_bounded_owner_file( + backup, + label="alias migration backup", + max_bytes=max_private_json_bytes, + exact_mode=0o600, + allow_missing=True, + ) + if backup_bytes is None: + backup_bytes = before if before is not None else absent_alias_backup + create_private_backup_fn(backup, backup_bytes) + create_private_backup_fn(paths["aliases_source_backup"], legacy[0]) + ( + preimage_bytes, + postimage, + postimage_bytes, + ) = alias_migration_images_fn(backup_bytes, legacy[1]) + if before == postimage_bytes: + pass + elif before == preimage_bytes: + atomic_write_json(config_file, postimage) + else: + raise CollectionError( + "canonical alias config diverged during migration" + ) + if rollback_retry: + os.unlink(paths["aliases"]) + else: + os.replace(paths["aliases"], paths["aliases_archive"]) + directory = os.open(paths["root"], os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + durable_archive = strict_legacy_aliases_fn(paths["aliases_archive"]) + source_backup = _read_bounded_owner_file( + paths["aliases_source_backup"], + label="legacy runtime alias source backup", + max_bytes=max_private_json_bytes, + exact_mode=0o600, + ) + if durable_archive is None: + raise CollectionError( + "runtime alias archive did not reach its durable postcondition" + ) + if ( + durable_archive[0] != legacy[0] + or source_backup != legacy[0] + or paths["aliases"].exists() + ): + raise CollectionError( + "runtime alias archive did not reach its durable postcondition" + ) + return { + "schema_version": schema_version, + "migrated": True, + "already_migrated": False, + "aliases": dict(postimage["aliases"]), + } + + +def _release_sha(value: Any, *, release_root: Path) -> str: + if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{40}", value): + raise CollectionError( + "release SHA must be one full lowercase 40-character commit" + ) + release_metadata = release_root / "RELEASE.json" + configured_root = os.environ.get("SESSION_KIT_RELEASE_DIR") + if configured_root and Path(configured_root).resolve() != release_root: + raise CollectionError( + "executing release does not match SESSION_KIT_RELEASE_DIR" + ) + if release_metadata.exists() or configured_root: + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(release_metadata, flags) + metadata = os.fstat(descriptor) + content = b"" + while True: + block = os.read(descriptor, 64 * 1024) + if not block: + break + content += block + except OSError as exc: + raise CollectionError("cannot verify executing immutable release") from exc + finally: + if "descriptor" in locals(): + os.close(descriptor) + try: + document = json.loads(content) + except (UnicodeDecodeError, ValueError) as exc: + raise CollectionError("executing RELEASE.json is invalid") from exc + if ( + not statmod.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or statmod.S_IMODE(metadata.st_mode) & 0o222 + or metadata.st_size > 64 * 1024 + or release_root.name != value + or not isinstance(document, Mapping) + or document.get("commit") != value + ): + raise CollectionError( + "release SHA is not bound to the executing immutable release" + ) + return value + + +def _daemon_start_epoch(proc_root: Path, generation: Mapping[str, Any]) -> float: + pid = generation.get("pid") + expected_ticks = generation.get("process_start_ticks") + if ( + not isinstance(pid, int) + or pid <= 0 + or not isinstance(expected_ticks, int) + or expected_ticks <= 0 + ): + raise CollectionError("current daemon generation is not exact") + try: + before = _proc_stat(proc_root / str(pid) / "stat") + lines = ( + (proc_root / "stat") + .read_text(encoding="utf-8", errors="replace") + .splitlines() + ) + boot_values = [ + int(line.split()[1]) + for line in lines + if len(line.split()) == 2 and line.split()[0] == "btime" + ] + ticks_per_second = int(os.sysconf("SC_CLK_TCK")) + after = _proc_stat(proc_root / str(pid) / "stat") + except (OSError, ValueError) as exc: + raise CollectionError("cannot prove current daemon wall-clock start") from exc + if ( + len(boot_values) != 1 + or boot_values[0] <= 0 + or ticks_per_second <= 0 + or before != after + or before[0] != pid + or before[2] != expected_ticks + ): + raise CollectionError("current daemon changed while proving continuity") + return boot_values[0] + (expected_ticks / ticks_per_second) + + +def _legacy_identities(value: Mapping[str, Any]) -> dict[str, tuple[str, str]]: + sessions = value.get("sessions") + outside = value.get("outside_agents", {}) + if not isinstance(sessions, Mapping) or not sessions or outside: + raise CollectionError( + "legacy migration requires nonempty shpool sessions and no outside roots" + ) + result: dict[str, tuple[str, str]] = {} + uuids: set[str] = set() + for shpool_id, item in sessions.items(): + if ( + not isinstance(shpool_id, str) + or not shpool_id + or not isinstance(item, Mapping) + ): + raise CollectionError( + "legacy recovery manifest contains an invalid session" + ) + provider = item.get("provider") + uuid = valid_uuid(item.get("uuid")) + if provider not in PROVIDERS or not uuid or uuid in uuids: + raise CollectionError("legacy recovery identities must be exact and unique") + result[shpool_id] = (provider, uuid) + uuids.add(uuid) + return result + + +def _evidence_identities(value: Mapping[str, Any]) -> dict[str, tuple[str, str]]: + rows = value.get("sessions") + if not isinstance(rows, list) or not rows: + raise CollectionError( + "continuity evidence must contain a nonempty session list" + ) + result: dict[str, tuple[str, str]] = {} + uuids: set[str] = set() + for item in rows: + if not isinstance(item, Mapping): + raise CollectionError("continuity evidence contains a non-object session") + shpool_id = item.get("shpool_name") + provider = item.get("provider") + uuid = valid_uuid(item.get("provider_uuid")) + if ( + not isinstance(shpool_id, str) + or not shpool_id + or provider not in PROVIDERS + or not uuid + or shpool_id in result + or uuid in uuids + ): + raise CollectionError( + "continuity evidence identities must be exact and unique" + ) + result[shpool_id] = (provider, uuid) + uuids.add(uuid) + return result + + +def _current_identities( + inventory: Mapping[str, Any], +) -> tuple[dict[str, tuple[str, str]], set[tuple[str, str]]]: + sessions: dict[str, tuple[str, str]] = {} + all_roots: set[tuple[str, str]] = set() + seen_uuids: set[str] = set() + for item in inventory.get("sessions", ()): + identity = item.get("identity") + recovery = item.get("recovery") + shpool_id = item.get("shpool_id_raw") + provider = item.get("provider") + uuid = ( + valid_uuid(identity.get("uuid")) if isinstance(identity, Mapping) else None + ) + recovery_uuid = ( + valid_uuid(recovery.get("uuid")) if isinstance(recovery, Mapping) else None + ) + if ( + not isinstance(shpool_id, str) + or provider not in PROVIDERS + or not uuid + or not isinstance(recovery, Mapping) + or recovery.get("available") is not True + or recovery.get("provider") != provider + or recovery_uuid != uuid + or shpool_id in sessions + or uuid in seen_uuids + ): + raise CollectionError("current shpool identities are not exact and unique") + sessions[shpool_id] = (provider, uuid) + all_roots.add((provider, uuid)) + seen_uuids.add(uuid) + outside = inventory.get("outside_agents", ()) + if outside: + # This one-time migration has no trusted historical evidence for + # outside roots, so including or excluding them would be an assumption. + raise CollectionError("legacy migration refuses current outside provider roots") + return sessions, all_roots + + +def _migration_context( + config: Mapping[str, Any], + *, + legacy_bytes: bytes, + evidence_path: Path, + collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, + proc_root: Path | None = None, + generation_key: Callable[[Mapping[str, Any]], Any], + has_recovery_entries: Callable[[Any], bool], + parse_private_json: Callable[..., tuple[bytes, Mapping[str, Any]]], + parse_utc_timestamp: Callable[..., Any], + sha256: Callable[[bytes], str], + collect_live: Callable[..., dict[str, Any]], + recovery_manifest: Callable[[Mapping[str, Any]], dict[str, Any]], + strict_live_inventory: Callable[[Mapping[str, Any]], bool], +) -> dict[str, Any]: + collect = collector or collect_live + try: + legacy = json.loads(legacy_bytes) + except (UnicodeDecodeError, ValueError) as exc: + raise CollectionError("legacy recovery manifest is invalid JSON") from exc + if ( + not isinstance(legacy, Mapping) + or not has_recovery_entries(legacy) + or legacy.get("daemon_generation") is not None + or not isinstance(legacy.get("boot_id"), str) + or not legacy.get("boot_id") + ): + raise CollectionError("source is not a null-generation legacy manifest") + legacy_generated = parse_utc_timestamp( + legacy.get("generated_at"), "legacy generated_at" + ) + evidence_bytes, evidence = parse_private_json(evidence_path, "continuity evidence") + evidence_captured = parse_utc_timestamp( + evidence.get("captured_at"), "continuity evidence captured_at" + ) + if evidence_captured > legacy_generated: + raise CollectionError( + "continuity evidence was captured after the legacy manifest" + ) + legacy_ids = _legacy_identities(legacy) + if _evidence_identities(evidence) != legacy_ids: + raise CollectionError( + "continuity evidence does not exactly match every legacy identity" + ) + settings = dict(config) + live = collect(settings) + if not isinstance(live, Mapping): + raise CollectionError("collector returned a non-object inventory") + live = dict(live) + complete = bool(live.pop("_complete", True)) + if not complete or not strict_live_inventory(live): + raise CollectionError( + "legacy recovery migration requires a complete strict live inventory" + ) + detected = recovery_manifest(live) + detected_key = generation_key(detected) + if ( + detected_key is None + or not has_recovery_entries(detected) + or detected.get("boot_id") != legacy.get("boot_id") + ): + raise CollectionError( + "legacy and current manifests do not have one exact same-boot generation" + ) + generation = detected["daemon_generation"] + if evidence.get("daemon_pid") != generation.get("pid"): + raise CollectionError("current daemon PID does not match continuity evidence") + root = proc_root or gated_proc_root() + daemon_started = _daemon_start_epoch(root, generation) + if daemon_started > evidence_captured.timestamp(): + raise CollectionError("current daemon did not predate continuity evidence") + current_ids, all_current_roots = _current_identities(live) + if not set(current_ids).issubset(legacy_ids): + raise CollectionError( + "current inventory contains a root absent from legacy evidence" + ) + reconciliation: list[dict[str, Any]] = [] + for shpool_id in sorted(legacy_ids, key=natural_name_key): + provider, uuid = legacy_ids[shpool_id] + current = current_ids.get(shpool_id) + if current is not None: + if current != (provider, uuid): + raise CollectionError( + f"current identity changed under legacy shpool ID {shpool_id!r}" + ) + disposition = "carried" + else: + if (provider, uuid) in all_current_roots: + raise CollectionError( + f"legacy identity moved from shpool ID {shpool_id!r}" + ) + disposition = "ended" + reconciliation.append( + { + "shpool_id": shpool_id, + "provider": provider, + "uuid": uuid, + "disposition": disposition, + } + ) + return { + "legacy": dict(legacy), + "legacy_sha256": sha256(legacy_bytes), + "evidence_sha256": sha256(evidence_bytes), + "evidence_captured_at": evidence_captured.isoformat().replace("+00:00", "Z"), + "daemon_started_at": dt.datetime.fromtimestamp(daemon_started, dt.timezone.utc) + .isoformat(timespec="microseconds") + .replace("+00:00", "Z"), + "generation": dict(generation), + "target_manifest": detected, + "current_roots": [ + {"shpool_id": key, "provider": value[0], "uuid": value[1]} + for key, value in sorted( + current_ids.items(), key=lambda item: natural_name_key(item[0]) + ) + ], + "reconciliation": reconciliation, + } + + +def _plan_token( + plan: Mapping[str, Any], + *, + json_bytes: Callable[[Any], bytes], + sha256: Callable[[bytes], str], +) -> str: + body = dict(plan) + body.pop("plan_token", None) + return sha256(json_bytes(body)) + + +def plan_legacy_recovery_manifest( + config: Mapping[str, Any], + continuity_evidence: str | Path, + release_sha: str, + *, + collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, + proc_root: Path | None = None, + now: float | None = None, + schema_version: int, + json_bytes: Callable[[Any], bytes], + read_private_regular_bytes: Callable[[Path], bytes], + sha256: Callable[[bytes], str], + migration_context_fn: Callable[..., Any], + plan_token_fn: Callable[..., Any], + verify_release_sha: Callable[[Any], str], +) -> dict[str, Any]: + """Create a read-only, content-addressed migration plan.""" + paths = _state_paths(config) + if paths["pending"].exists() or paths["pending"].is_symlink(): + raise CollectionError( + "legacy recovery migration refuses an existing pending queue" + ) + source_before = read_private_regular_bytes(paths["manifest"]) + evidence_path = Path(continuity_evidence).expanduser() + if not evidence_path.is_absolute(): + evidence_path = Path.cwd() / evidence_path + context = migration_context_fn( + config, + legacy_bytes=source_before, + evidence_path=evidence_path, + collector=collector, + proc_root=proc_root, + ) + if read_private_regular_bytes(paths["manifest"]) != source_before: + raise CollectionError("legacy manifest changed while planning") + release = verify_release_sha(release_sha) + source_hash = context["legacy_sha256"] + archive = paths["root"] / f"recovery-manifest.legacy.{source_hash}.json" + target = context["target_manifest"] + plan: dict[str, Any] = { + "schema_version": schema_version, + "plan_version": 1, + "action": "legacy-recovery-manifest-migration", + "created_at": _utc_now(now), + "release_sha": release, + "source_manifest": { + "path": str(paths["manifest"].resolve()), + "sha256": source_hash, + "boot_id": context["legacy"]["boot_id"], + "generated_at": context["legacy"]["generated_at"], + }, + "continuity_evidence": { + "path": str(evidence_path.resolve()), + "sha256": context["evidence_sha256"], + "captured_at": context["evidence_captured_at"], + "daemon_pid": context["generation"]["pid"], + "daemon_started_at": context["daemon_started_at"], + }, + "daemon_generation": context["generation"], + "current_roots": context["current_roots"], + "reconciliation": context["reconciliation"], + "target_manifest": target, + "target_manifest_sha256": sha256(json_bytes(target)), + "archive_path": str(archive.resolve()), + } + plan["plan_token"] = plan_token_fn(plan) + return plan + + +def publish_legacy_migration_plan( + config: Mapping[str, Any], + output: str | Path, + plan: Mapping[str, Any], + *, + schema_version: int, + json_bytes: Callable[[Any], bytes], + read_private_regular_bytes: Callable[[Path], bytes], + write_manifest_backup: Callable[[Path, bytes], None], +) -> dict[str, Any]: + """Durably create a reviewed plan as a private, non-overwriting state file.""" + paths = _state_paths(config) + destination = Path(output).expanduser() + if not destination.is_absolute(): + destination = Path.cwd() / destination + with StateLock(paths["root"], paths["lock"]): + if destination.parent.resolve() != paths["root"].resolve(): + raise CollectionError( + "migration plan output must be inside the owner-only state directory" + ) + if destination.exists() or destination.is_symlink(): + raise CollectionError( + f"migration plan output already exists: {destination}" + ) + content = json_bytes(plan) + write_manifest_backup(destination, content) + if read_private_regular_bytes(destination) != content: + raise CollectionError("durable migration plan verification failed") + dispositions = { + "carried": sum( + item.get("disposition") == "carried" + for item in plan.get("reconciliation", ()) + if isinstance(item, Mapping) + ), + "ended": sum( + item.get("disposition") == "ended" + for item in plan.get("reconciliation", ()) + if isinstance(item, Mapping) + ), + } + return { + "schema_version": schema_version, + "result": "planned", + "plan": str(destination), + "plan_token": plan.get("plan_token"), + "release_sha": plan.get("release_sha"), + "source_manifest_sha256": plan.get("source_manifest", {}).get("sha256"), + "target_manifest_sha256": plan.get("target_manifest_sha256"), + "reconciliation": dispositions, + } + + +def _validate_migration_plan( + plan: Mapping[str, Any], + paths: Mapping[str, Path], + release_sha: str, + *, + schema_version: int, + generation_key: Callable[[Mapping[str, Any]], Any], + json_bytes: Callable[[Any], bytes], + sha256: Callable[[bytes], str], + plan_token_fn: Callable[..., Any], + verify_release_sha: Callable[[Any], str], +) -> None: + expected_top_level = { + "schema_version", + "plan_version", + "action", + "created_at", + "release_sha", + "source_manifest", + "continuity_evidence", + "daemon_generation", + "current_roots", + "reconciliation", + "target_manifest", + "target_manifest_sha256", + "archive_path", + "plan_token", + } + if ( + set(plan) != expected_top_level + or plan.get("schema_version") != schema_version + or plan.get("plan_version") != 1 + or plan.get("action") != "legacy-recovery-manifest-migration" + or plan.get("release_sha") != verify_release_sha(release_sha) + or plan.get("plan_token") != plan_token_fn(plan) + or not isinstance(plan.get("source_manifest"), Mapping) + or not isinstance(plan.get("continuity_evidence"), Mapping) + or not isinstance(plan.get("target_manifest"), Mapping) + or not isinstance(plan.get("reconciliation"), list) + or not isinstance(plan.get("current_roots"), list) + or not isinstance(plan.get("daemon_generation"), Mapping) + ): + raise CollectionError( + "migration plan is invalid, altered, or for another release" + ) + source = plan["source_manifest"] + evidence = plan["continuity_evidence"] + target = plan["target_manifest"] + if ( + set(source) != {"path", "sha256", "boot_id", "generated_at"} + or set(evidence) + != {"path", "sha256", "captured_at", "daemon_pid", "daemon_started_at"} + or source.get("path") != str(paths["manifest"].resolve()) + or not re.fullmatch(r"[0-9a-f]{64}", str(source.get("sha256", ""))) + or not re.fullmatch(r"[0-9a-f]{64}", str(evidence.get("sha256", ""))) + or plan.get("archive_path") + != str( + ( + paths["root"] / f"recovery-manifest.legacy.{source.get('sha256')}.json" + ).resolve() + ) + or plan.get("target_manifest_sha256") != sha256(json_bytes(target)) + or generation_key(target) + != ( + source.get("boot_id"), + plan.get("daemon_generation", {}).get("pid"), + plan.get("daemon_generation", {}).get("process_start_ticks"), + ) + or evidence.get("daemon_pid") != plan.get("daemon_generation", {}).get("pid") + ): + raise CollectionError("migration plan paths or hashes are invalid") + + +def _receipt_path(paths: Mapping[str, Path]) -> Path: + return paths["root"] / "recovery-manifest-migration-receipt.json" + + +def _migration_receipt( + plan: Mapping[str, Any], + phase: str, + *, + now: float | None = None, + schema_version: int, +) -> dict[str, Any]: + return { + "schema_version": schema_version, + "action": "legacy-recovery-manifest-migration", + "phase": phase, + "updated_at": _utc_now(now), + "plan_token": plan["plan_token"], + "release_sha": plan["release_sha"], + "source_manifest_sha256": plan["source_manifest"]["sha256"], + "target_manifest_sha256": plan["target_manifest_sha256"], + "continuity_evidence_sha256": plan["continuity_evidence"]["sha256"], + "daemon_generation": plan["daemon_generation"], + "reconciliation": plan["reconciliation"], + "archive_path": plan["archive_path"], + } + + +def _read_matching_receipt( + path: Path, + plan: Mapping[str, Any], + *, + required: bool, + schema_version: int, + parse_private_json: Callable[..., tuple[bytes, Mapping[str, Any]]], + parse_utc_timestamp: Callable[..., Any], +) -> Mapping[str, Any] | None: + if not path.exists() and not path.is_symlink(): + if required: + raise CollectionError("migration receipt is missing") + return None + _, receipt = parse_private_json(path, "migration receipt") + expected_fields = { + "schema_version", + "action", + "phase", + "updated_at", + "plan_token", + "release_sha", + "source_manifest_sha256", + "target_manifest_sha256", + "continuity_evidence_sha256", + "daemon_generation", + "reconciliation", + "archive_path", + } + parse_utc_timestamp(receipt.get("updated_at"), "migration receipt updated_at") + if ( + set(receipt) != expected_fields + or receipt.get("schema_version") != schema_version + or receipt.get("action") != "legacy-recovery-manifest-migration" + or receipt.get("plan_token") != plan.get("plan_token") + or receipt.get("release_sha") != plan.get("release_sha") + or receipt.get("source_manifest_sha256") != plan["source_manifest"]["sha256"] + or receipt.get("target_manifest_sha256") != plan.get("target_manifest_sha256") + or receipt.get("continuity_evidence_sha256") + != plan["continuity_evidence"]["sha256"] + or receipt.get("daemon_generation") != plan.get("daemon_generation") + or receipt.get("reconciliation") != plan.get("reconciliation") + or receipt.get("archive_path") != plan.get("archive_path") + or receipt.get("phase") not in {"archive-published", "applied", "rolled-back"} + ): + raise CollectionError("migration receipt does not match the reviewed plan") + return receipt + + +def _revalidate_plan_context( + config: Mapping[str, Any], + plan: Mapping[str, Any], + legacy_bytes: bytes, + *, + collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None, + proc_root: Path | None, + json_bytes: Callable[[Any], bytes], + sha256: Callable[[bytes], str], + migration_context_fn: Callable[..., Any], +) -> None: + evidence_path = Path(str(plan["continuity_evidence"]["path"])) + context = migration_context_fn( + config, + legacy_bytes=legacy_bytes, + evidence_path=evidence_path, + collector=collector, + proc_root=proc_root, + ) + semantic_target = dict(context["target_manifest"]) + semantic_target["generated_at"] = plan["target_manifest"].get("generated_at") + if ( + context["legacy_sha256"] != plan["source_manifest"]["sha256"] + or context["evidence_sha256"] != plan["continuity_evidence"]["sha256"] + or context["generation"] != plan["daemon_generation"] + or context["current_roots"] != plan["current_roots"] + or context["reconciliation"] != plan["reconciliation"] + or semantic_target != plan["target_manifest"] + or sha256(json_bytes(plan["target_manifest"])) != plan["target_manifest_sha256"] + ): + raise CollectionError( + "live state no longer matches the reviewed migration plan" + ) + + +def apply_legacy_recovery_manifest( + config: Mapping[str, Any], + plan_path: str | Path, + release_sha: str, + *, + collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, + proc_root: Path | None = None, + schema_version: int, + parse_private_json: Callable[..., tuple[bytes, Mapping[str, Any]]], + read_private_regular_bytes: Callable[[Path], bytes], + sha256: Callable[[bytes], str], + write_manifest_backup: Callable[[Path, bytes], None], + atomic_write_json: Callable[[Path, Any], None], + migration_receipt_fn: Callable[..., Any], + read_matching_receipt_fn: Callable[..., Any], + revalidate_plan_context_fn: Callable[..., Any], + validate_migration_plan_fn: Callable[..., Any], +) -> dict[str, Any]: + """Apply or safely resume one reviewed legacy migration plan.""" + paths = _state_paths(config) + _, plan = parse_private_json(Path(plan_path).expanduser(), "migration plan") + validate_migration_plan_fn(plan, paths, release_sha) + source_hash = plan["source_manifest"]["sha256"] + target_hash = plan["target_manifest_sha256"] + archive = Path(str(plan["archive_path"])) + receipt_path = _receipt_path(paths) + with StateLock(paths["root"], paths["lock"]): + if paths["pending"].exists() or paths["pending"].is_symlink(): + raise CollectionError( + "legacy recovery migration refuses an existing pending queue" + ) + canonical = read_private_regular_bytes(paths["manifest"]) + canonical_hash = sha256(canonical) + receipt = read_matching_receipt_fn( + receipt_path, plan, required=canonical_hash == target_hash + ) + if receipt and receipt.get("phase") == "rolled-back": + raise CollectionError("reviewed migration plan was already rolled back") + if canonical_hash not in {source_hash, target_hash}: + raise CollectionError( + "recovery manifest no longer matches plan source or target" + ) + if archive.exists() or archive.is_symlink(): + legacy_bytes = read_private_regular_bytes(archive) + if sha256(legacy_bytes) != source_hash: + raise CollectionError( + "legacy archive does not match the reviewed source" + ) + else: + if canonical_hash != source_hash: + raise CollectionError( + "legacy archive is missing after target publication" + ) + legacy_bytes = canonical + if canonical_hash == target_hash: + if receipt is None: + raise CollectionError( + "target manifest has no matching migration receipt" + ) + if receipt.get("phase") != "applied": + atomic_write_json(receipt_path, migration_receipt_fn(plan, "applied")) + mutation_sequence, _diagnostic = _state_io.allocate_collection_start(paths) + _state_io.stamp_collection_document( + paths, + paths["manifest"], + collection_start=mutation_sequence, + ) + return { + "schema_version": schema_version, + "result": "already-applied", + "plan_token": plan["plan_token"], + "receipt": str(receipt_path), + } + revalidate_plan_context_fn( + config, + plan, + legacy_bytes, + collector=collector, + proc_root=proc_root, + ) + if not archive.exists(): + write_manifest_backup(archive, canonical) + if sha256(read_private_regular_bytes(archive)) != source_hash: + raise CollectionError("durable legacy archive verification failed") + atomic_write_json(receipt_path, migration_receipt_fn(plan, "archive-published")) + if sha256(read_private_regular_bytes(paths["manifest"])) != source_hash: + raise CollectionError( + "legacy manifest changed before migration publication" + ) + mutation_sequence, _diagnostic = _state_io.allocate_collection_start(paths) + atomic_write_json(paths["manifest"], plan["target_manifest"]) + _state_io.stamp_collection_document( + paths, + paths["manifest"], + collection_start=mutation_sequence, + ) + if sha256(read_private_regular_bytes(paths["manifest"])) != target_hash: + raise CollectionError("target recovery manifest verification failed") + atomic_write_json(receipt_path, migration_receipt_fn(plan, "applied")) + return { + "schema_version": schema_version, + "result": "applied", + "plan_token": plan["plan_token"], + "archive": str(archive), + "receipt": str(receipt_path), + } + + +def rollback_legacy_recovery_manifest( + config: Mapping[str, Any], + plan_path: str | Path, + release_sha: str, + *, + collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, + proc_root: Path | None = None, + schema_version: int, + atomic_write_private_bytes: Callable[[Path, bytes], None], + parse_private_json: Callable[..., tuple[bytes, Mapping[str, Any]]], + read_private_regular_bytes: Callable[[Path], bytes], + sha256: Callable[[bytes], str], + atomic_write_json: Callable[[Path, Any], None], + migration_receipt_fn: Callable[..., Any], + read_matching_receipt_fn: Callable[..., Any], + revalidate_plan_context_fn: Callable[..., Any], + validate_migration_plan_fn: Callable[..., Any], +) -> dict[str, Any]: + """Restore exact legacy bytes while every plan and generation guard holds.""" + paths = _state_paths(config) + _, plan = parse_private_json(Path(plan_path).expanduser(), "migration plan") + validate_migration_plan_fn(plan, paths, release_sha) + source_hash = plan["source_manifest"]["sha256"] + target_hash = plan["target_manifest_sha256"] + archive = Path(str(plan["archive_path"])) + receipt_path = _receipt_path(paths) + with StateLock(paths["root"], paths["lock"]): + if paths["pending"].exists() or paths["pending"].is_symlink(): + raise CollectionError( + "legacy migration rollback refuses an existing pending queue" + ) + receipt = read_matching_receipt_fn(receipt_path, plan, required=True) + assert receipt is not None + legacy_bytes = read_private_regular_bytes(archive) + if sha256(legacy_bytes) != source_hash: + raise CollectionError("legacy archive does not match the reviewed source") + canonical = read_private_regular_bytes(paths["manifest"]) + canonical_hash = sha256(canonical) + if canonical_hash not in {source_hash, target_hash}: + raise CollectionError("rollback refuses a changed recovery manifest") + revalidate_plan_context_fn( + config, + plan, + legacy_bytes, + collector=collector, + proc_root=proc_root, + ) + if canonical_hash == source_hash: + if receipt.get("phase") != "rolled-back": + atomic_write_json( + receipt_path, migration_receipt_fn(plan, "rolled-back") + ) + mutation_sequence, _diagnostic = _state_io.allocate_collection_start(paths) + _state_io.stamp_collection_document( + paths, + paths["manifest"], + collection_start=mutation_sequence, + ) + return { + "schema_version": schema_version, + "result": "already-rolled-back", + "plan_token": plan["plan_token"], + "receipt": str(receipt_path), + } + mutation_sequence, _diagnostic = _state_io.allocate_collection_start(paths) + atomic_write_private_bytes(paths["manifest"], legacy_bytes) + _state_io.stamp_collection_document( + paths, + paths["manifest"], + collection_start=mutation_sequence, + ) + if sha256(read_private_regular_bytes(paths["manifest"])) != source_hash: + raise CollectionError("exact legacy rollback verification failed") + atomic_write_json(receipt_path, migration_receipt_fn(plan, "rolled-back")) + return { + "schema_version": schema_version, + "result": "rolled-back", + "plan_token": plan["plan_token"], + "receipt": str(receipt_path), + } diff --git a/lib/sessionkit_inventory/model.py b/lib/sessionkit_inventory/model.py new file mode 100644 index 0000000..58feedf --- /dev/null +++ b/lib/sessionkit_inventory/model.py @@ -0,0 +1,536 @@ +"""Session record shaping: identity, recovery, titles, and the base row. + +Pure composition only. Nothing here reads the process table, the filesystem, +or provider state; callers pass in whatever evidence they already collected. +""" + +from __future__ import annotations + +import datetime as dt +import os +from pathlib import Path +import re +import shlex +from typing import Any, Callable, Mapping, Sequence + +from .common import ( + PROVIDERS, + automatic_naming_enabled, + clean_text, + _pending_native_title_matches, + display_shpool_id, + stall_threshold_seconds, + shpool_id_mutation_policy, + valid_uuid, +) +from . import labels + + +_PICKER_PROVIDER_ORDER = {"claude": 0, "codex": 1, "shell": 2, "unknown": 3} +_PICKER_AVAILABILITY_ORDER = {"ready": 0, "attached": 1} +_PICKER_STATE_ORDER = { + "question": 0, + "needs you": 1, + "pending": 2, + "working": 2, + "idle": 3, +} + +# A shell beneath the managed session shell is work in its own right. Keep +# this vocabulary shared with ``_shell_title`` so the detail inventory does +# not grow a second, conflicting idea of which process names are shells. +SHELL_PROCESS_NAMES = frozenset({"bash", "zsh", "fish", "script"}) + + +def session_is_unavailable(row: Mapping[str, Any]) -> bool: + """Whether a managed record is quarantined from every board action. + + Only a PROVEN missing shell quarantines. An unprovable generation is the + opposite case, the kit cannot tell, and a session it cannot judge must + stay on the board where a person can: two review lanes demonstrated a + real, actionable human session disappearing from the shell and curses + lists when this set also matched ``unprovable-generation``. Uncertainty + surfaces; it never hides. + """ + number = row.get("terminal_number") + return ( + (isinstance(number, bool) or not isinstance(number, int) or number <= 0) + and row.get("mutation_allowed") is False + and row.get("mutation_rejection_reason") == "missing-shell-generation" + ) + + +def canonical_session_order_key(row: Mapping[str, Any]) -> tuple[Any, ...]: + """The daily picker order shared by every session-list surface. + + Ready sessions precede sessions open elsewhere. Within each location, + attention comes first, then provider, newest recorded activity, and the + stable natural session ID. The final selector/key fallback makes malformed + or old snapshots deterministic without changing a valid session's place. + """ + + provider = str(row.get("display_provider") or row.get("provider") or "").casefold() + identifier = row.get("shpool_id_raw") or row.get("shpool_id") or "" + natural_id = tuple( + int(part) if part.isdigit() else part.casefold() + for part in re.split(r"([0-9]+)", str(identifier)) + ) + recent = row.get("recent_output_at_unix_ms") + if isinstance(recent, bool) or not isinstance(recent, int): + recent = None + selector = row.get("terminal_number") + if isinstance(selector, bool) or not isinstance(selector, int): + selector = 10**9 + # Derive from collector evidence every time. Cached/published ``state`` is + # display output and can lag a row mutation in tests or compatibility + # overlays; it must never outrank the evidence which creates it. + state = labels.session_state(row, stall_seconds=stall_threshold_seconds()) + return ( + _PICKER_AVAILABILITY_ORDER.get( + str(row.get("availability") or "").casefold(), 9 + ), + _PICKER_STATE_ORDER.get(str(state), 9), + _PICKER_PROVIDER_ORDER.get(provider, 9), + recent is None, + -(recent or 0), + natural_id, + selector, + str(identifier), + ) + + +def recovery_spec(provider: str, uuid: str, cwd: str | None = None) -> dict[str, Any]: + exact_uuid = valid_uuid(uuid) + if provider not in PROVIDERS or not exact_uuid: + raise ValueError("recovery requires provider claude|codex and an exact UUID") + argv = ( + ["claude", "--resume", exact_uuid] + if provider == "claude" + else ["codex", "--no-alt-screen", "resume", exact_uuid] + ) + clean_cwd = clean_text(cwd, 4096) if cwd else None + command = shlex.join(argv) + if clean_cwd: + command = f"cd -- {shlex.quote(clean_cwd)} && {command}" + return { + "available": True, + "provider": provider, + "uuid": exact_uuid, + "cwd": clean_cwd, + "argv": argv, + "command": command, + } + + +def _empty_recovery() -> dict[str, Any]: + return { + "available": False, + "provider": None, + "uuid": None, + "cwd": None, + "argv": [], + "command": None, + } + + +_INACTIVE_SUBAGENT_STATES = frozenset( + { + "idle", + "complete", + "completed", + "closed", + "stopped", + "failed", + "cancelled", + "canceled", + } +) + + +def classify_top_level_sessions( + rows: Sequence[Mapping[str, Any]], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: + """Return human roots, expandable machine roots, and hidden child orphans. + + A provider child can own a managed shell of its own. That shell is still + part of the parent conversation, not another top-level conversation. The + collector records the exact provider/UUID parent edge in ``parent_session``; + this projection folds such a row into the highest present root and keeps an + orphan behind the machine count. Callers receive copies so rendering never + mutates the collected snapshot. + """ + + copied = [dict(row) for row in rows if isinstance(row, Mapping)] + + def identity_key(row: Mapping[str, Any]) -> tuple[str, str] | None: + identity = row.get("identity") + uuid = identity.get("uuid") if isinstance(identity, Mapping) else None + provider = str(row.get("provider") or "").casefold() + if provider in {"claude", "codex"} and isinstance(uuid, str) and uuid: + return provider, uuid.casefold() + return None + + def parent_key(row: Mapping[str, Any]) -> tuple[str, str] | None: + parent = row.get("parent_session") + if not isinstance(parent, Mapping): + return None + provider = str(parent.get("provider") or "").casefold() + uuid = parent.get("uuid") + if provider in {"claude", "codex"} and isinstance(uuid, str) and uuid: + return provider, uuid.casefold() + return None + + by_identity = {key: row for row in copied if (key := identity_key(row)) is not None} + + def root_for(row: Mapping[str, Any]) -> dict[str, Any] | None: + wanted = parent_key(row) + seen: set[tuple[str, str]] = set() + while wanted is not None and wanted not in seen: + seen.add(wanted) + candidate = by_identity.get(wanted) + if candidate is None: + return None + next_parent = parent_key(candidate) + if next_parent is None: + return candidate + wanted = next_parent + return None + + orphans: list[dict[str, Any]] = [] + roots: list[dict[str, Any]] = [] + for row in copied: + if row.get("is_subagent") is not True and parent_key(row) is None: + roots.append(row) + continue + parent = root_for(row) + if parent is None: + orphans.append(row) + continue + nested = parent.get("subagents") + summaries = ( + [dict(item) for item in nested if isinstance(item, Mapping)] + if isinstance(nested, Sequence) and not isinstance(nested, (str, bytes)) + else [] + ) + identity = row.get("identity") + uuid = identity.get("uuid") if isinstance(identity, Mapping) else None + pid = identity.get("pid") if isinstance(identity, Mapping) else None + + def same_child(item: Mapping[str, Any]) -> bool: + return bool( + (uuid and item.get("uuid") == uuid) + or (isinstance(pid, int) and item.get("pid") == pid) + ) + + if not any(same_child(item) for item in summaries): + summaries.append( + { + "provider": row.get("provider"), + "uuid": uuid, + "pid": pid, + "title": row.get("display_title") or row.get("title") or "subagent", + "status": row.get("agent_status") or "unknown", + } + ) + parent["subagents"] = summaries + parent["_grouped_subagent_count"] = ( + int(parent.get("_grouped_subagent_count") or 0) + 1 + ) + parent["active_subagent_count"] = sum( + 1 + for item in summaries + if str(item.get("status") or "").casefold() not in _INACTIVE_SUBAGENT_STATES + ) + if row.get("needs_you") is True: + parent["needs_you"] = True + + machines = [row for row in roots if row.get("origin") == "machine"] + people = [row for row in roots if row.get("origin") != "machine"] + return people, machines, orphans + + +def _agent_identity( + *, + provider: str, + uuid: str | None, + pid: int | None, + process_table: Mapping[int, Mapping[str, Any]], + provenance: str, + confidence: str, +) -> dict[str, Any]: + process = process_table.get(pid or -1, {}) + return { + "uuid": uuid, + "pid": pid, + "process_start_ticks": process.get("start_ticks"), + "provenance": provenance, + "confidence": confidence, + } + + +def _context_title( + provider: str, + cwd: str, + started_at_unix_ms: int | None, + *, + home: Callable[[], Path], +) -> str: + """Return a deterministic project hint without exposing a full path.""" + if ( + isinstance(started_at_unix_ms, bool) + or not isinstance(started_at_unix_ms, int) + or started_at_unix_ms <= 0 + ): + return "" + try: + local_start = dt.datetime.fromtimestamp( + started_at_unix_ms / 1000, tz=dt.timezone.utc + ).astimezone() + except (OverflowError, OSError, ValueError): + return "" + timestamp = local_start.strftime("%b %d %H:%M") + clean_cwd = clean_text(cwd, 4096) + if not clean_cwd or not clean_cwd.startswith("/"): + return f"{provider.title()} started {timestamp}" + parts = [ + clean_text(part, 40) + for part in Path(clean_cwd).parts + if part not in {"", os.sep} + ] + generic = { + "app", + "apps", + "code", + "current", + "home", + "repo", + "repos", + "src", + "srv", + "v2", + "var", + "workspace", + "workspaces", + } + home_name = clean_text(home().name, 40).casefold() + for part in reversed(parts): + folded = part.casefold() + if ( + part + and not part.startswith(".") + and folded not in generic + and folded != home_name + ): + return f"{provider.title()} in {part} at {timestamp}" + return f"{provider.title()} started {timestamp}" + + +def _provider_title_info( + provider: str, + uuid: str | None, + native_title: str, + aliases: Mapping[str, str], + cwd: str = "", + started_at_unix_ms: int | None = None, + automatic_titles: Mapping[str, str] | None = None, + *, + provider_title_is_explicit: bool = True, + native_name_source: str = "", + native_name_since: Any = None, + context_title: Callable[[str, str, int | None], str], + pushed_titles: Mapping[str, str] | None = None, + pending_native_titles: Mapping[str, Mapping[str, Any]] | None = None, + name_ownership: Mapping[str, Mapping[str, str]] | None = None, +) -> tuple[str, str]: + key = f"{provider}:{uuid}" if uuid else "" + native_is_explicit = provider_title_is_explicit and not ( + provider == "claude" and native_name_source == "derived" + ) + if ( + provider == "claude" + and key + and native_title + and _pending_native_title_matches( + (pending_native_titles or {}).get(key), + native_title, + native_name_since, + native_name_source, + ) + and ((pushed_titles or {}).get(key) or (automatic_titles or {}).get(key)) + ): + native_is_explicit = False + if uuid: + alias = aliases.get(key) + # A provider-native rename outranks the alias tier. The alias layer + # holds kit-authored names as well as typed ones, so an automatic + # title that was also written as an alias would otherwise mask the + # name a person just typed into /rename, which is the newest thing + # anyone has said about what this room is called. Reconciliation then + # promotes it into the alias for good; this keeps the row honest in + # the meantime. + last_kit_title = (pushed_titles or {}).get(key) or (automatic_titles or {}).get( + key, "" + ) + # Divergence is the timestamp once provider placeholders and the one + # recorded pre-push value have been excluded above. `last_kit_title` + # is the last value the kit wrote into the provider store, including + # `sp name`; any third explicit value is therefore a newer human act. + if ( + last_kit_title + and native_title + and native_is_explicit + and native_title != last_kit_title + ): + return native_title, "native" + if alias: + return alias, "alias" + if native_title and native_is_explicit: + return native_title, "native" + if uuid and automatic_naming_enabled(): + automatic = (automatic_titles or {}).get(key) + if automatic: + return automatic, "automatic" + context = context_title(provider, cwd, started_at_unix_ms) + if context: + return context, "context" + # Nothing named this session and its context yielded nothing either. The + # provider alone is a poor label but an honest one; a UUID prefix here + # used to put a raw identifier on every human surface at once. + return provider.title(), "provider" + + +def _provider_title( + provider: str, + uuid: str | None, + native_title: str, + aliases: Mapping[str, str], + cwd: str = "", + started_at_unix_ms: int | None = None, + automatic_titles: Mapping[str, str] | None = None, + *, + provider_title_is_explicit: bool = True, + native_name_source: str = "", + native_name_since: Any = None, + provider_title_info: Callable[..., tuple[str, str]], + pushed_titles: Mapping[str, str] | None = None, + pending_native_titles: Mapping[str, Mapping[str, Any]] | None = None, + name_ownership: Mapping[str, Mapping[str, str]] | None = None, +) -> str: + return provider_title_info( + provider, + uuid, + native_title, + aliases, + cwd, + started_at_unix_ms, + automatic_titles, + provider_title_is_explicit=provider_title_is_explicit, + native_name_source=native_name_source, + native_name_since=native_name_since, + pushed_titles=pushed_titles, + pending_native_titles=pending_native_titles, + name_ownership=name_ownership, + )[0] + + +def _shell_title( + tree: Sequence[int], + root_pid: int, + process_table: Mapping[int, Mapping[str, Any]], + ignored: frozenset[int] = frozenset(), +) -> tuple[str, str, int | None]: + """Name what the shell is running, never the inventory that is asking. + + ``ignored`` is the census's own process tree: its chain of ancestors and + everything it spawned. A collection verb run inside a managed shell is a + descendant of the shell it is titling, the provider-exit menu's reopen is + exactly that call, so counting it made an idle terminal read as busy, + the Idle-shell overlay never applied, and provider-exit recovery could + never pass its gate. + """ + for pid in tree: + if pid == root_pid or pid in ignored: + continue + process = process_table.get(pid, {}) + comm = clean_text(process.get("comm"), 80) + if comm and comm not in SHELL_PROCESS_NAMES: + return comm, clean_text(process.get("cwd"), 4096), pid + root = process_table.get(root_pid, {}) + return "Idle shell", clean_text(root.get("cwd"), 4096), root_pid + + +def _base_agent( + *, + row: int | None, + shpool_id: str | None, + shpool_status: str | None, + availability: str | None, + provider: str, + identity: dict[str, Any], + title: str, + title_source: str, + native_title: str, + cwd: str, + started_at_unix_ms: int | None, + process_age_seconds: int | None, + agent_status: str, + needs_you: bool, + subagents: list[dict[str, Any]], + recovery: dict[str, Any], + diagnostics: list[str], + shpool_shell: dict[str, Any] | None, +) -> dict[str, Any]: + mutation_allowed, mutation_reason = ( + shpool_id_mutation_policy(shpool_id) + if shpool_id is not None + else (False, "outside-shpool") + ) + safe_subagents = [dict(child) for child in subagents if isinstance(child, Mapping)] + return { + "row": row, + "terminal_number": None, + "shpool_id": shpool_id, + "shpool_id_raw": shpool_id, + "display_shpool_id": ( + display_shpool_id(shpool_id) if shpool_id is not None else None + ), + "mutation_allowed": mutation_allowed, + "mutation_rejection_reason": mutation_reason, + "shpool_status": shpool_status, + "availability": availability, + "provider": provider, + "display_provider": provider, + "setup_incomplete": False, + "identity": identity, + "title": clean_text(title, 120), + "title_source": clean_text(title_source, 20), + "display_title": clean_text(title, 120), + "native_title": clean_text(native_title, 120), + "cwd": clean_text(cwd, 4096), + "started_at_unix_ms": started_at_unix_ms, + "process_age_seconds": process_age_seconds, + "agent_status": clean_text(agent_status, 60) or "unknown", + "needs_you": bool(needs_you), + "subagents": safe_subagents, + # Detail-only process evidence. It never contributes to state or the + # active-subagent count. + "aged_children": [], + "active_subagent_count": sum( + 1 + for child in safe_subagents + if str(child.get("status") or "").casefold() + not in { + "idle", + "complete", + "completed", + "closed", + "stopped", + "failed", + "cancelled", + "canceled", + } + ), + "recovery": recovery, + "diagnostics": diagnostics, + "shpool_shell": shpool_shell, + } diff --git a/lib/sessionkit_inventory/names.py b/lib/sessionkit_inventory/names.py new file mode 100644 index 0000000..0b001a3 --- /dev/null +++ b/lib/sessionkit_inventory/names.py @@ -0,0 +1,1311 @@ +"""Name intent and title propagation policy. + +What a session should be called, and what happens when telling a provider fails: +the canonical alias and automatic-title document, the audit and prune passes, +title and colour propagation with their retry and reconcile bookkeeping, and the +Claude native hydration pass. + +Provider-native writing lives in ``names_push``. The two halves need each other +in both directions, so neither imports the other, the facade injects across the +seam, which keeps the package graph acyclic. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import re +import sqlite3 +import stat as statmod +import sys +from typing import Any, Callable, Mapping, Sequence + +from .common import ( + PROVIDERS, + CollectionError, + _utc_now, + _valid_aliases, + _valid_automatic_title_failures, + _valid_automatic_titles, + _valid_name_ownership, + _valid_name_since, + _valid_pending_native_titles, + _valid_pushed_titles, + _pending_native_title_matches, + automatic_naming_enabled, + clean_text, + normalize_automatic_title, + valid_uuid, +) +from .providers_codex import _codex_state_databases +from .state_io import StateLock, _read_bounded_owner_file, _state_paths +from .transcripts import claude_roots + + +def _alias_document_from_bytes( + payload: bytes, + *, + label: str, + schema_version: int, +) -> dict[str, Any]: + try: + raw = json.loads(payload.decode("utf-8", "strict")) + except (UnicodeDecodeError, ValueError) as exc: + raise CollectionError(f"{label} is invalid JSON") from exc + if ( + not isinstance(raw, dict) + or raw.get("schema_version", schema_version) != schema_version + or ("aliases" in raw and not isinstance(raw["aliases"], Mapping)) + or ( + "automatic_titles" in raw + and not isinstance(raw["automatic_titles"], Mapping) + ) + or ( + "automatic_title_failures" in raw + and not isinstance(raw["automatic_title_failures"], Mapping) + ) + or ("name_ownership" in raw and not isinstance(raw["name_ownership"], Mapping)) + or ("pushed_titles" in raw and not isinstance(raw["pushed_titles"], Mapping)) + or ( + "pending_native_titles" in raw + and not isinstance(raw["pending_native_titles"], Mapping) + ) + ): + raise CollectionError(f"{label} has an invalid schema") + aliases = raw.get("aliases", {}) + normalized = _valid_aliases(aliases) + if len(normalized) != len(aliases): + raise CollectionError(f"{label} contains an invalid alias") + raw["schema_version"] = schema_version + raw["aliases"] = dict(sorted(normalized.items())) + if "automatic_titles" in raw: + titles = _valid_automatic_titles(raw["automatic_titles"]) + if len(titles) != len(raw["automatic_titles"]): + raise CollectionError(f"{label} contains an invalid automatic title") + raw["automatic_titles"] = dict(sorted(titles.items())) + if "automatic_title_failures" in raw: + failures = _valid_automatic_title_failures(raw["automatic_title_failures"]) + if len(failures) != len(raw["automatic_title_failures"]): + raise CollectionError( + f"{label} contains an invalid automatic title failure" + ) + raw["automatic_title_failures"] = dict(sorted(failures.items())) + if "name_ownership" in raw: + owners = _valid_name_ownership(raw["name_ownership"]) + if len(owners) != len(raw["name_ownership"]): + raise CollectionError(f"{label} contains an invalid name ownership record") + raw["name_ownership"] = dict(sorted(owners.items())) + if "pushed_titles" in raw: + pushed = _valid_pushed_titles(raw["pushed_titles"]) + if len(pushed) != len(raw["pushed_titles"]): + raise CollectionError(f"{label} contains an invalid pushed title") + raw["pushed_titles"] = dict(sorted(pushed.items())) + if "pending_native_titles" in raw: + pending = _valid_pending_native_titles(raw["pending_native_titles"]) + legacy_count = sum( + isinstance(value, str) for value in raw["pending_native_titles"].values() + ) + if len(pending) + legacy_count != len(raw["pending_native_titles"]): + raise CollectionError(f"{label} contains an invalid pending native title") + if pending: + raw["pending_native_titles"] = dict(sorted(pending.items())) + else: + raw.pop("pending_native_titles", None) + return raw + + +def last_kit_title(document: Mapping[str, Any], key: str) -> str: + """The last title the kit itself put into the provider's own store. + + The recorded push is the exact answer. Documents written before that + record existed fall back to the retained automatic title, which is the + value the kit would have pushed; that fallback is what lets a session + renamed natively before this release still be recognised. + """ + pushed = _valid_pushed_titles(document.get("pushed_titles")).get(key) + if pushed: + return pushed + return _valid_automatic_titles(document.get("automatic_titles")).get(key, "") + + +def native_rename( + document: Mapping[str, Any], + key: str, + native_title: str, + *, + native_name_source: str = "", + native_name_since: Any = None, +) -> str: + """The natively typed name for this thread, or "" if the kit wrote it. + + A native title equal to what the kit last pushed is the kit's own echo. + Claude's derived placeholder and the exact value observed immediately + before the newest push are provider state too. Any third value is a person + at a /rename prompt and is the newest statement of intent. + """ + title = clean_text(native_title, 120) + if native_name_source == "derived": + return "" + kit_title = last_kit_title(document, key) + # No kit value means the kit never named this thread, so a native title is + # not a correction of anything the kit did, it is just the name the + # thread has always had, and the ordinary precedence rules cover it. + if not kit_title or not title: + return "" + # Byte-exact, end to end. A title is an opaque string: changing only its + # capitals is still a person deciding what their work is called, and the + # kit is in no position to rule that it did not count. + if title == kit_title: + return "" + pending = _valid_pending_native_titles(document.get("pending_native_titles")).get( + key + ) + if key.startswith("claude:") and _pending_native_title_matches( + pending, title, native_name_since, native_name_source + ): + return "" + return title + + +def _name_owner(document: Mapping[str, Any], key: str) -> str: + """Say who owns one session's name: human, automatic, or nobody (""). + + Documents written before the ownership record exists still carry the + evidence: an alias is a human rename unless the automatic tier holds the + identical text, which only a self-name ever writes. + """ + recorded = _valid_name_ownership(document.get("name_ownership")).get(key) + if recorded: + return recorded["owner"] + aliases = _valid_aliases(document.get("aliases")) + if key not in aliases: + return "" + titles = _valid_automatic_titles(document.get("automatic_titles")) + return "automatic" if titles.get(key) == aliases[key] else "human" + + +def _record_name_ownership(document: dict[str, Any], key: str, owner: str) -> None: + """Stamp who owns a name, without ever re-stamping a human override. + + A human rename takes ownership from an automatic pass; nothing takes it + back. An automatic claim is one-shot: the first pass to name a thread + keeps the record, so a later pass finds the thread already owned. + """ + owners = _valid_name_ownership(document.get("name_ownership")) + existing = owners.get(key) + if existing and (owner == "automatic" or existing["owner"] == "human"): + return + owners[key] = {"owner": owner, "at": _utc_now()} + document["name_ownership"] = dict(sorted(owners.items())) + + +def _private_alias_document( + path: Path, + *, + allow_missing: bool, + max_private_json_bytes: int, + schema_version: int, + alias_document_from_bytes: Callable[..., dict[str, Any]], +) -> dict[str, Any]: + payload = _read_bounded_owner_file( + path, + label="canonical alias config", + max_bytes=max_private_json_bytes, + exact_mode=0o600, + allow_missing=allow_missing, + ) + if payload is None: + return {"schema_version": schema_version, "aliases": {}} + return alias_document_from_bytes(payload, label="canonical alias config") + + +def _private_alias_parent(path: Path) -> None: + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + metadata = path.parent.lstat() + if ( + not statmod.S_ISDIR(metadata.st_mode) + or statmod.S_ISLNK(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or statmod.S_IMODE(metadata.st_mode) != 0o700 + ): + raise CollectionError( + f"alias config directory must be mode-0700 current-owner: {path.parent}" + ) + + +def canonical_aliases( + config: Mapping[str, Any], + *, + config_path: Callable[[], Path], + private_alias_document: Callable[..., dict[str, Any]], +) -> dict[str, str]: + return dict(private_alias_document(config_path(), allow_missing=True)["aliases"]) + + +def mutate_canonical_alias( + config: Mapping[str, Any], + provider: str, + uuid: str, + title: str | None, + *, + atomic_write_json: Callable[[Path, Any], None], + config_path: Callable[[], Path], + private_alias_document: Callable[..., dict[str, Any]], + private_alias_parent: Callable[[Path], None], +) -> dict[str, str]: + exact_uuid = valid_uuid(uuid) + if provider not in PROVIDERS or not exact_uuid: + raise CollectionError("alias requires provider claude|codex and an exact UUID") + path = config_path() + paths = _state_paths(config) + # config.lock is the alias-write lock used by pinned schema-v1 releases. + # Keep it outermost so an old picker and this core cannot lose each + # other's config update during a mixed-release rollback window. + with StateLock(paths["root"], paths["config_lock"]): + with StateLock(paths["root"], paths["lock"]): + private_alias_parent(path) + document = private_alias_document(path, allow_missing=True) + aliases = dict(document["aliases"]) + key = f"{provider}:{exact_uuid}" + if title is None: + aliases.pop(key, None) + else: + clean_title = clean_text(title, 100) + if not clean_title: + raise CollectionError("alias title must contain visible text") + aliases[key] = clean_title + # This is the human rename surface, `sp name` and the picker + # reach the alias tier and nothing else does. Marking it here + # makes the override durable: `sp name reset` drops the alias + # but keeps the marker, so no automatic pass can resurrect a + # name over a session a person already named. + _record_name_ownership(document, key, "human") + document["aliases"] = dict(sorted(aliases.items())) + atomic_write_json(path, document) + return dict(document["aliases"]) + + +def canonical_automatic_titles( + config: Mapping[str, Any], + *, + config_path: Callable[[], Path], + private_alias_document: Callable[..., dict[str, Any]], +) -> dict[str, str]: + document = private_alias_document(config_path(), allow_missing=True) + return _valid_automatic_titles(document.get("automatic_titles")) + + +def canonical_name_ownership( + config: Mapping[str, Any], + *, + config_path: Callable[[], Path], + private_alias_document: Callable[..., dict[str, Any]], +) -> dict[str, dict[str, str]]: + """Read every recorded name owner, including inferred legacy renames.""" + document = private_alias_document(config_path(), allow_missing=True) + owners = _valid_name_ownership(document.get("name_ownership")) + for key in _valid_aliases(document.get("aliases")): + if key not in owners: + owners[key] = {"owner": _name_owner(document, key), "at": ""} + return dict(sorted(owners.items())) + + +def mutate_canonical_automatic_title( + config: Mapping[str, Any], + provider: str, + uuid: str, + title: str | None, + *, + overwrite: bool = False, + revalidate: Callable[[], None] | None = None, + schema_version: int, + atomic_write_json: Callable[[Path, Any], None], + config_path: Callable[[], Path], + private_alias_document: Callable[..., dict[str, Any]], + private_alias_parent: Callable[[Path], None], +) -> dict[str, Any]: + """Atomically set/reset one automatic title without crossing alias provenance.""" + exact_uuid = valid_uuid(uuid) + if provider not in PROVIDERS or not exact_uuid: + raise CollectionError( + "automatic title requires provider claude|codex and an exact UUID" + ) + if title is not None and not automatic_naming_enabled(): + raise CollectionError("automatic naming is disabled") + clean_title = normalize_automatic_title(title) if title is not None else None + path = config_path() + paths = _state_paths(config) + key = f"{provider}:{exact_uuid}" + with StateLock(paths["root"], paths["config_lock"]): + with StateLock(paths["root"], paths["lock"]): + if revalidate is not None: + revalidate() + private_alias_parent(path) + document = private_alias_document(path, allow_missing=True) + aliases = dict(document["aliases"]) + if clean_title is not None and key in aliases: + raise CollectionError("explicit local alias already owns this title") + if clean_title is not None and _name_owner(document, key) == "human": + raise CollectionError("a human name owns this session") + titles = _valid_automatic_titles(document.get("automatic_titles")) + failures = _valid_automatic_title_failures( + document.get("automatic_title_failures") + ) + existing = titles.get(key) + if clean_title is None: + titles.pop(key, None) + elif existing and existing != clean_title and not overwrite: + raise CollectionError("automatic title is already set") + else: + titles[key] = clean_title + failures.pop(key, None) + if clean_title is not None: + _record_name_ownership(document, key, "automatic") + document["automatic_titles"] = dict(sorted(titles.items())) + if failures: + document["automatic_title_failures"] = dict(sorted(failures.items())) + else: + document.pop("automatic_title_failures", None) + atomic_write_json(path, document) + verified = private_alias_document(path, allow_missing=False) + verified_titles = _valid_automatic_titles(verified.get("automatic_titles")) + if verified_titles.get(key) != clean_title: + if not (clean_title is None and key not in verified_titles): + raise CollectionError("automatic title atomic verification failed") + if clean_title is not None and key in verified["aliases"]: + raise CollectionError( + "explicit alias appeared during automatic title write" + ) + return { + "schema_version": schema_version, + "provider": provider, + "uuid": exact_uuid, + "title": clean_title, + "automatic_titles": verified_titles, + } + + +def record_pushed_title( + config: Mapping[str, Any], + provider: str, + uuid: str, + title: str, + *, + previous_native_title: str | None = None, + previous_native_name_since: Any = None, + previous_native_name_source: str = "", + atomic_write_json: Callable[[Path, Any], None], + config_path: Callable[[], Path], + private_alias_document: Callable[..., dict[str, Any]], + private_alias_parent: Callable[[Path], None], +) -> None: + """Remember exactly what the kit last wrote into a provider's own store.""" + exact_uuid = valid_uuid(uuid) + clean = clean_text(title, 120) + if provider not in PROVIDERS or not exact_uuid or not clean: + return + path = config_path() + paths = _state_paths(config) + key = f"{provider}:{exact_uuid}" + with StateLock(paths["root"], paths["config_lock"]): + with StateLock(paths["root"], paths["lock"]): + private_alias_parent(path) + document = private_alias_document(path, allow_missing=True) + pushed = _valid_pushed_titles(document.get("pushed_titles")) + already_pushed = pushed.get(key) == clean + pushed[key] = clean + document["pushed_titles"] = dict(sorted(pushed.items())) + if provider == "claude": + pending = _valid_pending_native_titles( + document.get("pending_native_titles") + ) + old_pending = pending.get(key) + previous = clean_text(previous_native_title, 120) + name_since = _valid_name_since(previous_native_name_since) + if previous and previous != clean and name_since is not None: + pending[key] = { + "title": previous, + "nameSince": name_since, + "nameSource": clean_text(previous_native_name_source, 20), + } + else: + pending.pop(key, None) + if already_pushed and old_pending == pending.get(key): + return + if pending: + document["pending_native_titles"] = dict(sorted(pending.items())) + else: + document.pop("pending_native_titles", None) + elif already_pushed: + return + atomic_write_json(path, document) + + +def adopt_native_rename( + config: Mapping[str, Any], + provider: str, + uuid: str, + native_title: str, + *, + native_name_source: str = "", + native_name_since: Any = None, + atomic_write_json: Callable[[Path, Any], None], + config_path: Callable[[], Path], + private_alias_document: Callable[..., dict[str, Any]], + private_alias_parent: Callable[[Path], None], +) -> str: + """Promote a provider-native rename to the permanent, human-owned name. + + Renaming a room with /rename is a person naming their work, and it is the + newest thing they have said about it. So it wins outright: it becomes the + alias, it takes human ownership, and the automatic title it replaced is + dropped rather than left behind to be restored by a later pass. + + This is also the migration. A session renamed natively before any of this + existed carries the same evidence, a native title that is not the + retained automatic one, so the first reconciliation after the release + settles it without anyone re-typing anything. + + Returns the adopted title, or "" when there was no native rename to adopt. + """ + exact_uuid = valid_uuid(uuid) + if provider not in PROVIDERS or not exact_uuid: + return "" + path = config_path() + paths = _state_paths(config) + key = f"{provider}:{exact_uuid}" + with StateLock(paths["root"], paths["config_lock"]): + with StateLock(paths["root"], paths["lock"]): + private_alias_parent(path) + document = private_alias_document(path, allow_missing=True) + pending = _valid_pending_native_titles( + document.get("pending_native_titles") + ) + # Seeing the pushed value in Claude's registry proves the lag is + # over. Retire the exception permanently before any later rename + # can happen to reuse the old text. + if ( + provider == "claude" + and key in pending + and clean_text(native_title, 120) == last_kit_title(document, key) + ): + pending.pop(key, None) + if pending: + document["pending_native_titles"] = dict(sorted(pending.items())) + else: + document.pop("pending_native_titles", None) + atomic_write_json(path, document) + return "" + adopted = native_rename( + document, + key, + native_title, + native_name_source=native_name_source, + native_name_since=native_name_since, + ) + if not adopted: + return "" + aliases = dict(document["aliases"]) + if ( + aliases.get(key) == adopted + and _name_owner(document, key) == "human" + and _valid_pushed_titles(document.get("pushed_titles")).get(key) + == adopted + ): + return "" + aliases[key] = adopted + document["aliases"] = dict(sorted(aliases.items())) + _record_name_ownership(document, key, "human") + titles = _valid_automatic_titles(document.get("automatic_titles")) + titles.pop(key, None) + document["automatic_titles"] = dict(sorted(titles.items())) + failures = _valid_automatic_title_failures( + document.get("automatic_title_failures") + ) + failures.pop(key, None) + if failures: + document["automatic_title_failures"] = dict(sorted(failures.items())) + else: + document.pop("automatic_title_failures", None) + # The adopted name is now what both stores hold, so the next pass + # sees agreement rather than detecting the same rename forever. + pushed = _valid_pushed_titles(document.get("pushed_titles")) + pushed[key] = adopted + document["pushed_titles"] = dict(sorted(pushed.items())) + pending = _valid_pending_native_titles( + document.get("pending_native_titles") + ) + pending.pop(key, None) + if pending: + document["pending_native_titles"] = dict(sorted(pending.items())) + else: + document.pop("pending_native_titles", None) + atomic_write_json(path, document) + return adopted + + +def claim_automatic_name( + config: Mapping[str, Any], + provider: str, + uuid: str, + *, + atomic_write_json: Callable[[Path, Any], None], + config_path: Callable[[], Path], + private_alias_document: Callable[..., dict[str, Any]], + private_alias_parent: Callable[[Path], None], +) -> str: + """Take the one-shot automatic claim on one session's name. + + Returns "" when the claim landed and the reason it did not otherwise. The + claim is what makes first-prompt ownership hold across restarts: the + record outlives the process that wrote it, so the pass that runs after a + reboot finds the thread already named instead of naming it again. + """ + exact_uuid = valid_uuid(uuid) + if provider not in PROVIDERS or not exact_uuid: + raise CollectionError( + "automatic name claim requires provider claude|codex and an exact UUID" + ) + path = config_path() + paths = _state_paths(config) + key = f"{provider}:{exact_uuid}" + with StateLock(paths["root"], paths["config_lock"]): + with StateLock(paths["root"], paths["lock"]): + private_alias_parent(path) + document = private_alias_document(path, allow_missing=True) + owner = _name_owner(document, key) + if owner: + return f"a {owner} name already owns this session" + _record_name_ownership(document, key, "automatic") + atomic_write_json(path, document) + verified = private_alias_document(path, allow_missing=False) + if _name_owner(verified, key) != "automatic": + raise CollectionError("automatic name claim verification failed") + return "" + + +def release_automatic_name_claim( + config: Mapping[str, Any], + provider: str, + uuid: str, + *, + atomic_write_json: Callable[[Path, Any], None], + config_path: Callable[[], Path], + private_alias_document: Callable[..., dict[str, Any]], + private_alias_parent: Callable[[Path], None], +) -> bool: + """Release an automatic claim that never produced a durable name.""" + exact_uuid = valid_uuid(uuid) + if provider not in PROVIDERS or not exact_uuid: + return False + path = config_path() + paths = _state_paths(config) + key = f"{provider}:{exact_uuid}" + with StateLock(paths["root"], paths["config_lock"]): + with StateLock(paths["root"], paths["lock"]): + private_alias_parent(path) + document = private_alias_document(path, allow_missing=True) + owners = _valid_name_ownership(document.get("name_ownership")) + if ( + owners.get(key, {}).get("owner") != "automatic" + or key in _valid_aliases(document.get("aliases")) + or key in _valid_automatic_titles(document.get("automatic_titles")) + ): + return False + owners.pop(key, None) + if owners: + document["name_ownership"] = dict(sorted(owners.items())) + else: + document.pop("name_ownership", None) + atomic_write_json(path, document) + verified = private_alias_document(path, allow_missing=False) + return not _name_owner(verified, key) + + +def mutate_canonical_self_name( + config: Mapping[str, Any], + provider: str, + uuid: str, + title: str, + *, + revalidate: Callable[[], None] | None = None, + schema_version: int, + atomic_write_json: Callable[[Path, Any], None], + config_path: Callable[[], Path], + private_alias_document: Callable[..., dict[str, Any]], + private_alias_parent: Callable[[Path], None], +) -> dict[str, Any]: + """Atomically set the paired alias and automatic title for a proved caller.""" + exact_uuid = valid_uuid(uuid) + if provider not in PROVIDERS or not exact_uuid: + raise CollectionError( + "self-name requires provider claude|codex and an exact UUID" + ) + if not automatic_naming_enabled(): + raise CollectionError("automatic naming is disabled") + clean_title = normalize_automatic_title(title) + path = config_path() + paths = _state_paths(config) + key = f"{provider}:{exact_uuid}" + with StateLock(paths["root"], paths["config_lock"]): + with StateLock(paths["root"], paths["lock"]): + if revalidate is not None: + revalidate() + private_alias_parent(path) + document = private_alias_document(path, allow_missing=True) + if _name_owner(document, key) == "human": + raise CollectionError("a human name owns this session") + aliases = dict(document["aliases"]) + titles = _valid_automatic_titles(document.get("automatic_titles")) + failures = _valid_automatic_title_failures( + document.get("automatic_title_failures") + ) + aliases[key] = clean_title + titles[key] = clean_title + failures.pop(key, None) + _record_name_ownership(document, key, "automatic") + document["aliases"] = dict(sorted(aliases.items())) + document["automatic_titles"] = dict(sorted(titles.items())) + if failures: + document["automatic_title_failures"] = dict(sorted(failures.items())) + else: + document.pop("automatic_title_failures", None) + atomic_write_json(path, document) + verified = private_alias_document(path, allow_missing=False) + verified_titles = _valid_automatic_titles(verified.get("automatic_titles")) + if verified["aliases"].get(key) != clean_title: + raise CollectionError("self-name alias atomic verification failed") + if verified_titles.get(key) != clean_title: + raise CollectionError( + "self-name automatic title atomic verification failed" + ) + return { + "schema_version": schema_version, + "provider": provider, + "uuid": exact_uuid, + "title": clean_title, + "aliases": dict(verified["aliases"]), + "automatic_titles": verified_titles, + } + + +def record_automatic_title_failure( + config: Mapping[str, Any], + provider: str, + uuid: str, + *, + revalidate: Callable[[], None] | None = None, + atomic_write_json: Callable[[Path, Any], None], + config_path: Callable[[], Path], + private_alias_document: Callable[..., dict[str, Any]], + private_alias_parent: Callable[[Path], None], +) -> int: + """Record at most two proved root-turn naming failures.""" + exact_uuid = valid_uuid(uuid) + if provider not in PROVIDERS or not exact_uuid: + raise CollectionError("automatic title failure requires an exact identity") + path = config_path() + paths = _state_paths(config) + key = f"{provider}:{exact_uuid}" + with StateLock(paths["root"], paths["config_lock"]): + with StateLock(paths["root"], paths["lock"]): + if revalidate is not None: + revalidate() + private_alias_parent(path) + document = private_alias_document(path, allow_missing=True) + if key in document["aliases"]: + return 0 + titles = _valid_automatic_titles(document.get("automatic_titles")) + if key in titles: + return 0 + failures = _valid_automatic_title_failures( + document.get("automatic_title_failures") + ) + failures[key] = min(2, failures.get(key, 0) + 1) + document["automatic_title_failures"] = dict(sorted(failures.items())) + atomic_write_json(path, document) + verified = private_alias_document(path, allow_missing=False) + attempts = _valid_automatic_title_failures( + verified.get("automatic_title_failures") + ).get(key) + if attempts != failures[key]: + raise CollectionError( + "automatic title failure atomic verification failed" + ) + return attempts + + +def _exact_active_title_keys(inventory: Mapping[str, Any]) -> set[str]: + keys: set[str] = set() + for item in [*inventory.get("sessions", ()), *inventory.get("outside_agents", ())]: + if not isinstance(item, Mapping): + continue + provider = item.get("provider") + identity = item.get("identity") + uuid = identity.get("uuid") if isinstance(identity, Mapping) else None + if ( + provider in PROVIDERS + and isinstance(identity, Mapping) + and identity.get("confidence") == "exact" + and valid_uuid(uuid) + ): + keys.add(f"{provider}:{valid_uuid(uuid)}") + return keys + + +def _automatic_title_prune_plan( + titles: Mapping[str, str], + active_keys: set[str], + *, + schema_version: int, +) -> tuple[list[str], str]: + orphans = sorted(set(titles) - active_keys) + evidence = json.dumps( + { + "schema_version": schema_version, + "automatic_titles": dict(sorted(titles.items())), + "active_keys": sorted(active_keys), + "orphans": orphans, + }, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return orphans, hashlib.sha256(evidence).hexdigest() + + +def audit_automatic_titles( + config: Mapping[str, Any], + inventory: Mapping[str, Any], + *, + schema_version: int, + automatic_title_prune_plan: Callable[..., Any], + exact_active_title_keys: Callable[[Mapping[str, Any]], set[str]], + canonical_automatic_titles_reader: Callable[..., dict[str, str]], +) -> dict[str, Any]: + titles = canonical_automatic_titles_reader(config) + orphans, token = automatic_title_prune_plan( + titles, exact_active_title_keys(inventory) + ) + return { + "schema_version": schema_version, + "automatic_title_count": len(titles), + "orphan_count": len(orphans), + "orphans": orphans, + "prune_token": token, + "dry_run": True, + } + + +def prune_automatic_titles( + config: Mapping[str, Any], + inventory: Mapping[str, Any], + prune_token: str, + *, + schema_version: int, + atomic_write_json: Callable[[Path, Any], None], + config_path: Callable[[], Path], + automatic_title_prune_plan: Callable[..., Any], + exact_active_title_keys: Callable[[Mapping[str, Any]], set[str]], + private_alias_document: Callable[..., dict[str, Any]], + private_alias_parent: Callable[[Path], None], + revalidate_inventory: Callable[[], Mapping[str, Any]] | None = None, +) -> dict[str, Any]: + """Apply only the exact orphan set previously exposed by a dry-run token.""" + path = config_path() + paths = _state_paths(config) + with StateLock(paths["root"], paths["config_lock"]): + with StateLock(paths["root"], paths["lock"]): + if revalidate_inventory is not None: + inventory = revalidate_inventory() + private_alias_parent(path) + document = private_alias_document(path, allow_missing=True) + titles = _valid_automatic_titles(document.get("automatic_titles")) + active_keys = exact_active_title_keys(inventory) + orphans, expected = automatic_title_prune_plan(titles, active_keys) + if ( + not re.fullmatch(r"[0-9a-f]{64}", prune_token) + or prune_token != expected + ): + raise CollectionError( + "automatic title prune token is stale or does not match dry run" + ) + for key in orphans: + titles.pop(key, None) + document["automatic_titles"] = dict(sorted(titles.items())) + failures = _valid_automatic_title_failures( + document.get("automatic_title_failures") + ) + owners = _valid_name_ownership(document.get("name_ownership")) + for key in orphans: + failures.pop(key, None) + # A spent automatic claim goes with the title it claimed. A + # human override never does: it has to outlive the pruning of + # a conversation that is merely not running right now. + if owners.get(key, {}).get("owner") == "automatic": + owners.pop(key, None) + if owners: + document["name_ownership"] = dict(sorted(owners.items())) + else: + document.pop("name_ownership", None) + if failures: + document["automatic_title_failures"] = dict(sorted(failures.items())) + else: + document.pop("automatic_title_failures", None) + atomic_write_json(path, document) + return { + "schema_version": schema_version, + "pruned": orphans, + "automatic_title_count": len(titles), + } + + +def propagate_provider_color( + provider: str, + uuid: str, + color: str, + *, + environ: Mapping[str, str] | None = None, + palette: Sequence[str], + push_claude_color: Callable[..., tuple[list[str], list[str]]], +) -> dict[str, Any]: + """One-shot push of a session color into provider-native storage. + + Codex has no native color surface; its rows carry the kit-side color only. + """ + exact_uuid = valid_uuid(uuid) + if provider not in PROVIDERS or not exact_uuid or color not in palette: + return { + "provider_color_pushes": [], + "provider_color_warnings": ["invalid provider color push request"], + } + if provider != "claude": + return {"provider_color_pushes": [], "provider_color_warnings": []} + env = environ if environ is not None else os.environ + # An explicit environment IS the caller's sandbox; falling back to the + # real home from inside one would leak pushes into live provider stores. + home_raw = env.get("HOME") or (os.fspath(Path.home()) if environ is None else "") + if not home_raw: + return { + "provider_color_pushes": [], + "provider_color_warnings": [ + "caller environment has no HOME; provider color not pushed" + ], + } + home = Path(home_raw) + pushed, warnings = push_claude_color(home, exact_uuid, color) + for warning in warnings: + print(f"session inventory: {warning}", file=sys.stderr) + return { + "provider_color_pushes": pushed, + "provider_color_warnings": warnings, + } + + +def propagate_provider_title( + provider: str, + uuid: str, + title: str, + *, + environ: Mapping[str, str] | None = None, + codex_home: Callable[..., Path], + claude_pre_push_title: Callable[..., Mapping[str, Any] | None], + push_claude_title: Callable[..., tuple[list[str], list[str]]], + push_codex_title: Callable[..., tuple[list[str], list[str]]], + session_kit_state_dir: Callable[[Mapping[str, str], Path], Path], + record_pushed: Callable[..., None], +) -> dict[str, Any]: + """One-shot push of an assigned name into the provider's own surfaces. + + Assignment semantics are last-writer-wins: the push happens once at + assignment time and later provider-side renames stand until the next + assignment. Every failure is fail-open, naming never breaks because a + provider surface is unavailable, but each skipped surface is reported. + """ + exact_uuid = valid_uuid(uuid) + clean_title = clean_text(title, 100) + if ( + provider not in PROVIDERS + or not exact_uuid + or not clean_title + # A placeholder identity must never be written into provider stores. + or set(exact_uuid.replace("-", "")) <= {"0"} + ): + return { + "provider_title_pushes": [], + "provider_title_warnings": ["invalid provider title push request"], + } + env = environ if environ is not None else os.environ + # An explicit environment IS the caller's sandbox; falling back to the + # real home from inside one would leak pushes into live provider stores. + home_raw = env.get("HOME") or (os.fspath(Path.home()) if environ is None else "") + if not home_raw: + return { + "provider_title_pushes": [], + "provider_title_warnings": [ + "caller environment has no HOME; provider title not pushed" + ], + } + home = Path(home_raw) + previous_native_title: str | None = None + previous_native_name_since: Any = None + previous_native_name_source = "" + if provider == "claude": + previous_native = claude_pre_push_title(home, exact_uuid, environ=env) + if previous_native: + previous_native_title = clean_text(previous_native.get("title"), 120) + previous_native_name_since = previous_native.get("nameSince") + previous_native_name_source = clean_text( + previous_native.get("nameSource"), 20 + ) + pushed, warnings = push_claude_title(home, exact_uuid, clean_title, environ=env) + else: + live_state_dir: Path | None = session_kit_state_dir(env, home) + if env.get("SESSION_KIT_CODEX_LIVE_RENAME") == "0": + live_state_dir = None + pushed, warnings = push_codex_title( + codex_home(env, default_home=home), + exact_uuid, + clean_title, + kit_state_dir=live_state_dir, + ) + for warning in warnings: + print(f"session inventory: {warning}", file=sys.stderr) + if pushed and not warnings: + # Only a push that landed becomes the kit's last-pushed value. A push + # that failed leaves the old title in the provider store, and calling + # that the kit's own value would read the kit's own stale name back as + # somebody's rename. + if provider == "claude": + record_pushed( + provider, + exact_uuid, + clean_title, + previous_native_title=previous_native_title, + previous_native_name_since=previous_native_name_since, + previous_native_name_source=previous_native_name_source, + environ=environ, + ) + else: + record_pushed(provider, exact_uuid, clean_title, environ=environ) + return { + "provider_title_pushes": pushed, + "provider_title_warnings": warnings, + } + + +def _record_provider_title_retry( + config: Mapping[str, Any], + provider: str, + uuid: str, + title: str, + *, + schema_version: int, + read_state_json: Callable[[Path], Any], + atomic_write_json: Callable[[Path, Any], None], +) -> None: + paths = _state_paths(config) + key = f"{provider}:{uuid}" + with StateLock(paths["root"], paths["lock"]): + raw = read_state_json(paths["provider_title_retries"]) + entries = dict(raw.get("entries", {})) if isinstance(raw, Mapping) else {} + entries[key] = { + "provider": provider, + "uuid": uuid, + "title": title, + "attempts": 0, + "updated_at": _utc_now(), + } + atomic_write_json( + paths["provider_title_retries"], + {"schema_version": schema_version, "entries": entries}, + ) + + +def _provider_title_retry_disposition( + provider: str, + uuid: str, + title: str, + environ: Mapping[str, str], + *, + codex_home: Callable[..., Path], + codex_title_echoes_prompt: Callable[[str, str], bool], + home_resolver: Callable[[], Path], + transcript_signals: Callable[..., dict[str, str]], + read_session_index: Callable[..., dict[str, str]], +) -> str: + """Return retry, satisfied, superseded, or defer from native evidence.""" + home_raw = environ.get("HOME") + if provider == "claude": + if not home_raw: + return "defer" + home = Path(home_raw) + # Every profile, not just the default root. An account session keeps + # its transcript under that account's profile, so this glob found + # nothing and answered "defer" on every pass: a title push that + # failed once was never retried for an account session, and its entry + # never left the retry ledger. + transcripts: list[Path] = [] + for root in claude_roots({**environ, "HOME": home_raw}): + try: + transcripts.extend( + path + for path in (root / "projects").glob(f"*/{uuid}.jsonl") + if path.is_file() and not path.is_symlink() + ) + except OSError: + continue + if not transcripts: + return "defer" + native = transcript_signals(uuid, home)["agent_name"] + if native == title: + return "satisfied" + if native: + return "superseded" + return "retry" + + codex_root = codex_home( + environ, default_home=Path(home_raw) if home_raw else home_resolver() + ) + try: + indexed = read_session_index(codex_root / "session_index.jsonl") + except CollectionError: + return "defer" + index_title = indexed.get(uuid, "") + if index_title and index_title != title: + return "superseded" + databases = _codex_state_databases(codex_root) + if not databases: + return "defer" + try: + connection = sqlite3.connect(f"file:{databases[-1]}?mode=ro", uri=True) + try: + columns = { + row[1] for row in connection.execute("PRAGMA table_info(threads)") + } + if "title" not in columns: + return "defer" + first_column = ( + "first_user_message" if "first_user_message" in columns else "NULL" + ) + row = connection.execute( + f"SELECT title, {first_column} FROM threads WHERE id = ?", (uuid,) + ).fetchone() + finally: + connection.close() + except sqlite3.Error: + return "defer" + if row is None: + return "defer" + native_title = clean_text(row[0], 120) + first_message = clean_text(row[1], 8192) + if native_title == title and index_title == title: + return "satisfied" + if ( + native_title + and native_title != title + and not codex_title_echoes_prompt(native_title, first_message) + ): + return "superseded" + return "retry" + + +def _reconcile_pending_provider_titles( + config: Mapping[str, Any], + provider: str, + environ: Mapping[str, str], + limit: int = 4, + *, + schema_version: int, + read_state_json: Callable[[Path], Any], + atomic_write_json: Callable[[Path, Any], None], + retry_disposition: Callable[..., Any], + propagate_title: Callable[..., dict[str, Any]], + human_named: Callable[[Mapping[str, str]], frozenset[str]], +) -> list[dict[str, Any]]: + """Retry a bounded number of exact failed self-name provider pushes.""" + paths = _state_paths(config) + with StateLock(paths["root"], paths["lock"]): + raw = read_state_json(paths["provider_title_retries"]) + entries = dict(raw.get("entries", {})) if isinstance(raw, Mapping) else {} + # A queued retry outlives the process that queued it. If a person renamed + # the session in the meantime, replaying the old automatic title would + # resurrect it over their name, after a restart, without them asking. + owned = human_named(environ) + candidates = [ + (key, item) + for key, item in sorted(entries.items()) + if isinstance(item, Mapping) + and item.get("provider") == provider + and valid_uuid(item.get("uuid")) + and clean_text(item.get("title"), 100) + and isinstance(item.get("attempts"), int) + and item.get("attempts", 0) < 3 + and f"{provider}:{valid_uuid(item.get('uuid'))}" not in owned + ][:limit] + results: list[dict[str, Any]] = [] + for key, item in candidates: + disposition = retry_disposition( + provider, str(item["uuid"]), str(item["title"]), environ + ) + if disposition in {"satisfied", "superseded"}: + with StateLock(paths["root"], paths["lock"]): + current = read_state_json(paths["provider_title_retries"]) + current_entries = ( + dict(current.get("entries", {})) + if isinstance(current, Mapping) + else {} + ) + stored = current_entries.get(key) + if isinstance(stored, Mapping) and stored.get("title") == item.get( + "title" + ): + current_entries.pop(key, None) + atomic_write_json( + paths["provider_title_retries"], + {"schema_version": schema_version, "entries": current_entries}, + ) + results.append( + { + "uuid": str(item["uuid"]), + "title": str(item["title"]), + "status": disposition, + } + ) + continue + if disposition == "defer": + results.append( + { + "uuid": str(item["uuid"]), + "title": str(item["title"]), + "status": "deferred", + } + ) + continue + result = propagate_title( + provider, + str(item["uuid"]), + str(item["title"]), + environ=environ, + ) + with StateLock(paths["root"], paths["lock"]): + current = read_state_json(paths["provider_title_retries"]) + current_entries = ( + dict(current.get("entries", {})) if isinstance(current, Mapping) else {} + ) + stored = current_entries.get(key) + if not isinstance(stored, Mapping) or stored.get("title") != item.get( + "title" + ): + continue + if result.get("provider_title_warnings"): + updated = dict(stored) + updated["attempts"] = min(3, int(stored.get("attempts", 0)) + 1) + updated["updated_at"] = _utc_now() + current_entries[key] = updated + else: + current_entries.pop(key, None) + atomic_write_json( + paths["provider_title_retries"], + {"schema_version": schema_version, "entries": current_entries}, + ) + results.append( + { + "uuid": str(item["uuid"]), + "title": str(item["title"]), + **result, + } + ) + return results + + +def claude_pending_native_hydrations( + config: dict[str, Any] | None = None, + environ: Mapping[str, str] | None = None, + *, + canonical_colors: Callable[[Mapping[str, Any]], dict[str, str]], + guard_inventory: Callable[[Mapping[str, Any]], bool], + load_config: Callable[[], dict[str, Any]], + transcript_signals: Callable[..., dict[str, str]], + session_color: Callable[..., str | None], + snapshot_inventory: Callable[..., dict[str, Any]], + propagate_color: Callable[..., dict[str, Any]], + propagate_title: Callable[..., dict[str, Any]], + reconcile_pending_titles: Callable[..., Any], + human_named: Callable[[Mapping[str, str]], frozenset[str]], + adopt_native: Callable[..., str], +) -> list[dict[str, Any]]: + """Fill absent visible records for exact managed Claude sessions. + + Claude's ai-title record names the conversation in inventory, but the + prompt bar and future resumes use agent-name. A missing native color has + the same next-start behavior. This bounded reconciliation never replaces + a provider-side rename or color. + """ + env = environ if environ is not None else os.environ + naming_enabled = automatic_naming_enabled(env) + settings = config or load_config() + hydrated: list[dict[str, Any]] = reconcile_pending_titles(settings, "claude", env) + try: + live = snapshot_inventory(write_state=False, config=settings) + except CollectionError: + return hydrated + if not guard_inventory(live): + return hydrated + home_raw = env.get("HOME") or (os.fspath(Path.home()) if environ is None else "") + if not home_raw: + return hydrated + home = Path(home_raw) + colors = canonical_colors(settings) + owned = human_named(env) + for item in live.get("sessions", ()): + if not isinstance(item, Mapping) or item.get("provider") != "claude": + continue + uuid = valid_uuid(str((item.get("identity") or {}).get("uuid") or "")) + if not uuid: + continue + pushes: list[str] = [] + warnings: list[str] = [] + # One session's file cannot cost every other session its colour and + # its name. This pass walks every live Claude session and its output + # is discarded by both callers (`bin/shpool_status`, `sp_core.sh`), + # so anything raised here used to end the pass in silence: from the + # first damaged transcript onward nobody got named or coloured and + # nothing anywhere said why. Each session is now its own blast + # radius, and the reason is carried out in the result. + try: + signals = transcript_signals(uuid, home) + # A /rename lands in agent-name, the same record the kit pushes + # to. Whatever is there now that the kit did not put there is a + # person's name for this conversation, and it wins for good. + adopted = adopt_native("claude", uuid, signals["agent_name"], environ=env) + if adopted: + hydrated.append({"uuid": uuid, "adopted_native_rename": adopted}) + continue + title = signals["ai_title"] + # A session someone renamed keeps that name on its prompt bar. The + # provider's own ai-title is the automatic tier and never outranks + # it. + if ( + naming_enabled + and f"claude:{uuid}" not in owned + and title + and not signals["agent_name"] + and item.get("native_title") == title + and item.get("provider_title_state") == "pending" + ): + result = propagate_title("claude", uuid, title, environ=env) + pushes.extend(result["provider_title_pushes"]) + warnings.extend(result["provider_title_warnings"]) + color = session_color("claude", uuid, colors) + if color and not signals["agent_color"]: + result = propagate_color("claude", uuid, color, environ=env) + pushes.extend(result["provider_color_pushes"]) + warnings.extend(result["provider_color_warnings"]) + except Exception as exc: # noqa: BLE001 - one session, not the pass + warnings.append(f"{type(exc).__name__}: {exc}") + if pushes or warnings: + hydrated.append({"uuid": uuid, "pushes": pushes, "warnings": warnings}) + return hydrated diff --git a/lib/sessionkit_inventory/names_push.py b/lib/sessionkit_inventory/names_push.py new file mode 100644 index 0000000..a29adb6 --- /dev/null +++ b/lib/sessionkit_inventory/names_push.py @@ -0,0 +1,1763 @@ +"""Provider-native name and colour writing. + +Everything here reaches into a provider's own storage: Claude's name-intent +file, transcript records and session records; Codex's session index, thread +store and live app-server sockets. Nothing here decides WHAT a session should +be called (that is ``names``), and nothing here reads inventory state. + +The two halves depend on each other in both directions (this module reconciles +pending titles, ``names`` dispatches pushes), so neither imports the other. The +facade injects across the seam, which is what keeps the package graph acyclic. +""" + +from __future__ import annotations + +import contextlib +import datetime as dt +import errno +import json +import os +from pathlib import Path +import re +import stat as statmod +import sys +import time +from typing import Any, Callable, Mapping, Protocol + +from .common import ( + _valid_name_since, + automatic_naming_enabled, + clean_text, + valid_uuid, +) +from .providers_codex import _codex_state_databases +from .transcripts import claude_roots + + +MAX_CLAUDE_SESSION_RECORDS = 512 + + +class LiveRename(Protocol): + def __call__( + self, + state_dir: Path, + uuid: str, + title: str, + /, + *, + timeout_seconds: float | None = None, + still_automatic: Callable[[], bool] | None = None, + ) -> tuple[list[str], list[str]]: ... + + +def _claude_sessions_directories( + home: Path, environ: Mapping[str, str] | None = None +) -> tuple[list[Path], list[str]]: + """Verified Claude registry directories which cannot escape a profile.""" + env = dict(environ or {}) + env["HOME"] = os.fspath(home) + found: list[Path] = [] + warnings: list[str] = [] + seen: set[str] = set() + account_root = Path( + env.get("SESSION_KIT_ACCOUNT_ROOT") + or home / ".local/share/session-kit/accounts" + ) + account_profiles = (account_root / "claude").absolute() + for root in claude_roots(env): + sessions = root / "sessions" + try: + # An enrolled profile name is a trust boundary, not a redirect. + # Refuse it before resolve() can erase the evidence that the + # account-profile component itself was a symlink. + if root.absolute().parent == account_profiles and root.is_symlink(): + warnings.append( + f"Claude sessions directory refused: {sessions}: " + "account profile is a symlink" + ) + continue + if not sessions.exists() and not sessions.is_symlink(): + continue + resolved_root = root.resolve(strict=True) + resolved_sessions = sessions.resolve(strict=True) + try: + resolved_sessions.relative_to(resolved_root) + except ValueError: + warnings.append( + f"Claude sessions directory refused: {sessions}: " + "resolved path escapes its profile root" + ) + continue + descriptor = os.open( + resolved_sessions, + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) + try: + if not statmod.S_ISDIR(os.fstat(descriptor).st_mode): + raise OSError("not a directory") + finally: + os.close(descriptor) + except (OSError, RuntimeError) as exc: + warnings.append(f"Claude sessions directory refused: {sessions}: {exc}") + continue + key = os.fspath(resolved_sessions) + if key in seen: + continue + seen.add(key) + found.append(resolved_sessions) + return found, warnings + + +def _open_sessions_directory(sessions: Path) -> int: + descriptor = os.open( + sessions, + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) + try: + is_directory = statmod.S_ISDIR(os.fstat(descriptor).st_mode) + except OSError: + os.close(descriptor) + raise + if not is_directory: + os.close(descriptor) + raise OSError("not a directory") + return descriptor + + +def _read_json_at(directory: int, name: str) -> dict[str, Any] | None: + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(name, flags, dir_fd=directory) + try: + if not statmod.S_ISREG(os.fstat(descriptor).st_mode): + return None + chunks: list[bytes] = [] + remaining = 1024 * 1024 + 1 + while remaining: + block = os.read(descriptor, min(remaining, 65536)) + if not block: + break + chunks.append(block) + remaining -= len(block) + payload = b"".join(chunks) + if len(payload) > 1024 * 1024: + return None + value = json.loads(payload.decode("utf-8", "strict")) + return value if isinstance(value, dict) else None + except (OSError, UnicodeDecodeError, ValueError): + return None + finally: + os.close(descriptor) + + +def _atomic_bytes_at(directory: int, name: str, payload: bytes) -> None: + """Publish one private file relative to an already verified directory.""" + try: + existing = os.open( + name, + os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), + dir_fd=directory, + ) + except FileNotFoundError: + existing = -1 + except OSError as exc: + if exc.errno == errno.ELOOP: + label = "name intent" if name.endswith(".nameintent") else f"target {name}" + raise OSError(f"refusing symlinked {label}") from exc + raise + if existing >= 0: + try: + if not statmod.S_ISREG(os.fstat(existing).st_mode): + raise OSError(f"refusing non-regular target {name}") + finally: + os.close(existing) + + temporary = "" + descriptor = -1 + for attempt in range(100): + candidate = f".{name}.{os.getpid()}.{time.time_ns()}.{attempt}" + try: + descriptor = os.open( + candidate, + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + 0o600, + dir_fd=directory, + ) + temporary = candidate + break + except FileExistsError: + continue + if descriptor < 0: + raise OSError("cannot allocate temporary registry file") + try: + view = memoryview(payload) + while view: + written = os.write(descriptor, view) + if written <= 0: + raise OSError("short registry write") + view = view[written:] + os.fsync(descriptor) + os.close(descriptor) + descriptor = -1 + os.replace( + temporary, + name, + src_dir_fd=directory, + dst_dir_fd=directory, + ) + temporary = "" + os.fsync(directory) + finally: + if descriptor >= 0: + os.close(descriptor) + if temporary: + with contextlib.suppress(FileNotFoundError): + os.unlink(temporary, dir_fd=directory) + + +def _claude_session_records( + home: Path, + uuid: str, + *, + environ: Mapping[str, str] | None, + max_session_records: int, + session_directories: list[Path] | None = None, +) -> tuple[list[tuple[Path, str, dict[str, Any]]], list[str]]: + """Readable, regular PID records for one exact conversation.""" + found: list[tuple[Path, str, dict[str, Any]]] = [] + if session_directories is None: + directories, warnings = _claude_sessions_directories(home, environ) + else: + directories, warnings = session_directories, [] + for sessions in directories: + directory = -1 + try: + directory = _open_sessions_directory(sessions) + records = sorted( + name + for name in os.listdir(directory) + if re.fullmatch(r"\d+\.json", name) + )[:max_session_records] + except OSError as exc: + warnings.append(f"Claude session records unreadable: {sessions}: {exc}") + if directory >= 0: + os.close(directory) + continue + try: + for record in records: + try: + data = _read_json_at(directory, record) + except OSError: + continue + if data is not None and data.get("sessionId") == uuid: + found.append((sessions, record, data)) + finally: + os.close(directory) + return found, warnings + + +def _claude_pre_push_title( + home: Path, + uuid: str, + *, + environ: Mapping[str, str] | None, + max_session_records: int, +) -> dict[str, Any] | None: + """The one unambiguous registry name observation before a Claude push. + + ``None`` means the registry supplied no safe evidence. Multiple matching + records count only when every readable copy agrees; uncertainty must keep + the historical human-rename behavior. + """ + records, _warnings = _claude_session_records( + home, + uuid, + environ=environ, + max_session_records=max_session_records, + ) + observations: set[tuple[str, str | int | float, str]] = set() + for _sessions, _name, data in records: + title = clean_text(data.get("name"), 120) + if not title: + continue + name_since = _valid_name_since(data.get("nameSince")) + if name_since is None: + return None + observations.add((title, name_since, clean_text(data.get("nameSource"), 20))) + if len(observations) != 1: + return None + title, name_since, name_source = next(iter(observations)) + return {"title": title, "nameSince": name_since, "nameSource": name_source} + + +def _claude_transcripts(home: Path, uuid: str) -> list[Path]: + """Every local transcript of one conversation, whichever profile holds it. + + A session started on an enrolled account keeps its transcript under that + account's profile, not under ``~/.claude``. A push that knew only the + default root therefore wrote nothing for those sessions and reported no + error a person would ever see, measured 2026-08-12: six of seven live + sessions had no colour record at all, so every provider window picked its + own colour while the picker showed the kit's. + """ + found: list[Path] = [] + for root in claude_roots({"HOME": os.fspath(home)}): + try: + found.extend(sorted((root / "projects").glob(f"*/{uuid}.jsonl"))) + except OSError: + continue + return found + + +def _claude_transcripts_by_age(home: Path, uuid: str) -> list[Path]: + """`_claude_transcripts`, oldest first, unreadable entries dropped. + + One conversation can exist in more than one profile, an account switch + copies it, and then "which file describes this session" has to be + answered by evidence, not by which profile name sorts last. Recency is + the same rule `transcripts._claude_transcript` already uses. + """ + dated: list[tuple[float, str, Path]] = [] + for path in _claude_transcripts(home, uuid): + try: + if path.is_symlink() or not path.is_file(): + continue + dated.append((path.stat().st_mtime, os.fspath(path), path)) + except OSError: + continue + dated.sort() + return [path for _, _, path in dated] + + +def _push_claude_title( + home: Path, + uuid: str, + title: str, + *, + environ: Mapping[str, str] | None = None, + atomic_write_json: Callable[[Path, Any], None], + max_session_records: int, +) -> tuple[list[str], list[str]]: + pushed: list[str] = [] + session_directories, warnings = _claude_sessions_directories(home, environ) + if not session_directories: + if not warnings: + warnings.append("Claude sessions directory unavailable; title not pushed") + return pushed, warnings + for sessions in session_directories: + intent_name = f"{uuid}.nameintent" + directory = -1 + try: + directory = _open_sessions_directory(sessions) + _atomic_bytes_at(directory, intent_name, (title + "\n").encode("utf-8")) + pushed.append("claude-nameintent") + except OSError as exc: + warnings.append(f"Claude name intent not written: {sessions}: {exc}") + finally: + if directory >= 0: + os.close(directory) + # The prompt bar's bottom-right name is a transcript agent-name record: + # the exact store /rename persists, hydrated at session start/resume, + # rendered beside the agent-color. Same append discipline as colors. + # A profile can hold the transcript without a sessions/ registry (fresh + # or partially migrated account roots), so transcript reach must never be + # narrowed to the roots that happened to have one. + transcripts = _claude_transcripts(home, uuid) + name_entry = json.dumps( + {"type": "agent-name", "agentName": title, "sessionId": uuid}, + separators=(",", ":"), + ) + for transcript in transcripts[:4]: + if transcript.is_symlink(): + continue + # Same "already says the right thing, leave it alone" rule the colour + # got: this runs on every pass too, so an unguarded append grew one + # more identical name record every time. + if _claude_name_already_set(transcript, uuid, title): + pushed.append("claude-transcript-name-current") + continue + landed, note = _append_transcript_record(transcript, name_entry) + if note: + warnings.append( + f"Claude transcript name{'' if landed else ' not'} appended: {note}" + ) + if landed: + pushed.append("claude-transcript-name") + # The per-PID session record names the thread in Claude's own session + # picker. Records carry the exact sessionId, so match by content. + records, record_warnings = _claude_session_records( + home, + uuid, + environ=environ, + max_session_records=max_session_records, + session_directories=session_directories, + ) + warnings.extend(record_warnings) + for sessions, record_name, data in records: + directory = -1 + try: + data["name"] = title + if data.get("nameSource") == "derived": + data.pop("nameSource", None) + directory = _open_sessions_directory(sessions) + _atomic_bytes_at( + directory, + record_name, + (json.dumps(data, indent=2, sort_keys=True) + "\n").encode("utf-8"), + ) + pushed.append("claude-session-record") + except OSError as exc: + warnings.append( + f"Claude session record not updated: {sessions / record_name}: {exc}" + ) + finally: + if directory >= 0: + os.close(directory) + return pushed, warnings + + +def _codex_title_echoes_prompt(title: str, first_message: str) -> bool: + """True when a stored title is just the first prompt (or a prefix cut).""" + return ( + bool(title) + and bool(first_message) + and ( + first_message.casefold().startswith(title.casefold()) + or title.casefold().startswith(first_message.casefold()) + ) + ) + + +def codex_bounce_prepare( + uuid: str, + codex_root: Path | None = None, + now: float | None = None, + *, + codex_home: Callable[..., Path], + max_session_index_bytes: int, + mirror_index: bool = True, +) -> str: + """Resolve the real name a bounced Codex process should boot under. + + Session-index rename evidence wins; otherwise a database title that does + not merely echo the first prompt. Codex seeds threads.title with the raw + prompt and replaces it within about two minutes of the first answer, so + an echo-shaped title only counts once the thread has been quiet long + enough that it must be the final title (a terse prompt can BE the real + title). The chosen name is mirrored into the session index when its + latest entry differs, because the relaunched status bar reads only the + index. Returns "" when the thread has no real name yet; the caller must + then defer the bounce and keep its one-shot marker so a later reopen + retries after the title exists. + """ + import sqlite3 + + if codex_root is None: + codex_root = codex_home() + title = "" + first_message = "" + has_split_prompt_schema = False + settled = False + databases = _codex_state_databases(codex_root) + if databases: + fetched = None + selected_columns: list[str] = [] + try: + connection = sqlite3.connect(f"file:{databases[-1]}?mode=ro", uri=True) + try: + columns = { + row[1] for row in connection.execute("PRAGMA table_info(threads)") + } + has_split_prompt_schema = "first_user_message" in columns + selected_columns = ["title"] + if has_split_prompt_schema: + selected_columns.append("first_user_message") + if "updated_at" in columns: + selected_columns.append("updated_at") + fetched = connection.execute( + "SELECT " + + ", ".join(selected_columns) + + " FROM threads WHERE id = ?", + (uuid,), + ).fetchone() + finally: + connection.close() + except sqlite3.Error: + fetched = None + if fetched: + values = dict(zip(selected_columns, fetched)) + title = str(values.get("title") or "").strip() + if has_split_prompt_schema: + first_message = str(values.get("first_user_message") or "").strip() + updated_at = values.get("updated_at") + if isinstance(updated_at, (int, float)) and not isinstance( + updated_at, bool + ): + current = time.time() if now is None else now + settled = current - float(updated_at) > 300 + index = codex_root / "session_index.jsonl" + index_name = "" + if index.is_file() and not index.is_symlink(): + try: + if index.stat().st_size <= max_session_index_bytes: + for line in index.read_text(encoding="utf-8").splitlines(): + try: + entry = json.loads(line) + except ValueError: + continue + if isinstance(entry, dict) and entry.get("id") == uuid: + candidate = str(entry.get("thread_name") or "").strip() + if candidate: + index_name = candidate + except OSError: + index_name = "" + # Codex never writes the session index itself: every entry is a + # deliberate push (kit title, auto-titler, rename mirror), so any entry + # counts as a real name even when it is echo-shaped. Only the database + # title needs the seed-vs-final test. + index_is_explicit = bool(index_name) + if has_split_prompt_schema: + title_is_explicit = bool(title) and ( + settled or not _codex_title_echoes_prompt(title, first_message) + ) + else: + title_is_explicit = bool(title) + if index_is_explicit: + chosen = index_name + elif title_is_explicit: + chosen = title + else: + return "" + if index_name != chosen and mirror_index: + # A failed mirror would relaunch under the stale bar name and burn + # the one-shot bounce on nothing, so it defers instead. + if index.is_symlink(): + return "" + try: + _append_codex_index_entry(index, uuid, chosen[:100]) + except OSError: + return "" + return chosen + + +def claude_bounce_prepare( + uuid: str, home: Path | None = None, now: float | None = None +) -> tuple[str, bool]: + """Decide whether a Claude window can only be named by a restart. + + Claude applies a kit name intent through its SessionStart and prompt + hooks, a living window renames at the next prompt, and a fresh boot + renames immediately. A session whose name arrived AFTER boot and that + never saw another prompt (ask, read, close) therefore shows its stale + title until the provider restarts. Returns (title, clear): + title != "", bounce is warranted; "" with clear=True, the live window + already renamed (a real prompt followed the intent), so the caller + should drop its untitled marker; "" with clear=False, defer: a name + may still arrive. + """ + if home is None: + home = Path.home() + intent = home / ".claude" / "sessions" / f"{uuid}.nameintent" + try: + if intent.is_symlink() or not intent.is_file(): + return "", False + intent_mtime = intent.stat().st_mtime + title = intent.read_text(encoding="utf-8").splitlines()[0].strip()[:64] + except (OSError, IndexError): + return "", False + if not title: + return "", False + # Every profile, not just the default root. A session launched on an + # enrolled account keeps its transcript under that account's profile, so + # the default-root glob found nothing and returned "defer" forever: the + # untitled marker was never cleared and the one safe rename bounce was + # never requested, which is exactly "it didn't rename the session" for a + # window that got its name after boot and then sat idle. + transcripts = _claude_transcripts_by_age(home, uuid) + if not transcripts: + return "", False + last_prompt = 0.0 + try: + with open(transcripts[-1], "rb") as handle: + for raw in handle: + try: + record = json.loads(raw.decode("utf-8", "strict")) + except (UnicodeDecodeError, ValueError): + continue + if not isinstance(record, Mapping) or record.get("type") != "user": + continue + if record.get("isMeta"): + continue + message = record.get("message") + if not isinstance(message, Mapping): + continue + content = message.get("content") + # Real prompts are strings; tool results arrive as lists. + # The synthesized resume-mount pair is not a human prompt. + if not isinstance(content, str): + continue + if content.startswith("Continue from where you left off."): + continue + stamp = record.get("timestamp") + if not isinstance(stamp, str): + continue + try: + parsed = dt.datetime.fromisoformat( + stamp.replace("Z", "+00:00") + ).timestamp() + except ValueError: + continue + last_prompt = max(last_prompt, parsed) + except OSError: + return "", False + if last_prompt > intent_mtime + 1: + # The hook applied the name at that prompt; the window is current. + return "", True + return title, False + + +def codex_pending_auto_titles( + environ: Mapping[str, str] | None = None, + *, + codex_paths: Callable[[], tuple[Path, Path]], + reconcile_pending_titles: Callable[..., Any], + derive_title: Callable[[Any], str | None], + load_config: Callable[[], dict[str, Any]], + max_session_index_bytes: int, + push_live_rename: LiveRename, + human_named: Callable[[Mapping[str, str]], frozenset[str]], + name_owner: Callable[..., str], + claim_name: Callable[..., str], + release_claim: Callable[..., bool], + record_pushed: Callable[..., None], + adopt_native: Callable[..., str], + budget_seconds: float | None = None, + monotonic: Callable[[], float] = time.monotonic, +) -> list[dict[str, str]]: + """Kit-side auto-titler for Codex threads nobody has named. + + Codex seeds threads.title with the raw first prompt and has no title + hook, and agents only self-name work they judge substantive, so a plain + question thread stays unnamed on every surface. Recent split-schema + threads whose title is empty or still echoes the first prompt, and which + have no session-index entry (the deliberate-name evidence), get the same + seven-word heuristic title Claude sessions get, pushed into both Codex + stores. Self-terminating: the push writes the index entry that excludes + the thread from every later pass, and a later agent self-name simply + overwrites it (last-writer-wins). + + Ownership is claimed at the thread's first turn, which is the earliest + moment a Codex thread has an identity at all: before that turn there is + no thread id and no first_user_message, so nothing here can see it. From + the claim onward the thread is named, and no later pass renames it, and + a thread a person has renamed is never a candidate in the first place. + + Nothing here is capped by count or by age any more. The old shape looked + at the 200 most recent threads, skipped anything older than seven days, + and stopped after twenty names and twenty repairs per pass, so a thread + that fell past any of those edges was never named at all, while the + Claude side named everything. The bound is now TIME: this runs on the + path that builds a human-facing inventory, so a pass spends at most a + couple of seconds and the next pass continues where it left off. Progress + is durable (each name writes the index entry that excludes the thread from + later passes), so a backlog converges instead of being abandoned. + """ + import sqlite3 + + env = environ if environ is not None else os.environ + config = load_config() + reconciled = reconcile_pending_titles(config, "codex", env) + if not automatic_naming_enabled(env): + return reconciled + if str(env.get("SESSION_KIT_CODEX_AUTOTITLE", "")).strip().casefold() in { + "0", + "false", + "no", + "off", + }: + return reconciled + codex_root, database = codex_paths() + if not database.is_file(): + # No thread store at all is not a failure to report: Codex may simply + # not be installed here. Any earlier complaint is stale, though, and a + # doctor warning nobody can act on is its own kind of noise. + _clear_titler_failure(env) + return reconciled + if budget_seconds is None: + budget_seconds = _autotitle_budget_seconds(env) + deadline = monotonic() + budget_seconds + store_error = False + + def _remaining() -> float: + return max(0.0, deadline - monotonic()) + + def _set_sqlite_deadline(connection: Any) -> bool: + remaining = _remaining() + if remaining <= 0: + return False + connection.execute(f"PRAGMA busy_timeout={max(0, int(remaining * 1000))}") + return True + + def _report_unreadable(exc: Exception) -> None: + nonlocal store_error + store_error = True + # A thread store that cannot be read means no Codex session gets a + # name, which is exactly the failure nobody could see before. + print( + f"session inventory: Codex thread store unreadable; " + f"threads were not titled: {exc}", + file=sys.stderr, + ) + _record_titler_failure(env, f"Codex thread store unreadable: {exc}") + + remaining = _remaining() + if remaining <= 0: + # No budget, no work: not even the schema probe. A pass that is out of + # time before it starts is a pass that does nothing, which is what + # makes the bound something a caller can rely on. + return reconciled + try: + connection = sqlite3.connect( + f"file:{database}?mode=ro", uri=True, timeout=remaining + ) + try: + if not _set_sqlite_deadline(connection): + return reconciled + columns = { + row[1] for row in connection.execute("PRAGMA table_info(threads)") + } + if not {"id", "title", "first_user_message", "updated_at"} <= columns: + return reconciled + # Only human root threads: subagent threads inherit their task + # prompt as first_user_message, and titling them replaces useful + # agent nicknames with near-duplicate task titles. Non-interactive + # `codex exec` runs are one-shot jobs nobody reopens, titling + # them only fills the index with prompt fragments. + root_filter = "" + if "thread_source" in columns: + root_filter = " AND (thread_source IS NULL OR thread_source = 'user')" + elif "agent_path" in columns: + root_filter = " AND (agent_path IS NULL OR agent_path = '')" + if "source" in columns: + root_filter += " AND (source IS NULL OR source != 'exec')" + finally: + connection.close() + except sqlite3.Error as exc: + _report_unreadable(exc) + return reconciled + + # Read a page at a time, newest first, and only while there is budget left. + # The old shape asked for every matching row and materialized the lot + # before the first deadline check, so on a large store the query and its + # result set were outside the bound the pass claims to have. Keyset paging + # (updated_at, id) keeps the order total -- a thread with no timestamp + # sorts last rather than vanishing -- and each page is a short read. + select = ( + "SELECT id, title, first_user_message, updated_at" + " FROM threads" + " WHERE first_user_message IS NOT NULL" + " AND first_user_message != ''" + root_filter + ) + + def _page(after: tuple[float, str] | None) -> list: + remaining = _remaining() + if remaining <= 0: + return [] + connection = sqlite3.connect( + f"file:{database}?mode=ro", uri=True, timeout=remaining + ) + try: + if not _set_sqlite_deadline(connection): + return [] + if after is None: + return connection.execute( + select + " ORDER BY COALESCE(updated_at, 0) DESC, id DESC LIMIT ?", + (AUTOTITLE_PAGE_ROWS,), + ).fetchall() + stamp, last_id = after + return connection.execute( + select + " AND (COALESCE(updated_at, 0) < ?" + " OR (COALESCE(updated_at, 0) = ? AND id < ?))" + " ORDER BY COALESCE(updated_at, 0) DESC, id DESC LIMIT ?", + (stamp, stamp, last_id, AUTOTITLE_PAGE_ROWS), + ).fetchall() + finally: + connection.close() + + def _rows(): + after: tuple[float, str] | None = None + while True: + if monotonic() >= deadline: + return + try: + page = _page(after) + except sqlite3.Error as exc: + _report_unreadable(exc) + return + if not page: + return + last = page[-1] + after = (float(last[3] or 0), str(last[0])) + yield from page + + rows = _rows() + index = codex_root / "session_index.jsonl" + # Latest entry per id wins, the file is append-ordered and renames are + # appended, so later lines overwrite earlier ones here. + indexed: dict[str, str] = {} + if index.is_file() and not index.is_symlink(): + try: + if index.stat().st_size > max_session_index_bytes: + # Silence here looks exactly like "nothing needed a name", + # and the effect is that no Codex session is ever named again. + detail = ( + "Codex session index is larger than the bounded size " + f"({max_session_index_bytes} bytes); threads were not titled" + ) + print(f"session inventory: {detail}", file=sys.stderr) + _record_titler_failure(env, detail) + return reconciled + for line in index.read_text(encoding="utf-8").splitlines(): + try: + entry = json.loads(line) + except ValueError: + continue + if isinstance(entry, Mapping) and entry.get("id"): + indexed[str(entry["id"])] = str( + entry.get("thread_name") or "" + ).strip() + except OSError as exc: + # Unable to prove which threads already carry deliberate names; + # titling anyway could overwrite one, so do nothing, but say so, + # because silence here looks exactly like "nothing needed a name". + detail = f"Codex session index unreadable; threads were not titled: {exc}" + print(f"session inventory: {detail}", file=sys.stderr) + _record_titler_failure(env, detail) + return reconciled + results: list[dict[str, str]] = [ + {"uuid": str(item["uuid"]), "title": str(item["title"])} for item in reconciled + ] + # Read the recorded name state once for the whole pass. The settled-row + # fast path is hot when a large indexed head precedes unnamed work, so + # deciding it from these in-memory maps keeps the check bounded and + # socket-free. + human_owned = human_named(env) + raw_aliases = config.get("aliases") + aliases = raw_aliases if isinstance(raw_aliases, Mapping) else {} + raw_automatic = config.get("automatic_titles") + automatic_titles = raw_automatic if isinstance(raw_automatic, Mapping) else {} + raw_pushed = config.get("pushed_titles") + pushed_titles = dict(raw_pushed) if isinstance(raw_pushed, Mapping) else {} + # Live app-server windows repaint from a socket rename; derive the + # socket root from the caller's exact environment (no real-home + # fallback) so sandboxed runs stay inert. + live_state_dir: Path | None = None + if env.get("SESSION_KIT_CODEX_LIVE_RENAME") != "0": + live_home = env.get("HOME") + if live_home: + live_state_dir = _session_kit_state_dir(env, Path(live_home)) + for uuid, raw_title, first_message, updated_at in rows: + if monotonic() >= deadline: + # Out of budget for this pass. Everything already written stands, + # and the next pass resumes from the newest thread still unnamed. + break + exact = valid_uuid(uuid) + if not exact: + continue + # A settled row is safe to skip only while its native title still + # equals the last state the kit recorded for that ownership tier. For + # a human row that is its alias; for an automatic row it is the last + # provider push. Ownership alone is never enough: a second /rename + # keeps human ownership but changes the native title, and that news + # must reach adoption before restore can replay the older alias. + key = f"codex:{exact}" + settled = exact in indexed and indexed[exact] == str(raw_title or "").strip() + recorded_title = ( + aliases.get(key) if key in human_owned else pushed_titles.get(key) + ) + if settled and recorded_title and recorded_title == indexed[exact]: + continue + # Rows created before pushed-title bookkeeping can still prove their + # history without a provider write: the two native stores agree, no + # person owns the row, and that exact title is either the retained + # automatic title or the title this same heuristic derives today. + # Adopt that evidence once so old stores converge onto the fast path. + if ( + settled + and key not in human_owned + and not pushed_titles.get(key) + and indexed[exact] + and ( + automatic_titles.get(key) == indexed[exact] + or derive_title(str(first_message or "")) == indexed[exact] + ) + ): + record_pushed("codex", exact, indexed[exact], environ=env) + pushed_titles[key] = indexed[exact] + continue + # The index name is the deliberate one when it exists, /rename + # writes it, and threads.title is the fallback evidence. Codex may + # re-stamp the raw first prompt and drop its index entry; that known + # seed is not a person renaming the thread away from the kit's push. + native_title = indexed.get(exact) or str(raw_title or "") + last_recorded_title = ( + aliases.get(key) + if key in human_owned + else pushed_titles.get(key) or automatic_titles.get(key) + ) + adopted = "" + if ( + last_recorded_title + and native_title != last_recorded_title + and ( + exact in indexed + or not _codex_title_echoes_prompt( + native_title, str(first_message or "") + ) + ) + ): + adopted = adopt_native( + "codex", + exact, + native_title, + environ=env, + ) + if adopted: + results.append({"uuid": exact, "title": adopted}) + continue + if key in human_owned: + # Human ownership is irreversible. If this snapshot already + # proves it and adoption found no newer native text, the later + # repair/name paths are forbidden without another config read. + continue + if exact in indexed: + # Heal the database half of the dual write: the status bar reads + # threads.title, and a curated index name next to a prompt-echo + # (or empty) database title means the earlier UPDATE never landed + # or Codex re-stamped the seed over it. Re-assert, bounded and + # fail-open; each inventory build converges the two stores. + index_name = indexed[exact] + title = str(raw_title or "").strip() + first = str(first_message or "").strip() + if ( + index_name + and index_name != title + and not _codex_title_echoes_prompt(index_name, first) + and (not title or _codex_title_echoes_prompt(title, first)) + ): + # This is the only indexed-row path that can still write. + # Re-read ownership at that boundary; rows with nothing to + # heal need no per-row config read at all. + owner = name_owner("codex", exact, environ=env) + if owner == "human": + continue + remaining = _remaining() + if remaining > 0: + _push_codex_thread_title( + codex_root, + exact, + index_name, + timeout_seconds=remaining, + ) + remaining = _remaining() + if live_state_dir is not None and remaining > 0: + push_live_rename( + live_state_dir, + exact, + index_name, + timeout_seconds=remaining, + ) + continue + owner = name_owner("codex", exact, environ=env) + if owner == "human": + # A person renamed this thread. Neither the titler nor the healer + # touches it again, in this pass or any pass after any restart. + continue + if exact == "00000000-0000-0000-0000-000000000000": + continue + if owner: + # The first-turn pass already claimed this thread's name. + continue + title = str(raw_title or "").strip() + first = str(first_message or "").strip() + if title and not _codex_title_echoes_prompt(title, first): + # A real name someone wrote; never replace it. + continue + derived = derive_title(first) + if not derived: + continue + # The claim is the race decision, so nothing is written before it. + # `/rename` or `sp name` landing between the ownership read above and + # this line takes the name; the claim refuses, and refusing has to mean + # writing nothing at all. Claiming after the index append (as this did) + # left the person owning the name locally while the provider surfaces + # carried the automatic one -- the two disagreeing about the same + # session is the whole defect class this file exists to close. + refusal = claim_name("codex", exact, environ=env) + if refusal: + continue + + def still_automatic() -> bool: + return name_owner("codex", exact, environ=env) == "automatic" + + # The index is append-only, so its only safe race boundary is the + # instant immediately before the append. A human claim that landed + # after our automatic claim therefore suppresses this write entirely. + if not still_automatic(): + continue + try: + _append_codex_index_entry(index, exact, derived[:100]) + except OSError: + release_claim("codex", exact, environ=env) + continue + # The append is the titler's durable provider-side write. Remember + # its exact value so a later settled pass can distinguish the kit's + # own title from a native /rename without reopening this row forever. + record_pushed("codex", exact, derived, environ=env) + # Both writes target the exact store tree the candidates were read + # from, pushing anywhere else would split reads and writes across + # different Codex homes. Each is checked against the budget: one + # SQLite update plus eight socket attempts at a two-second timeout can + # outlast the whole pass on its own. + remaining = _remaining() + if remaining > 0: + _push_codex_thread_title( + codex_root, + exact, + derived, + timeout_seconds=remaining, + still_automatic=still_automatic, + ) + remaining = _remaining() + if live_state_dir is not None and remaining > 0: + push_live_rename( + live_state_dir, + exact, + derived, + timeout_seconds=remaining, + still_automatic=still_automatic, + ) + results.append({"uuid": exact, "title": derived}) + if not store_error: + _clear_titler_failure(env) + return results + + +DEFAULT_AUTOTITLE_BUDGET_SECONDS = 2.0 + +# One page is a short read even on a large store, and small enough that the +# deadline is re-checked often. +AUTOTITLE_PAGE_ROWS = 200 + + +def _record_titler_failure(env: Mapping[str, str], detail: str) -> None: + """Leave a failure where a person will actually meet it. + + Printing to stderr is not enough on its own: every human-facing inventory + build calls this through `shpool_status`, which sends both streams to + /dev/null, so the one thing worth saying -- no Codex session can be named + right now -- reached nobody. `session-kit doctor` reads this file. + """ + home = env.get("HOME") + if not home: + return + try: + state = _session_kit_state_dir(env, Path(home)) + state.mkdir(parents=True, exist_ok=True) + path = state / "codex-autotitle-error.json" + if path.is_symlink(): + return + payload = json.dumps( + { + "detail": str(detail)[:400], + "at": dt.datetime.now(dt.timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + }, + separators=(",", ":"), + ) + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(payload + "\n") + except OSError: + return + + +def _clear_titler_failure(env: Mapping[str, str]) -> None: + """A pass that read the store fine retires the last complaint.""" + home = env.get("HOME") + if not home: + return + try: + path = _session_kit_state_dir(env, Path(home)) / "codex-autotitle-error.json" + if not path.is_symlink(): + path.unlink() + except OSError: + return + + +def _autotitle_budget_seconds(env: Mapping[str, str]) -> float: + """How long one titling pass may spend, in seconds. + + This runs before a human-facing inventory build, so the pass has to end + while somebody is waiting for a list. Two seconds names a large backlog + over a handful of refreshes and is invisible on an ordinary one, where + every recent thread already carries an index entry and the loop does + nothing at all. + """ + raw = str(env.get("SESSION_KIT_CODEX_AUTOTITLE_BUDGET_SECONDS", "")).strip() + if not raw: + return DEFAULT_AUTOTITLE_BUDGET_SECONDS + try: + value = float(raw) + except ValueError: + return DEFAULT_AUTOTITLE_BUDGET_SECONDS + if value <= 0 or value > 60: + return DEFAULT_AUTOTITLE_BUDGET_SECONDS + return value + + +def _append_codex_index_entry(index: Path, uuid: str, title: str) -> None: + entry = json.dumps( + { + "id": uuid, + "thread_name": title, + "updated_at": dt.datetime.now(dt.timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + }, + separators=(",", ":"), + ) + descriptor = os.open(index, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600) + with os.fdopen(descriptor, "a", encoding="utf-8") as handle: + handle.write(entry + "\n") + handle.flush() + os.fsync(handle.fileno()) + + +def _push_codex_thread_title( + codex_root: Path, + uuid: str, + title: str, + *, + timeout_seconds: float = 1.0, + still_automatic: Callable[[], bool] | None = None, +) -> tuple[list[str], list[str]]: + """Set threads.title in Codex's own state database. + + The Codex TUI's thread-title status item and its rename flow read and + write this column (the session index alone never reaches the status + bar). Update-only, a missing row is reported, never created, and every + failure is fail-open. + """ + import sqlite3 + + candidates = _codex_state_databases(codex_root) + if not candidates: + # Older Codex builds have no thread store; nothing to report. + return [], [] + database = candidates[-1] + try: + timeout_seconds = max(0.0, float(timeout_seconds)) + deadline = time.monotonic() + timeout_seconds + + def set_busy_deadline(connection: Any) -> bool: + remaining = max(0.0, deadline - time.monotonic()) + if remaining <= 0: + return False + connection.execute(f"PRAGMA busy_timeout={max(0, int(remaining * 1000))}") + return True + + connection = sqlite3.connect(database, timeout=timeout_seconds) + try: + if not set_busy_deadline(connection): + return [], [] + cursor = connection.execute( + "UPDATE threads SET title = ? WHERE id = ?", (title, uuid) + ) + # SQLite lets us hold the UPDATE uncommitted. Re-read ownership + # after the blocking write and finalize only while the claim is + # still automatic; a human rename that landed meanwhile wins. + if still_automatic is not None and not still_automatic(): + connection.rollback() + return [], [] + if not set_busy_deadline(connection): + connection.rollback() + return [], [] + connection.commit() + if cursor.rowcount > 0: + return ["codex-thread-title"], [] + return [], ["Codex thread row not found; thread title not set"] + finally: + connection.close() + except sqlite3.Error as exc: + return [], [f"Codex thread title not set: {exc}"] + + +MAX_CODEX_LIVE_RENAME_SOCKETS = 8 + +MAX_CODEX_LIVE_RENAME_FRAME = 1024 * 1024 + + +def _session_kit_state_dir(env: Mapping[str, str], home: Path) -> Path: + """The session-kit state directory under the caller's exact sandbox. + + Same precedence as the shell helpers: SESSION_KIT_STATE_DIR names the + kit directory itself; otherwise it lives under XDG_STATE_HOME or the + sandbox home. Never falls back to the real home, an explicit + environment IS the caller's sandbox. + """ + explicit = env.get("SESSION_KIT_STATE_DIR") + if explicit: + return Path(explicit) + xdg = env.get("XDG_STATE_HOME") + if xdg: + return Path(xdg) / "session-kit" + return home / ".local" / "state" / "session-kit" + + +def _socket_time_left(deadline: float | None, cap: float = 2.0) -> float: + if deadline is None: + return cap + return min(cap, max(0.0, deadline - time.monotonic())) + + +def _set_socket_deadline(connection: Any, deadline: float | None) -> None: + remaining = _socket_time_left(deadline) + if remaining <= 0: + raise TimeoutError("automatic-title deadline expired") + connection.settimeout(remaining) + + +def _ws_send_frame( + connection: Any, + payload: bytes, + opcode: int = 1, + *, + deadline: float | None = None, +) -> None: + import struct + + header = bytes([0x80 | opcode]) + length = len(payload) + if length < 126: + header += bytes([length | 0x80]) + elif length < 65536: + header += bytes([126 | 0x80]) + struct.pack("!H", length) + else: + header += bytes([127 | 0x80]) + struct.pack("!Q", length) + mask = os.urandom(4) + masked = bytes(value ^ mask[index % 4] for index, value in enumerate(payload)) + _set_socket_deadline(connection, deadline) + connection.sendall(header + mask + masked) + + +def _ws_recv_frame( + connection: Any, *, max_frame: int, deadline: float | None = None +) -> tuple[int, bytes]: + import struct + + def read_exact(count: int) -> bytes: + data = b"" + while len(data) < count: + _set_socket_deadline(connection, deadline) + chunk = connection.recv(count - len(data)) + if not chunk: + raise OSError("WebSocket closed mid-frame") + data += chunk + return data + + first, second = read_exact(2) + opcode = first & 0x0F + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", read_exact(2))[0] + elif length == 127: + length = struct.unpack("!Q", read_exact(8))[0] + if length > max_frame: + raise OSError("oversized WebSocket frame") + mask = read_exact(4) if second & 0x80 else b"" + payload = read_exact(length) + if mask: + payload = bytes(value ^ mask[index % 4] for index, value in enumerate(payload)) + return opcode, payload + + +def _ws_request( + connection: Any, + request_id: int, + method: str, + params: dict[str, Any], + *, + max_frame: int, + deadline: float | None = None, +) -> None: + _ws_send_frame( + connection, + json.dumps( + {"method": method, "id": request_id, "params": params}, + separators=(",", ":"), + ).encode(), + deadline=deadline, + ) + while True: + opcode, payload = _ws_recv_frame( + connection, max_frame=max_frame, deadline=deadline + ) + if opcode == 8: + raise OSError("WebSocket closed by server") + if opcode == 9: + _ws_send_frame(connection, payload, opcode=10, deadline=deadline) + continue + if opcode != 1: + continue + try: + message = json.loads(payload) + except ValueError: + continue + if not isinstance(message, dict) or message.get("id") != request_id: + continue + if message.get("error") is not None: + raise OSError(f"app-server {method} refused: {message['error']}") + return + + +def _push_codex_live_rename( + kit_state_dir: Path, + uuid: str, + title: str, + *, + max_sockets: int, + max_frame: int, + timeout_seconds: float | None = None, + still_automatic: Callable[[], bool] | None = None, +) -> tuple[list[str], list[str]]: + """Repaint live app-server-backed Codex windows with the new name. + + A direct-TUI Codex bar repaints only at process start, but a remote TUI + attached to a kit app-server repaints its thread-title item from the + thread/name/updated broadcast, so a rename through the socket names a + LIVE window with no provider restart. Every kit app-server shares one + thread store, so the rename is offered to each live socket; a server + not hosting the thread writes the same value the direct push already + wrote, harmlessly. Entirely fail-open: absent, dead, or refusing + sockets never block the store push and never warn, most sessions are + direct TUIs with no socket at all. + """ + import base64 + import socket as socketmod + + deadline = ( + None + if timeout_seconds is None + else time.monotonic() + max(0.0, float(timeout_seconds)) + ) + + app_root = kit_state_dir / "app-server" + try: + entries = list(app_root.iterdir()) + except OSError: + return [], [] + # Newest directory first. A socket directory outlives the session that + # made it, so the oldest entries are the least likely to answer, and a + # name-ordered walk (the directory names begin with their creation date) + # reached exactly the wrong end of the list: nine dead 2026-08-04 dirs + # consumed the cap while three live windows behind them were never + # offered the rename. The cap below counts sockets that ACCEPTED a + # connection, never directories considered, so any number of dead ones + # may precede the live ones. + ordered: list[tuple[float, Path]] = [] + for entry in entries: + try: + ordered.append((entry.stat().st_mtime, entry)) + except OSError: + continue + ordered.sort(key=lambda item: item[0], reverse=True) + delivered = False + connected = 0 + for _, directory in ordered: + if connected >= max_sockets or _socket_time_left(deadline) <= 0: + break + socket_path = directory / "app.sock" + try: + if socket_path.is_symlink() or not statmod.S_ISSOCK( + os.stat(socket_path).st_mode + ): + continue + except OSError: + continue + connection = socketmod.socket(socketmod.AF_UNIX, socketmod.SOCK_STREAM) + try: + try: + _set_socket_deadline(connection, deadline) + connection.connect(os.fspath(socket_path)) + except OSError: + # An abandoned socket file refuses the connection at once; + # skipping it costs nothing and never spends the cap. + continue + connected += 1 + key = base64.b64encode(os.urandom(16)).decode() + _set_socket_deadline(connection, deadline) + connection.sendall( + ( + "GET / HTTP/1.1\r\nHost: localhost\r\n" + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n" + ).encode() + ) + response = b"" + while not response.endswith(b"\r\n\r\n"): + if len(response) > 65536: + raise OSError("oversized upgrade response") + _set_socket_deadline(connection, deadline) + byte = connection.recv(1) + if not byte: + raise OSError("app-server closed during upgrade") + response += byte + if b" 101 " not in response.split(b"\r\n", 1)[0]: + raise OSError("WebSocket upgrade refused") + _ws_request( + connection, + 1, + "initialize", + { + "clientInfo": { + "name": "session-kit-live-rename", + "title": "Session Kit live rename", + "version": "1.0", + } + }, + max_frame=max_frame, + deadline=deadline, + ) + _ws_send_frame( + connection, + json.dumps( + {"method": "initialized", "params": {}}, + separators=(",", ":"), + ).encode(), + deadline=deadline, + ) + # The protocol call below is irreversible. Check the canonical + # owner at its last safe boundary so a human claim suppresses it. + if still_automatic is not None and not still_automatic(): + continue + _ws_request( + connection, + 2, + "thread/name/set", + {"threadId": uuid, "name": title}, + max_frame=max_frame, + deadline=deadline, + ) + delivered = True + except (OSError, ValueError): + continue + finally: + with contextlib.suppress(OSError): + connection.close() + if delivered: + return ["codex-live-rename"], [] + return [], [] + + +CODEX_LIVE_CAPABILITY_FILE = "capabilities.json" + + +def codex_live_capabilities(kit_state_dir: Path) -> dict[str, bool]: + """What a live Codex app-server in this installation can be asked to do. + + The kit already renames a live window through ``thread/name/set``. A live + RECOLOUR has no equivalent in the installed build, and the honest handling + of that is to say so rather than to guess a method name: the caller then + tells the person their colour lands at the window's next start, which is + true today and stops being printed the moment the capability appears. + + The file is written by the app-server client after it negotiates + ``initialize`` -- that client is WS-F's work. Until it exists, every + capability reads false, which is exactly the current truth. + """ + path = kit_state_dir / "app-server" / CODEX_LIVE_CAPABILITY_FILE + try: + if path.is_symlink(): + return {} + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + if not isinstance(raw, Mapping): + return {} + return { + str(key): bool(value) + for key, value in raw.items() + if isinstance(key, str) and isinstance(value, bool) + } + + +def push_codex_live_color( + kit_state_dir: Path, + uuid: str, + color: str, + *, + capabilities: Callable[[Path], Mapping[str, bool]] = codex_live_capabilities, + send: Callable[[Path, str, str], tuple[list[str], list[str]]] | None = None, +) -> tuple[list[str], list[str]]: + """Repaint a live Codex window in a new colour, when that becomes possible. + + Codex takes its theme from the command line at launch, so today a recolour + reaches a window only through the one safe provider restart. The seam is + here so the recolour path has one place to ask, and one answer that is + never a false success: a warning naming the real reason, which the caller + turns into the line the person reads. + """ + exact = valid_uuid(uuid) + if not exact or not color: + return [], ["invalid Codex live recolor request"] + if not capabilities(kit_state_dir).get("theme_set"): + return [], [ + "Codex has no live theme control in this build; " + "the color applies when the window next starts" + ] + if send is None: + return [], [ + "Codex live theme control is available but no app-server client is wired" + ] + return send(kit_state_dir, exact, color) + + +def _push_codex_title( + codex_root: Path, + uuid: str, + title: str, + kit_state_dir: Path | None = None, + *, + max_session_index_bytes: int, + push_live_rename: Callable[[Path, str, str], tuple[list[str], list[str]]], +) -> tuple[list[str], list[str]]: + if not codex_root.is_dir(): + return [], ["Codex home unavailable; title not pushed"] + index = codex_root / "session_index.jsonl" + if index.is_symlink(): + return [], ["Codex session index is a symlink; title not pushed"] + try: + if index.exists() and index.stat().st_size > max_session_index_bytes: + return [], [ + "Codex session index exceeds the bounded size; title not pushed" + ] + _append_codex_index_entry(index, uuid, title) + except OSError as exc: + return [], [f"Codex session index not appended: {exc}"] + thread_pushes, thread_warnings = _push_codex_thread_title(codex_root, uuid, title) + live_pushes: list[str] = [] + if kit_state_dir is not None: + live_pushes, _ = push_live_rename(kit_state_dir, uuid, title) + return ( + ["codex-session-index", *thread_pushes, *live_pushes], + thread_warnings, + ) + + +def _claude_last_record_value( + transcript: Path, uuid: str, kind: str, field: str +) -> str | None: + """The value this transcript's LAST `kind` record carries for this uuid. + + The last one is what counts: Claude replays the records in order, so a + later record overrides an earlier one. Any unreadable or malformed line + is skipped rather than treated as an answer, and a transcript that cannot + be read at all returns None -- every caller reads that as "no answer" and + falls through to the append, which is the behaviour that existed before + the guard. + + The file is read as BYTES and each candidate line is decoded on its own. + Text mode raises UnicodeDecodeError -- a ValueError, not an OSError -- + from the iteration itself, so one half-written multi-byte character at + the end of a transcript a live provider is writing (the normal shape of a + killed writer) escaped this function, its caller, and the hydration loop + that walks every session, and silently stopped colour and name for every + session in the pass. Decoding per line keeps one torn line from costing + the other forty thousand. + """ + current: str | None = None + marker = f'"{kind}"'.encode() + try: + with open(transcript, "rb") as handle: + for raw in handle: + if marker not in raw: + continue + try: + item = json.loads(raw.decode("utf-8", "strict")) + except (UnicodeDecodeError, ValueError): + continue + if ( + isinstance(item, Mapping) + and item.get("type") == kind + and item.get("sessionId") == uuid + ): + value = item.get(field) + current = value if isinstance(value, str) else None + except OSError: + return None + return current + + +def _claude_color_already_set(transcript: Path, uuid: str, color: str) -> bool: + """Whether this transcript's LAST color record already says `color`.""" + return ( + _claude_last_record_value(transcript, uuid, "agent-color", "agentColor") + == color + ) + + +def _claude_name_already_set(transcript: Path, uuid: str, name: str) -> bool: + """Whether this transcript's LAST name record already says `name`.""" + return ( + _claude_last_record_value(transcript, uuid, "agent-name", "agentName") == name + ) + + +# How long a transcript must sit untouched before an unterminated last line +# counts as wreckage left by a killed writer rather than a record being +# written this instant. +TRANSCRIPT_QUIET_SECONDS = 30.0 + + +def _write_whole_line(descriptor: int, payload: bytes) -> bool: + """One O_APPEND write. True when the record ended up on its own line. + + O_APPEND makes the kernel place the whole buffer contiguously at the end + of the file, so this record can never be cut in half by another writer. + Where it landed is read back from the file offset, which O_APPEND leaves + at the end of OUR bytes -- so it stays true even if the provider appends + again immediately afterwards. + + A payload that already begins with a newline starts a line whatever + precedes it, so it is standalone by construction; anything else has to be + proven by the byte in front of it. + """ + written = os.write(descriptor, payload) + if written != len(payload): + raise OSError(f"short write ({written} of {len(payload)} bytes)") + start = os.lseek(descriptor, 0, os.SEEK_CUR) - written + if os.pread(descriptor, written, start) != payload: + raise OSError("the record did not read back as it was written") + if payload.startswith(b"\n") or start == 0: + return True + return os.pread(descriptor, 1, start - 1) == b"\n" + + +def _append_transcript_record(transcript: Path, entry: str) -> tuple[bool, str]: + """Append one whole JSONL record. Returns (it landed, what to say). + + Appending is the only safe edit to a transcript a live provider owns, and + the danger is not the write being torn -- O_APPEND cannot tear it -- but + WHERE it lands. A provider writes a record; if it does that in more than + one write, there is an instant when the file ends part-way through its + record, and a record appended in that instant lands INSIDE the provider's, + making one invalid line out of two records. The old code checked the tail + and then wrote, and a provider that began a record between those two steps + got its record destroyed while the caller was told the colour had landed. + + Nothing closes that window from this side: the provider does not + coordinate, and there is no check-and-append syscall. What is guaranteed + instead: + + * a record already visibly in flight is never written onto at all; + * where the write lands is VERIFIED, not assumed; + * if it landed inside another record anyway, the colour is written again + behind a leading newline, which starts a line whatever precedes it, so + OUR record always ends up whole and readable; + * success is reported only for a record read back from the file; + * and damage to the provider's record is said out loud instead of being + swallowed. + """ + payload = (entry + "\n").encode("utf-8") + try: + descriptor = os.open(transcript, os.O_RDWR | os.O_APPEND) + except OSError as exc: + return False, str(exc) + try: + status = os.fstat(descriptor) + if not statmod.S_ISREG(status.st_mode): + return False, "not a regular file" + if status.st_size and os.pread(descriptor, 1, status.st_size - 1) != b"\n": + if time.time() - status.st_mtime < TRANSCRIPT_QUIET_SECONDS: + # A record is visibly in flight. Refuse: the next pass tries + # again, and a colour that arrives a few seconds later beats + # one that costs the provider an event. + return False, "a record is being written to this transcript right now" + # Untouched long enough that the half-line is wreckage from a + # killed writer, not work in progress. The leading newline closes + # it, so the file is never left worse than it was found. + payload = b"\n" + payload + if not _write_whole_line(descriptor, payload): + # The provider began a record between the check above and the + # write, and this one landed inside it. Both records are now one + # invalid line. Write the colour again behind a leading newline -- + # that starts a line no matter what precedes it, so this copy is + # whole and is the one every reader will find. + _write_whole_line(descriptor, b"\n" + payload) + os.fsync(descriptor) + return True, ( + "a Claude record was being written at the same instant and was " + "damaged; the record was written again on its own line" + ) + os.fsync(descriptor) + except OSError as exc: + return False, str(exc) + finally: + os.close(descriptor) + return True, "" + + +def _push_claude_color( + home: Path, uuid: str, color: str +) -> tuple[list[str], list[str]]: + """Write the exact agent-color record /color itself writes, once. + + Claude Code reads it at session start/resume; nothing is ever typed into + a live terminal. Missing transcripts fail open. + + Written once per conversation, not once per pass. This runs on every + attach (`sk_push_session_color`), and it used to append unconditionally, + so a session a person opened eight times carried eight identical color + records and grew one more on every reattach. Claude honours the last + record either way, so the repeats never changed what was shown -- they + only grew a file the kit does not own and cannot compact. A record that + already says the right thing is left alone; a DIFFERENT color still + appends, because that is a real change and appending is the only safe + edit to a transcript a live provider is writing to. + """ + transcripts = _claude_transcripts(home, uuid) + if not transcripts: + return [], ["no Claude transcript for this conversation; color not pushed"] + entry = json.dumps( + {"type": "agent-color", "agentColor": color, "sessionId": uuid}, + separators=(",", ":"), + ) + pushed: list[str] = [] + warnings: list[str] = [] + for transcript in transcripts[:4]: + if transcript.is_symlink(): + warnings.append(f"refusing symlinked transcript: {transcript.name}") + continue + if _claude_color_already_set(transcript, uuid, color): + pushed.append("claude-transcript-color-current") + continue + landed, note = _append_transcript_record(transcript, entry) + if note: + warnings.append( + f"Claude transcript{'' if landed else ' not'} appended: {note}" + ) + if landed: + pushed.append("claude-transcript-color") + return pushed, warnings diff --git a/lib/sessionkit_inventory/origins.py b/lib/sessionkit_inventory/origins.py new file mode 100644 index 0000000..c416b65 --- /dev/null +++ b/lib/sessionkit_inventory/origins.py @@ -0,0 +1,784 @@ +"""Who asked for a session: a person, or a machine. + +The picker's default view is the person's own screen. Everything an +automation, a drill, or a headless restore creates belongs behind one counted +row; it is work nobody chose to look at, and a list that mixes the two makes +a person's own sessions harder to find every time a machine starts one. + +Origin is stamped at CREATION, by the verb that creates the session, because +that is the only moment anybody knows who asked. Nothing infers it later from +a directory, a title, or a guess: an unstamped session is a person's, so a +tool that forgets to say it is a machine shows up in the human list and gets +noticed, rather than hiding by accident. + +TWO SIGNALS, AND WHICH ONE WINS. An instance-bound stamp is provenance -- who +created this session instance -- and it is the answer whenever it matches, in both directions. A +second signal exists for sessions created before any of this was recorded: +collection can PROVE that a program and no window holds a Codex App Server's +socket (see `collector`). That verdict is consulted ONLY where there is no +stamp at all. It never overturns one, because a stamp that says a person +created a session is a fact about its creation, and a window that happens to +be absent right now is not. The order is: a machine stamp folds, a human +stamp stays, and only silence is decided by the driver. + +SILENCE MUST BE PROVEN, TOO. "This session was never stamped" is a claim about +the store, and it is only true when the store was READ. A store that will not +open, will not parse, or does not have the shape this version writes is not +evidence of anything, so it never licenses the driver to fold a row and it is +never rewritten -- rewriting it is how a reading failure turns into permanent +data loss. + +Legacy stamps predate instance binding. A reused session-manager name must not +inherit one: those stamps remain readable for rollout, but classify no current +row. This deliberately leaves an old machine-created session visible as +clutter instead of risking that a person's later session disappears. + +The record outlives nothing: it is keyed by session ID and forgotten the +moment its session is gone. "Gone" also has to be proven: an older collection +can have read the manager just before a newly attached session was visible, +and a listing that names no sessions at all is not proof that every stamped +session died. Both keep their stamps. +""" + +from __future__ import annotations + +import os +import re +import secrets +import time +from pathlib import Path +from typing import Any, Iterable, Mapping + +from .common import CollectionError +from .state_io import ( + StateLock, + atomic_write_private_json, + ensure_private_directory, + read_private_json, +) + +ORIGIN_SCHEMA_VERSION = 2 +MAX_ORIGIN_BYTES = 256 * 1024 +HUMAN = "human" +MACHINE = "machine" +ORIGINS = (HUMAN, MACHINE) +_ORIGIN_GENERATION_RE = re.compile(r"[0-9a-f]{64}") +_LEGACY_ORIGIN_GENERATION = "legacy" +_ORIGIN_INSTANCE_ENV = { + "shell_pid": "SESSION_KIT_ORIGIN_SHELL_PID", + "shell_start_ticks": "SESSION_KIT_ORIGIN_SHELL_START_TICKS", + "started_at_unix_ms": "SESSION_KIT_ORIGIN_STARTED_AT_UNIX_MS", +} +# Record generations are freshness nonces for cooperating writers, not +# authentication tags. They prevent an older captured record from reaching a +# replacement; a same-account process that can rewrite private state can also +# delete that state directly and is outside this boundary. +# Creation and collection use different locks. A collector can read the +# manager just before attach, then prune after creation captures and stamps the +# exact new shell. An id-not-listed test alone would delete that fresh stamp, +# and nothing ever stamps a live session again. Two minutes is far past this +# attach/capture race and costs one dictionary entry. +ORIGIN_PRUNE_GRACE_MS = 120 * 1000 +# The suffix a bounce marker carries once the session shell has taken the +# instruction and is relaunching. The picker writes the bare session ID and +# never a receipt; collection deletes only a generated receipt captured before +# its process reading and never the bare ID. Both forms read as "bouncing". +TAKEN_SUFFIX = ".taken" +# New provider shells put the request generation after the receipt suffix. +# The whole name is immutable: a later bounce gets a different name, so a +# collector carrying an older sighting has no pathname by which it could +# remove the later receipt. The fixed ``.taken`` name remains readable for +# shells that were already live when this protocol shipped, but collection +# never settles that reusable legacy name. +TAKEN_GENERATION_SEPARATOR = f"{TAKEN_SUFFIX}." +# A request reserves its generated receipt name for the lifetime of the +# session. Keeping the reservation after settlement makes generated names +# non-reusable while any collector for that session can still be in flight. +BOUNCE_GENERATION_RESERVATION_PREFIX = ".generation." +# A listing with NO sessions in it is the one shape that can empty the whole +# store in a single pass, and it is also what a collection run against a +# stand-in `shpool` produces -- the store is shared, the session manager is +# not. It forgets nothing at all. An age cut here reads as "old enough that no +# live session could still be using it", and that is not a fact about the +# session, only about the stamp: the operator's own sessions run for days, and +# one empty listing plus a day of uptime deleted their provenance and handed +# them to the driver. Nothing grows without bound as a result -- the first +# listing that names any session prunes whatever really died, and while the +# listing is empty nothing new is being stamped either. + + +def origins_path(state_dir: Path) -> Path: + return state_dir / "session-origins.json" + + +def checked_origin(value: Any) -> str: + if value not in ORIGINS: + raise CollectionError(f"session origin must be one of {', '.join(ORIGINS)}") + return str(value) + + +def _empty_document() -> dict[str, Any]: + return {"schema_version": ORIGIN_SCHEMA_VERSION, "sessions": {}} + + +def _origin_lock(state_dir: Path) -> StateLock: + ensure_private_directory(state_dir) + return StateLock(state_dir, state_dir / "session-origins.lock") + + +def _validated_pair(document: Any) -> tuple[dict[str, Any], bool]: + """The store, and whether the document it came from was fully understood. + + The second value is the difference between "nobody has been stamped" and + "this file did not read as a stamp store". Only the first is evidence. + """ + if ( + not isinstance(document, Mapping) + or document.get("schema_version") not in {1, ORIGIN_SCHEMA_VERSION} + or not isinstance(document.get("sessions"), Mapping) + ): + return _empty_document(), False + sessions: dict[str, Any] = {} + recognized = True + schema_version = document["schema_version"] + for key, entry in document["sessions"].items(): + if ( + not isinstance(key, str) + or not isinstance(entry, Mapping) + or entry.get("origin") not in ORIGINS + ): + # One unreadable row is one session whose provenance was lost, so + # the whole reading stops being proof that anybody is unstamped. + recognized = False + continue + stamp = entry.get("at_unix_ms") + generation = entry.get("record_generation") + if schema_version == ORIGIN_SCHEMA_VERSION and ( + not isinstance(generation, str) + or ( + generation != _LEGACY_ORIGIN_GENERATION + and not _ORIGIN_GENERATION_RE.fullmatch(generation) + ) + ): + recognized = False + continue + normalized = { + "origin": entry["origin"], + "at_unix_ms": stamp + if isinstance(stamp, int) and not isinstance(stamp, bool) + else 0, + } + normalized["record_generation"] = ( + generation + if schema_version == ORIGIN_SCHEMA_VERSION + else _LEGACY_ORIGIN_GENERATION + ) + instance = entry.get("instance") + if isinstance(instance, Mapping): + pid = instance.get("shell_pid") + ticks = instance.get("shell_start_ticks") + started = instance.get("started_at_unix_ms") + if ( + isinstance(pid, int) + and not isinstance(pid, bool) + and pid > 0 + and isinstance(ticks, int) + and not isinstance(ticks, bool) + and ticks > 0 + and isinstance(started, int) + and not isinstance(started, bool) + and started >= 0 + ): + normalized["instance"] = { + "shell_pid": pid, + "shell_start_ticks": ticks, + "started_at_unix_ms": started, + } + else: + recognized = False + sessions[key] = normalized + return {"schema_version": ORIGIN_SCHEMA_VERSION, "sessions": sessions}, recognized + + +def _validated(document: Any) -> dict[str, Any]: + return _validated_pair(document)[0] + + +def read_origins(state_dir: Path) -> tuple[dict[str, Any], bool]: + """Load the store and say whether the reading can be trusted. + + A store that has never been written reads as an empty TRUSTED store: no + session has been stamped yet, which is a fact. Anything else that goes + wrong -- permissions, a truncated file, a size cap, JSON that will not + parse, a shape from another version -- reads as an empty UNTRUSTED store, + and callers must neither infer from it nor write over it. + """ + try: + raw = read_private_json( + origins_path(state_dir), + max_bytes=MAX_ORIGIN_BYTES, + allow_missing=True, + ) + except (CollectionError, OSError): + return _empty_document(), False + if raw is None: + return _empty_document(), True + return _validated_pair(raw) + + +def load_origins(state_dir: Path) -> dict[str, Any]: + return _validated( + read_private_json( + origins_path(state_dir), + max_bytes=MAX_ORIGIN_BYTES, + allow_missing=True, + ) + ) + + +def record_origin( + state_dir: Path, + *, + shpool_id: str, + origin: str, + now_unix_ms: int | None = None, +) -> dict[str, Any]: + """Stamp one proven session instance at the moment it is created.""" + if not isinstance(shpool_id, str) or not shpool_id: + raise CollectionError("session origin requires a session ID") + checked = checked_origin(origin) + instance: dict[str, int] = {} + for field, variable in _ORIGIN_INSTANCE_ENV.items(): + raw = os.environ.get(variable) + try: + value = int(raw) if raw is not None else 0 + except ValueError: + value = 0 + if value <= 0: + raise CollectionError( + "session origin requires the exact shell PID, shell start ticks, " + "and session start time" + ) + instance[field] = value + with _origin_lock(state_dir): + document, trusted = read_origins(state_dir) + if not trusted: + # Writing here would replace every stamp this reading could not see + # with one entry, and the sessions behind those stamps are live. A + # refusal costs one unstamped session, which is listed as a person's; + # the write would cost every other session its provenance. + raise CollectionError( + "session origin store did not read as a stamp store; refusing to " + "overwrite it" + ) + document["schema_version"] = ORIGIN_SCHEMA_VERSION + document["sessions"][shpool_id] = { + "origin": checked, + "at_unix_ms": int(time.time() * 1000) + if now_unix_ms is None + else now_unix_ms, + "record_generation": secrets.token_hex(32), + "instance": instance, + } + atomic_write_private_json(origins_path(state_dir), document) + return document + + +def origin_for(shpool_id: Any, origins: Mapping[str, Any] | None) -> str: + """An unstamped session belongs to the person. Never the other way.""" + if not isinstance(shpool_id, str) or not isinstance(origins, Mapping): + return HUMAN + entry = (origins.get("sessions") or {}).get(shpool_id) + if not isinstance(entry, Mapping): + return HUMAN + origin = entry.get("origin") + return origin if isinstance(origin, str) and origin in ORIGINS else HUMAN + + +def recorded_origin(shpool_id: Any, origins: Mapping[str, Any] | None) -> str | None: + """The stamp itself, or None when this session was never stamped. + + `origin_for` answers what a session IS, and reads an unstamped session as + a person's. This answers the different question of whether anybody ever + said, which is what separates "a person created this" from "this was + created before the kit recorded creators at all". + """ + if not isinstance(shpool_id, str) or not isinstance(origins, Mapping): + return None + entry = (origins.get("sessions") or {}).get(shpool_id) + if not isinstance(entry, Mapping): + return None + return entry.get("origin") if entry.get("origin") in ORIGINS else None + + +def _recorded_origin_for_row( + row: Mapping[str, Any], origins: Mapping[str, Any] | None +) -> str | None: + shpool_id = row.get("shpool_id_raw") + if not isinstance(shpool_id, str) or not isinstance(origins, Mapping): + return None + entry = (origins.get("sessions") or {}).get(shpool_id) + if not isinstance(entry, Mapping): + return None + # A name is reusable. Schema-1 stamps and early schema-2 stamps have no + # evidence tying them to this instance, so applying either would turn the + # old creator into the new one. They remain readable so their generation + # can still be retired by the ordinary pruner. + instance = entry.get("instance") + if not isinstance(instance, Mapping): + return None + shell = row.get("shpool_shell") + if not isinstance(shell, Mapping) or ( + shell.get("pid") != instance.get("shell_pid") + or shell.get("process_start_ticks") != instance.get("shell_start_ticks") + or row.get("started_at_unix_ms") != instance.get("started_at_unix_ms") + ): + return None + return recorded_origin(shpool_id, origins) + + +def _provider_is_bouncing(state_dir: Path, shpool_id: Any) -> bool: + """Whether the kit itself asked this session's provider to restart. + + A bounce relaunches the same conversation to repaint its title or theme, + so the window is briefly gone from a session a PERSON is sitting in. That + absence is the kit's own doing and says nothing about who drives the + session, so the driver verdict is not read while it lasts. + """ + if not isinstance(shpool_id, str) or not shpool_id or "/" in shpool_id: + return False + directory = state_dir / "provider-bounce" + # The request name, the reusable receipt written by already-live legacy + # shells, and every generation-specific receipt written by new shells all + # mean the same thing here: the kit removed this session's window itself. + markers = [directory / shpool_id, directory / f"{shpool_id}{TAKEN_SUFFIX}"] + try: + markers.extend( + marker + for marker in directory.iterdir() + if _bounce_session_id(marker.name, generated_only=True) == shpool_id + ) + except FileNotFoundError: + # No bounce directory is the ordinary pre-bounce state, not a failed + # reading of state that might contain a protective receipt. + pass + except OSError: + return True + for marker in markers: + try: + if marker.is_file() and not marker.is_symlink(): + return True + except OSError: + # State that cannot be read must not be what clears a session out + # of the list: treat it as bouncing, which suppresses the inference + # and leaves the row visible. + return True + return False + + +def apply_session_origins( + inventory: dict[str, Any], + *, + state_dir: Path, + clear_settled_bounces: bool = True, + settle_bounce_receipts: Iterable[str] = (), +) -> None: + """Stamp every row with who asked for it, before anything filters. + + Two fields come out of this. `origin` is what the row IS, and every + consumer that shows or filters rows reads it. `origin_recorded` is the + stamp and nothing but the stamp -- absent when this session was never + stamped -- and it is what anything DURABLE must read: a repair that + re-declares an origin, a close that writes the conversation's origin into + the ledger. Without that separation a verdict about who holds a socket + right now becomes a permanent fact about who created the session, and no + later refresh can take it back. + """ + # Before anything is read: a bounce whose replacement window is visible in + # THIS collection is over, and its marker must stop suppressing the + # verdict. This is the only place that can say so -- the shell that + # relaunches a provider blocks on it and cannot report its own success -- + # so the marker outlives the relaunch instead of being dropped inside it, + # and a positive sighting is what ends it. + # ...except on a reading that promised to change nothing. `--no-write` is + # used for deployment validation and by every `sp` guard snapshot, and it + # states that it will not so much as create a state directory; deleting a + # file there is worse than that, and it does it without the lock the write + # path holds. Skipping it costs nothing: the marker is cleared by the next + # collection that is allowed to write, and until then the row is visible. + if clear_settled_bounces: + clear_settled_bounce_markers( + inventory, + state_dir=state_dir, + settle_bounce_receipts=settle_bounce_receipts, + ) + try: + origins, trusted = read_origins(state_dir) + except (CollectionError, OSError): # pragma: no cover - read_origins catches + origins, trusted = _empty_document(), False + for row in inventory.get("sessions", ()): + if not isinstance(row, dict): + continue + shpool_id = row.get("shpool_id_raw") + stamped = _recorded_origin_for_row(row, origins) if trusted else None + row.pop("origin_recorded", None) + if stamped is not None: + # Provenance, in both directions. A session a person created stays + # a person's even on a refresh where no window happens to hold it. + row["origin"] = stamped + row["origin_recorded"] = stamped + elif ( + trusted + and row.get("machine_driven") is True + and not _provider_is_bouncing(state_dir, shpool_id) + ): + # Never stamped -- created before creators were recorded. The one + # proven reading available decides it. `trusted` is part of the + # condition because "never stamped" is a claim about the store: if + # the store did not read, nobody knows whether this session was + # stamped, and an unknown provenance is a person's. + row["origin"] = MACHINE + else: + row["origin"] = HUMAN + + +def capture_origin_generations(state_dir: Path) -> frozenset[tuple[str, str]]: + """Exact origin-entry generations present before process collection.""" + if not origins_path(state_dir).exists(): + return frozenset() + try: + with _origin_lock(state_dir): + document, trusted = read_origins(state_dir) + if not trusted: + return frozenset() + return frozenset( + (key, generation) + for key, entry in document["sessions"].items() + if isinstance(entry, Mapping) + and isinstance(generation := entry.get("record_generation"), str) + and bool(_ORIGIN_GENERATION_RE.fullmatch(generation)) + ) + except (CollectionError, OSError): + return frozenset() + + +def prune_origins( + state_dir: Path, + active_session_ids: Iterable[str], + *, + now_unix_ms: int | None = None, + retire_generations: Iterable[tuple[str, str]] = (), + retire_bounce_generations: Iterable[tuple[str, str]] = (), +) -> int: + """Forget the stamp once its session is PROVEN gone. + + Absence from one listing is not that proof in two shapes, and a stamp + deleted here is never rewritten -- nothing re-stamps a live session -- so + each wrong deletion costs that session its provenance for the rest of its + life and hands it to the driver. + + A collection can have read just before a new session attached, so a stamp + younger than the grace can still be absent from that older reading. And a + listing that names no sessions at all forgets nothing: it can empty + the store in a single pass, it is what a collection run against a stand-in + session manager produces, and no amount of elapsed time turns it into + proof that a live session died. + """ + active = set(active_session_ids) + now = int(time.time() * 1000) if now_unix_ms is None else now_unix_ms + # Same question, same key, same evidence: state about a session that no + # longer exists. Kept here so every caller of the one prune pass sweeps + # the bounce markers too, and a bounce interrupted by a kill cannot leave + # classification suppressed for an id nothing will ever consume again. + prune_bounce_markers( + state_dir, + active, + now_unix_ms=now, + retire_generations=retire_bounce_generations, + ) + allowed = frozenset(retire_generations) + if not active or not allowed or not origins_path(state_dir).exists(): + return 0 + with _origin_lock(state_dir): + try: + document, trusted = read_origins(state_dir) + except (CollectionError, OSError): # pragma: no cover - reader catches + return 0 + if not trusted: + # A store that did not read is not a store full of dead sessions. + return 0 + floor = now - ORIGIN_PRUNE_GRACE_MS + keep = { + key: entry + for key, entry in document["sessions"].items() + if key in active + or _stamped_after(entry, floor) + or (key, entry.get("record_generation")) not in allowed + } + removed = len(document["sessions"]) - len(keep) + if removed: + document["sessions"] = keep + atomic_write_private_json(origins_path(state_dir), document) + return removed + + +def capture_bounce_receipts(state_dir: Path) -> frozenset[str]: + """Exact generated receipt names that predate the next process reading. + + Receipt names are generations. Capturing them before collection binds a + later positive window sighting to the only receipts that sighting is old + enough to settle. A receipt created while collection is running has a new + name and is absent from this set. + + The reusable legacy ``<id>.taken`` name is deliberately excluded. Shells + that were already running when generated receipts shipped still create + it, and no deployed code can make that pathname immune to ABA replacement. + Leaving it in place errs visible until that shell's ordinary final cleanup + or the session-gone sweep removes it. + """ + directory = state_dir / "provider-bounce" + captured: set[str] = set() + try: + entries = list(directory.iterdir()) + except OSError: + return frozenset() + for marker in entries: + if _bounce_session_id(marker.name, generated_only=True) is None: + continue + try: + if marker.is_file() and not marker.is_symlink(): + captured.add(marker.name) + except OSError: + continue + return frozenset(captured) + + +def _bounce_cleanup_generation( + marker: Path, + *, + original_name: str | None = None, +) -> tuple[str, str] | None: + name = marker.name if original_name is None else original_name + session_id = _bounce_session_id(name, generated_only=True) + if session_id is not None or name.startswith(BOUNCE_GENERATION_RESERVATION_PREFIX): + return (name, name) + if _bounce_session_id(name) != name: + # Reusable legacy receipts and temporary files have no safe generation. + return None + try: + lines = marker.read_text(encoding="utf-8", errors="strict").splitlines() + except OSError: + return None + if len(lines) != 3 or not lines[2].isalnum(): + return None + return (name, lines[2]) + + +def capture_bounce_cleanup_generations( + state_dir: Path, +) -> frozenset[tuple[str, str]]: + """Exact marker generations eligible for a later session-gone sweep.""" + directory = state_dir / "provider-bounce" + try: + entries = list(directory.iterdir()) + except OSError: + return frozenset() + captured: set[tuple[str, str]] = set() + for marker in entries: + try: + if marker.is_symlink() or not marker.is_file(): + continue + generation = _bounce_cleanup_generation(marker) + if generation is not None: + captured.add(generation) + except OSError: + continue + return frozenset(captured) + + +def _retire_bounce_generation( + marker: Path, + allowed: frozenset[tuple[str, str]], +) -> bool: + """Move, verify, and either retire or restore one exact marker inode.""" + original_name = marker.name + temporary = marker.parent / f".retiring.{secrets.token_hex(16)}" + try: + marker.replace(temporary) + actual = _bounce_cleanup_generation( + temporary, + original_name=original_name, + ) + if actual in allowed: + temporary.unlink() + return True + try: + marker.hardlink_to(temporary) + except FileExistsError: + # A still-newer marker already owns the live name. + pass + temporary.unlink() + except OSError: + try: + if temporary.exists() and not marker.exists(): + marker.hardlink_to(temporary) + temporary.unlink(missing_ok=True) + except OSError: + pass + return False + + +def clear_settled_bounce_markers( + inventory: Mapping[str, Any], + *, + state_dir: Path, + settle_bounce_receipts: Iterable[str] = (), +) -> int: + """Drop the marker for every session whose window is visible again. + + A bounce ends when the replacement window exists, and nothing inside the + relaunch can observe that: the session shell is blocked on the provider it + just started. So the shell hands the question over by renaming the request + to a generated receipt, which leaves the session reading as bouncing for + as long as the answer is unknown. The answer is given here, by the one + reader that can see the window. + + Only a positive sighting clears it, and only for a bounce that has already + HAPPENED -- which is why this deletes the RENAMED marker and never the + original. A marker still under its first name is an instruction nobody has + carried out yet, and the window in sight beside it is the old one: the + picker writes the instruction, re-proves the session on a fresh collection, + and only then asks the window to exit, so the whole interval between the + request and the exit is a live window sitting next to a live instruction. + Deleting it there does not end a bounce, it CANCELS one -- the shell reaches + the marker it was told to read, finds nothing, takes the ordinary provider + exit instead of relaunching, and the session the person was sitting in + closes. + + A generated receipt name rather than one reusable second path binds this + unlink to the exact receipt captured before the process reading. Telling + generations apart by content, timestamp, or inode would still put a check + and an unlink around a reusable path, allowing a replacement between them. + + A bounce whose replacement never starts keeps its renamed marker, keeps its + row visible, and is forgotten only when the session itself is gone. + """ + allowed = frozenset(settle_bounce_receipts) + if not allowed: + return 0 + cleared = 0 + for row in inventory.get("sessions", ()): + if not isinstance(row, Mapping) or row.get("app_server_window") is not True: + continue + shpool_id = row.get("shpool_id_raw") + if not isinstance(shpool_id, str) or not shpool_id or "/" in shpool_id: + continue + prefix = f"{shpool_id}{TAKEN_GENERATION_SEPARATOR}" + for name in allowed: + # An exact generated name, captured before the sighting. The + # provider never reuses it, so unlinking this path cannot reach a + # receipt installed by a later bounce. + if ( + not name.startswith(prefix) + or _bounce_session_id(name, generated_only=True) != shpool_id + ): + continue + marker = state_dir / "provider-bounce" / name + try: + if marker.is_symlink() or not marker.is_file(): + continue + marker.unlink() + except OSError: + continue + cleared += 1 + return cleared + + +def _bounce_session_id(name: str, *, generated_only: bool = False) -> str | None: + """Session id carried by a request or either receipt-name generation.""" + if not isinstance(name, str) or not name or "/" in name: + return None + if name.startswith(BOUNCE_GENERATION_RESERVATION_PREFIX): + if generated_only: + return None + reserved = name[len(BOUNCE_GENERATION_RESERVATION_PREFIX) :] + if TAKEN_GENERATION_SEPARATOR not in reserved: + return None + session_id, generation = reserved.split(TAKEN_GENERATION_SEPARATOR, 1) + if session_id and generation and generation.isalnum(): + return session_id + return None + if TAKEN_GENERATION_SEPARATOR in name: + session_id, generation = name.split(TAKEN_GENERATION_SEPARATOR, 1) + if session_id and generation and generation.isalnum(): + return session_id + return None + if generated_only: + return None + if name.endswith(TAKEN_SUFFIX): + session_id = name[: -len(TAKEN_SUFFIX)] + return session_id or None + # Other dot-prefixed mktemp files are not requests and must age out as + # their own names, never be attributed to a live session accidentally. + return None if name.startswith(".") else name + + +def prune_bounce_markers( + state_dir: Path, + active_session_ids: Iterable[str], + *, + now_unix_ms: int | None = None, + retire_generations: Iterable[tuple[str, str]] = (), +) -> int: + """Forget a bounce marker whose session no longer exists. + + The session shell removes its own marker as it relaunches, so a marker + outliving its session means the shell never got there -- it was killed, or + the box went down mid-bounce. That marker can never be consumed again and + would sit in the state directory forever. + + Only markers for sessions the listing did not name are touched, only when + the listing named some session (an empty listing proves nothing), and only + after the grace, so a bounce in flight is never disarmed under a live + session -- disarming one is what puts a person's window in the machine + list while it restarts. + """ + active = set(active_session_ids) + allowed = frozenset(retire_generations) + if not active or not allowed: + return 0 + directory = state_dir / "provider-bounce" + now = int(time.time() * 1000) if now_unix_ms is None else now_unix_ms + floor = (now - ORIGIN_PRUNE_GRACE_MS) / 1000 + removed = 0 + try: + entries = list(directory.iterdir()) + except OSError: + return 0 + for marker in entries: + # A marker the shell has taken carries a second name, and it belongs to + # the same session -- swept as that session's, or a bounce in flight + # under a live session gets disarmed the moment it passes the grace. + name = _bounce_session_id(marker.name) + if name is None: + name = marker.name + if name in active or marker.is_symlink(): + continue + try: + if not marker.is_file() or marker.stat().st_mtime > floor: + continue + if not _retire_bounce_generation(marker, allowed): + continue + except OSError: + continue + removed += 1 + return removed + + +def _stamped_after(entry: Any, floor_unix_ms: int) -> bool: + if not isinstance(entry, Mapping): + return False + stamp = entry.get("at_unix_ms") + if isinstance(stamp, bool) or not isinstance(stamp, int): + return False + return stamp > floor_unix_ms diff --git a/lib/sessionkit_inventory/printed_selectors.py b/lib/sessionkit_inventory/printed_selectors.py new file mode 100644 index 0000000..a8224bf --- /dev/null +++ b/lib/sessionkit_inventory/printed_selectors.py @@ -0,0 +1,204 @@ +"""What a screen printed beside a row, so the word they type still means it. + +A selector is only worth typing if it still names the row it was printed +beside. Two of them cannot be checked by looking at them: `unnamed`, and the +clock face a shared name falls back to. Both are properties of the LIST, and +the list is rebuilt every time it is asked for -- so between printing a word +and acting on it, the word can quietly change conversations: + + sp recover -> @19:00 [Claude] Alpha [closed 1 day ago] + @20:00 [Claude] Alpha [closed 1 day ago] + + (the 19:00 conversation's transcript goes missing, so it can no longer + come back and loses its selector; a third Alpha closes the next day at + the same time of day and takes `@19:00` for itself) + + sp restore @19:00 -> Restored Alpha. + +They asked for one conversation, a different one opened, and the sentence they +was shown was true of the wrong one -- both rows are called Alpha, so nothing +on the screen could tell them. Rebinding is the point of the fallback: while a +name is shared, each row goes by its own event. What must not happen is a word +being rebound BETWEEN the screen and the action. + +So a surface that prints the list records what it printed beside each row, and +an action checks the word it was given against that record. Same conversation, +or refuse -- and refuse in a sentence, never a restore that reports success. + +The record is deliberately dumber than the projection: it holds the token, the +conversation it stood for, and when it was printed. It is not a lock, not a +queue and not a second list; it cannot make a wrong restore right, it can only +stop a word from changing its mind. What it does not know, it says so about -- +a token nobody has printed on this machine is not evidence of anything, and +the caller is told exactly that rather than a guess. + +Where it stops, exactly, since a check that overstates itself is worse than +none. Two screens printed at the same instant merge one into the other, and +the token count is bounded, so a binding can be lost; a lost binding reads as +never printed, which the caller is allowed to act on. And a second screen +printed between the one they read and the word they type is a screen this machine +did show them, so its meaning is the one that stands. Neither is the reported +failure -- that one is a list rebuilt by the restore itself, with no screen in +between -- and both are the same direction the surfaces already fail in: they +lose a check, never gain a wrong one. +""" + +from __future__ import annotations + +import json +from pathlib import Path +import time +from typing import Any, Iterable, Mapping + +from .common import valid_uuid +from .state_io import atomic_write_json + + +SCHEMA_VERSION = 1 + +# How many printed words are remembered. Every screen prints at most the whole +# recovery list, which is itself capped, and a redraw of the same list rewrites +# the same tokens rather than adding any -- so this holds several distinct +# screens, not several minutes. The oldest go first. +MAX_REMEMBERED = 2000 + +# The three answers a check can have. UNKNOWN is not a quiet yes: it says this +# machine has no record of that word ever being printed, and the caller decides +# what to do about that. +AGREES = "agrees" +DISAGREES = "disagrees" +UNKNOWN = "unknown" + + +def printed_selectors_path(state_dir: Any) -> Path: + return Path(state_dir) / "recovery-selectors-printed.json" + + +def folded(token: Any) -> str: + """A typed word and a printed one, reduced to the same thing. + + Both surfaces already match case-insensitively on runs of whitespace + collapsed to one space; the record has to fold a word exactly the way the + matcher does, or it would answer about a different string than the one the + action resolved. + """ + return " ".join(str(token or "").split()).casefold() + + +def load_printed(state_dir: Any) -> dict[str, dict[str, Any]]: + """Every word this machine has printed, and what it stood for. + + A record that cannot be read is not a claim that a word means something + else; it is no record at all. Every malformed shape is dropped rather than + trusted, and a caller that gets nothing back is told UNKNOWN, never AGREES. + """ + try: + with printed_selectors_path(state_dir).open(encoding="utf-8") as handle: + document = json.load(handle) + except (OSError, ValueError): + return {} + if not isinstance(document, Mapping): + return {} + recorded = document.get("selectors") + if not isinstance(recorded, Mapping): + return {} + known: dict[str, dict[str, Any]] = {} + for token, binding in recorded.items(): + if not isinstance(token, str) or not isinstance(binding, Mapping): + continue + provider = binding.get("provider") + exact = valid_uuid(binding.get("uuid")) or "" + when = binding.get("printed_at_unix_ms") + if not isinstance(provider, str) or not provider or not exact: + continue + known[folded(token)] = { + "provider": provider, + "uuid": exact, + "printed_at_unix_ms": ( + when + if isinstance(when, int) and not isinstance(when, bool) and when > 0 + else 0 + ), + } + return known + + +def remember_printed( + state_dir: Any, + rows: Iterable[Any], + *, + now_unix_ms: int | None = None, +) -> dict[str, Any]: + """Record the word each row was printed under, on the screen just drawn. + + Later prints win: a word that legitimately moved to another row -- which is + what the fallback is FOR -- is the word they have just been shown, and the + screen they have just been shown is the one they are about to type at. Words that + were not on this screen keep the row they were last printed beside, so a + conversation that leaves the list does not quietly release its word to the + next one. + """ + stamp = ( + now_unix_ms + if isinstance(now_unix_ms, int) and not isinstance(now_unix_ms, bool) + else int(time.time() * 1000) + ) + known = load_printed(state_dir) + printed = 0 + for row in rows or (): + if not isinstance(row, Mapping): + continue + token = folded(row.get("selector")) + provider = row.get("provider") + exact = valid_uuid(row.get("uuid")) or "" + # A row with no selector offers no word to type, and a row with no + # conversation has nothing a word could stand for. + if not token or not isinstance(provider, str) or not provider or not exact: + continue + known[token] = { + "provider": provider, + "uuid": exact, + "printed_at_unix_ms": stamp, + } + printed += 1 + if len(known) > MAX_REMEMBERED: + newest = sorted( + known.items(), + key=lambda item: (item[1]["printed_at_unix_ms"], item[0]), + reverse=True, + )[:MAX_REMEMBERED] + known = dict(newest) + directory = Path(state_dir) + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + atomic_write_json( + printed_selectors_path(directory), + {"schema_version": SCHEMA_VERSION, "selectors": known}, + ) + return {"remembered": printed, "known": len(known)} + + +def check_printed( + state_dir: Any, token: Any, provider: Any, uuid: Any +) -> dict[str, Any]: + """Does this word still name the conversation it was printed beside? + + AGREES and DISAGREES are about a word this machine has actually printed. + UNKNOWN is the honest third answer: nothing here has ever shown them that + word, so there is no earlier meaning for it to have drifted from. + """ + wanted = folded(token) + exact = valid_uuid(uuid) or "" + binding = load_printed(state_dir).get(wanted) + if binding is None: + return {"token": wanted, "verdict": UNKNOWN} + same = ( + isinstance(provider, str) + and binding["provider"] == provider + and bool(exact) + and binding["uuid"].casefold() == exact.casefold() + ) + return { + "token": wanted, + "verdict": AGREES if same else DISAGREES, + "printed_at_unix_ms": binding["printed_at_unix_ms"], + } diff --git a/lib/sessionkit_inventory/private_store.py b/lib/sessionkit_inventory/private_store.py new file mode 100644 index 0000000..35cfb9c --- /dev/null +++ b/lib/sessionkit_inventory/private_store.py @@ -0,0 +1,143 @@ +"""Owner-private JSON records on disk, shared by the worktree and receipt stores. + +Both stores keep small documents the operator alone may read or change, and +both are read back by code that acts on them: a worktree record decides which +directory `sp` is allowed to remove, and a receipt decides whether a worker is +allowed to keep spending. A record that exists but cannot be trusted, a +symlink, another owner's file, a group-readable one, one larger than its +subsystem ever writes, is an error here, never a silent absence. Answering +"no such record" for a damaged file is how a live worktree gets orphaned or a +breached cap gets forgotten. + +The write path is the one intake.py established: temp file in the destination +directory, 0600 before any bytes, fsync, atomic rename, fsync of the parent +directory. +""" + +from __future__ import annotations + +import contextlib +import json +import os +from pathlib import Path +import stat +import tempfile +from typing import Any + + +class PrivateStoreError(ValueError): + """A private record is unreadable, untrusted, or malformed.""" + + +def private_directory(path: Path, *, label: str) -> Path: + """Create or adopt one mode-0700 directory owned by this user.""" + path.mkdir(mode=0o700, parents=True, exist_ok=True) + info = path.lstat() + if ( + not stat.S_ISDIR(info.st_mode) + or stat.S_IMODE(info.st_mode) != 0o700 + or info.st_uid != os.geteuid() + ): + raise PrivateStoreError( + f"{label} must be a mode-0700 current-owner directory: {path}" + ) + return path + + +def check_private_file(path: Path, info: os.stat_result, *, label: str) -> None: + if ( + path.is_symlink() + or not stat.S_ISREG(info.st_mode) + or stat.S_IMODE(info.st_mode) & 0o077 + or info.st_uid != os.geteuid() + ): + raise PrivateStoreError(f"{label} must be an owner-private file: {path}") + + +def atomic_private_write(path: Path, payload: bytes) -> None: + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb") as handle: + descriptor = -1 + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if descriptor >= 0: + os.close(descriptor) + with contextlib.suppress(FileNotFoundError): + os.unlink(temporary) + + +def read_private_json(path: Path, *, limit: int, label: str) -> dict[str, Any] | None: + """One private JSON object, or None when the file does not exist.""" + try: + info = path.lstat() + except FileNotFoundError: + return None + check_private_file(path, info, label=label) + if info.st_size > limit: + raise PrivateStoreError(f"{label} exceeds {limit} bytes: {path}") + try: + raw = path.read_bytes() + except OSError as exc: + raise PrivateStoreError(f"cannot read {label}: {path}") from exc + try: + document = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise PrivateStoreError(f"{label} is not valid JSON: {path}") from exc + if not isinstance(document, dict): + raise PrivateStoreError(f"{label} must be a JSON object: {path}") + return document + + +def write_private_json(path: Path, document: dict[str, Any]) -> dict[str, Any]: + atomic_private_write(path, canonical_bytes(document) + b"\n") + return document + + +def canonical_bytes(document: dict[str, Any]) -> bytes: + """One byte-for-byte reproducible encoding, so a digest means something.""" + return json.dumps( + document, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ).encode("utf-8") + + +def private_names( + directory: Path, suffix: str, *, limit: int, strict: bool = False +) -> list[str]: + """Sorted record names in one private directory, bounded and symlink-free. + + ``strict`` refuses a directory holding more records than the bound instead + of returning the first ``limit`` of them. A caller that sums the records + (spend against a cap) has to refuse, because a truncated list reads as a + smaller total and a cap that under-counts is not a cap. + """ + try: + entries = sorted(os.listdir(directory)) + except FileNotFoundError: + return [] + except OSError as exc: + raise PrivateStoreError(f"cannot list {directory}") from exc + names: list[str] = [] + for entry in entries: + if not entry.endswith(suffix) or entry.startswith("."): + continue + if (directory / entry).is_symlink(): + continue + names.append(entry[: -len(suffix)] if suffix else entry) + if len(names) > limit: + if strict: + raise PrivateStoreError( + f"{directory} holds more than {limit} records; " + "refusing a truncated read" + ) + return names[:limit] + return names diff --git a/lib/sessionkit_inventory/processes.py b/lib/sessionkit_inventory/processes.py new file mode 100644 index 0000000..e7c899d --- /dev/null +++ b/lib/sessionkit_inventory/processes.py @@ -0,0 +1,1000 @@ +"""Bounded native process discovery, ancestry, generations, and boot identity.""" + +from __future__ import annotations + +import ctypes +import ctypes.util +from enum import Enum +import os +from pathlib import Path +import sys +from typing import Any, Callable, Iterable, Mapping, Sequence + +from .common import CollectionError, clean_text, proc_root as default_proc_root + + +DARWIN_PLATFORM = "darwin" + + +def _runtime_platform( + *, + environ: Mapping[str, str], + platform_name: str, +) -> str: + """Return the real platform, with a test-only override for Linux fixtures.""" + override = environ.get("SESSION_KIT_TEST_PLATFORM") + if environ.get("SESSION_KIT_TESTING") == "1" and override: + return override.casefold() + return platform_name + + +def _require_supported_platform(*, runtime_platform: Callable[[], str]) -> str: + platform = runtime_platform() + if platform != DARWIN_PLATFORM and not platform.startswith("linux"): + raise CollectionError(f"unsupported platform: {platform}") + return platform + + +def _proc_stat(path: Path) -> tuple[int, int, int]: + raw = path.read_text(encoding="utf-8", errors="replace") + left = raw.find("(") + right = raw.rfind(")") + if left < 1 or right < left: + raise ValueError("malformed stat") + pid = int(raw[:left].strip()) + fields = raw[right + 2 :].split() + return pid, int(fields[1]), int(fields[19]) + + +def _proc_environ(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + for entry in path.read_bytes().split(b"\0"): + if b"=" not in entry: + continue + key, value = entry.split(b"=", 1) + try: + values[key.decode("utf-8")] = value.decode("utf-8", errors="replace") + except UnicodeDecodeError: + continue + return values + + +def _readable_by_us(entry: Path) -> bool | None: + """Whether this process could read ``entry``'s restricted proc files. + + ``/proc/<pid>/environ`` and ``/proc/<pid>/cwd`` are readable only by the + process owner, so for any other uid the read always fails and the caller + falls back to the same empty values this guard returns. Skipping it keeps a + scan from making one denied syscall per foreign process, which on a busy + host with auditing on failed opens is a lot of log for no information. + + Root is exempt, so it keeps reading everything. ``None`` preserves a failed + ownership check as unknown instead of turning it into a foreign process. + """ + if os.getuid() == 0: + return True + try: + return entry.stat().st_uid == os.getuid() + except OSError: + return None + + +class ProcessTable(dict): + """A process table that remembers whether it saw everything. + + Most readers ask "is this pid in the table?" and act on the answer. One + reader asks the opposite -- "is NOTHING in this tree holding that socket?" + -- and a table with a hole in it answers that question wrongly and + silently, because a process the scan lost is indistinguishable from a + process that was never there. Where the parent is known the loss is + recorded in place (see `_unreadable_process`); where even that could not be + read, the pid cannot be attributed to any tree, so the only honest record + is that this reading of the machine is not complete. + + It is a plain dict in every other respect, so a caller that never asks + about absence is unaffected, and a hand-built fixture with no flag reads as + complete, which is what a fixture is. + """ + + complete: bool = True + + +def table_is_complete(process_table: Mapping[int, Mapping[str, Any]]) -> bool: + """Whether this reading of the process table saw every process.""" + return bool(getattr(process_table, "complete", True)) + + +def _unreadable_process(pid: int, ppid: int) -> dict[str, Any]: + """A process that exists, placed in the tree, saying nothing about itself. + + Every field a reader might match on is empty, so nothing here can be + mistaken for evidence; `argv_unreadable` is the one positive statement, + and it is what a reader needs in order to refuse a verdict that rests on + absence. The parent is published because placing the process in the right + tree is the whole point -- a process nobody can attribute is a process + whose absence somebody will read as proof. + + The start time is deliberately impossible. This entry is published exactly + when identity could NOT be confirmed, and every verifier in the kit + compares a recorded start tick against the live one before it acts on a + process; a stale-but-plausible value would let one of those confirm an + identity that no longer exists. -1 matches nothing, so they all refuse. + """ + return { + "pid": pid, + "ppid": ppid, + "start_ticks": -1, + "cmdline": [], + "comm": "", + "cwd": "", + "argv_unreadable": True, + "environ_unreadable": True, + "session_name": "", + "claude_config_dir": "", + "codex_home": "", + "account_alias": "", + "account_capable": "", + } + + +def _unreadable_shpool_process(pid: int, ppid: int) -> dict[str, Any]: + """An exact shpool name whose argv and generation cannot be trusted.""" + process = _unreadable_process(pid, ppid) + process["comm"] = "shpool" + process["args_available"] = False + return process + + +def scan_process_table( + proc_root: Path, + max_nodes: int, + *, + proc_stat: Callable[[Path], tuple[int, int, int]], + proc_environ: Callable[[Path], dict[str, str]], +) -> dict[int, dict[str, Any]]: + """Read one bounded process-table view from proc_root.""" + table = ProcessTable() + try: + entries = sorted( + (entry for entry in proc_root.iterdir() if entry.name.isdigit()), + key=lambda item: int(item.name), + ) + except OSError as exc: + raise CollectionError(f"cannot enumerate {proc_root}: {exc}") from exc + if len(entries) > max_nodes: + raise CollectionError( + f"{proc_root} has {len(entries)} processes, " + f"above max_proc_nodes={max_nodes}" + ) + for entry in entries: + pid = int(entry.name) + try: + before_pid, ppid, start_ticks = proc_stat(entry / "stat") + if before_pid != pid: + # The directory entry and the file disagree about which + # process this is; nothing read here can be attributed. + table.complete = False + continue + except (OSError, ValueError): + # It was listed a moment ago and its stat will not read, so its + # parent is unknown and it cannot be placed in any tree. Unlike + # the cases below there is no slot to leave behind -- the only + # honest record is that this reading of the machine has a hole in + # it, and a reader that would conclude "nothing in this tree holds + # that socket" must not conclude it from a table with a hole. + table.complete = False + continue + try: + comm = clean_text( + (entry / "comm").read_text(encoding="utf-8", errors="replace"), 128 + ) + except (OSError, ValueError): + # A process whose stat read but whose argv did not is one that + # EXISTS and will not say what it is. Dropped, it left the table + # reading "no such process", so a reader asking "is anything in + # this tree holding that socket?" got "no" about a process it + # never saw, and answering that question with "no" is what puts + # a person's session behind the machine count. It stays, placed by + # its parent, with the ambiguity recorded the same way an + # unreadable environment is. + table[pid] = _unreadable_process(pid, ppid) + continue + try: + cmdline = [ + value.decode("utf-8", errors="replace") + for value in (entry / "cmdline").read_bytes().split(b"\0") + if value + ] + args_available = True + except OSError: + # Keep only the one unreadable-argv process shape that affects + # daemon selection. Retaining every process whose arguments are + # hidden would turn an ordinary large process table into a field + # of false daemon candidates; an exact comm=shpool row is the + # bounded uncertainty this collector must carry forward. + if comm != "shpool": + table[pid] = _unreadable_process(pid, ppid) + continue + cmdline = [] + args_available = False + # An own-uid environment that will not read is not the same fact as an + # empty one: `environ` needs PTRACE_MODE_READ, so a hardening change + # (yama, a container policy) can deny it for a live managed shell. Left + # indistinguishable, that shell looks like a session with no process at + # all, the exact state the auto-close engine treats as "nothing to + # preserve". Record the ambiguity and let the readers refuse. + environ_unreadable = False + try: + owner_uid: int | None = entry.stat().st_uid + except OSError: + owner_uid = None + readable = _readable_by_us(entry) + if readable: + try: + environ = proc_environ(entry / "environ") + except OSError: + environ = {} + environ_unreadable = True + try: + cwd = os.readlink(entry / "cwd") + except OSError: + cwd = "" + else: + # Same result the OSError branches produce, without the denied + # syscall. A shpool session is always our own uid, so a foreign + # process can never supply a session name anyway. + environ = {} + cwd = "" + if readable is None: + environ_unreadable = True + try: + after_pid, after_ppid, after_start_ticks = proc_stat(entry / "stat") + except (OSError, ValueError): + # It was there when the walk started and it is not answering now. + # The fields already read may describe a process that has since + # exec'd into something else, so they are not published, but the + # slot is, because "it exited" and "it exec'd into the window" are + # the same reading from here. + table[pid] = ( + _unreadable_shpool_process(pid, ppid) + if not args_available + else _unreadable_process(pid, ppid) + ) + continue + if (before_pid, ppid, start_ticks) != ( + after_pid, + after_ppid, + after_start_ticks, + ): + table[pid] = ( + _unreadable_shpool_process(pid, ppid) + if not args_available + else _unreadable_process(pid, ppid) + ) + continue + table[pid] = { + "pid": pid, + "ppid": ppid, + "start_ticks": start_ticks, + "cmdline": cmdline, + "comm": comm, + "cwd": cwd, + # Which account owns the process. Read here, while /proc/<pid> is + # in hand, so daemon selection can rule out another account's + # shpool without a second pass over the process table. + "uid": owner_uid, + "environ_unreadable": environ_unreadable, + "session_name": environ.get("SHPOOL_SESSION_NAME", ""), + "claude_config_dir": environ.get("CLAUDE_CONFIG_DIR", ""), + "codex_home": environ.get("CODEX_HOME", ""), + "account_alias": environ.get("SESSION_KIT_ACCOUNT_ALIAS", ""), + "account_capable": environ.get("SESSION_KIT_ACCOUNT_CAPABLE", ""), + } + if not args_available: + table[pid]["args_available"] = False + table[pid]["argv_unreadable"] = True + return table + + +class _DarwinBsdInfo(ctypes.Structure): + """Stable prefix and start fields from Darwin's ``struct proc_bsdinfo``.""" + + _fields_ = [ + ("pbi_flags", ctypes.c_uint32), + ("pbi_status", ctypes.c_uint32), + ("pbi_xstatus", ctypes.c_uint32), + ("pbi_pid", ctypes.c_uint32), + ("pbi_ppid", ctypes.c_uint32), + ("pbi_uid", ctypes.c_uint32), + ("pbi_gid", ctypes.c_uint32), + ("pbi_ruid", ctypes.c_uint32), + ("pbi_rgid", ctypes.c_uint32), + ("pbi_svuid", ctypes.c_uint32), + ("pbi_svgid", ctypes.c_uint32), + ("rfu_1", ctypes.c_uint32), + ("pbi_comm", ctypes.c_char * 16), + ("pbi_name", ctypes.c_char * 32), + ("pbi_nfiles", ctypes.c_uint32), + ("pbi_pgid", ctypes.c_uint32), + ("pbi_pjobc", ctypes.c_uint32), + ("e_tdev", ctypes.c_uint32), + ("e_tpgid", ctypes.c_uint32), + ("pbi_nice", ctypes.c_int32), + ("pbi_start_tvsec", ctypes.c_uint64), + ("pbi_start_tvusec", ctypes.c_uint64), + ] + + +class _DarwinTimeval(ctypes.Structure): + _fields_ = [ + ("tv_sec", ctypes.c_long), + ("tv_usec", ctypes.c_int), + ] + + +def _parse_darwin_procargs2(payload: bytes) -> tuple[list[str], dict[str, str]]: + """Parse a KERN_PROCARGS2 payload without trusting text delimiters.""" + integer_size = ctypes.sizeof(ctypes.c_int) + if len(payload) < integer_size: + raise ValueError("short KERN_PROCARGS2 payload") + argc = int.from_bytes(payload[:integer_size], sys.byteorder, signed=True) + if argc < 0 or argc > 65536: + raise ValueError("invalid KERN_PROCARGS2 argc") + values = payload[integer_size:].split(b"\0") + if not values: + raise ValueError("missing KERN_PROCARGS2 executable") + # First value is the executable path. Darwin may insert extra NUL padding + # before argv[0]. + index = 1 + while index < len(values) and not values[index]: + index += 1 + if len(values) - index < argc: + raise ValueError("truncated KERN_PROCARGS2 argv") + argv = [ + value.decode("utf-8", errors="replace") + for value in values[index : index + argc] + ] + environ: dict[str, str] = {} + for raw in values[index + argc :]: + if not raw or b"=" not in raw: + continue + key, value = raw.split(b"=", 1) + try: + name = key.decode("utf-8") + except UnicodeDecodeError: + continue + environ[name] = value.decode("utf-8", errors="replace") + return argv, environ + + +def _darwin_libraries(*, runtime_platform: Callable[[], str]) -> tuple[Any, Any]: + if runtime_platform() != DARWIN_PLATFORM: + raise CollectionError("Darwin native process APIs are unavailable") + libc = ctypes.CDLL(ctypes.util.find_library("c") or "libc.dylib", use_errno=True) + libproc = ctypes.CDLL( + ctypes.util.find_library("proc") or "/usr/lib/libproc.dylib", + use_errno=True, + ) + libc.sysctl.argtypes = [ + ctypes.POINTER(ctypes.c_int), + ctypes.c_uint, + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_size_t), + ctypes.c_void_p, + ctypes.c_size_t, + ] + libc.sysctl.restype = ctypes.c_int + libc.sysctlbyname.argtypes = [ + ctypes.c_char_p, + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_size_t), + ctypes.c_void_p, + ctypes.c_size_t, + ] + libc.sysctlbyname.restype = ctypes.c_int + libproc.proc_listallpids.argtypes = [ctypes.c_void_p, ctypes.c_int] + libproc.proc_listallpids.restype = ctypes.c_int + libproc.proc_pidinfo.argtypes = [ + ctypes.c_int, + ctypes.c_int, + ctypes.c_uint64, + ctypes.c_void_p, + ctypes.c_int, + ] + libproc.proc_pidinfo.restype = ctypes.c_int + return libc, libproc + + +def _darwin_procargs2(libc: Any, pid: int) -> bytes: + # CTL_KERN, KERN_PROCARGS2, pid + mib = (ctypes.c_int * 3)(1, 49, pid) + size = ctypes.c_size_t(0) + if libc.sysctl(mib, 3, None, ctypes.byref(size), None, 0) != 0: + raise OSError(ctypes.get_errno(), "KERN_PROCARGS2 size") + if size.value <= 0 or size.value > 16 * 1024 * 1024: + raise OSError("unsafe KERN_PROCARGS2 size") + buffer = ctypes.create_string_buffer(size.value) + if libc.sysctl(mib, 3, buffer, ctypes.byref(size), None, 0) != 0: + raise OSError(ctypes.get_errno(), "KERN_PROCARGS2 read") + return buffer.raw[: size.value] + + +def _darwin_bsd_info(libproc: Any, pid: int) -> _DarwinBsdInfo: + info = _DarwinBsdInfo() + # PROC_PIDTBSDINFO + received = libproc.proc_pidinfo(pid, 3, 0, ctypes.byref(info), ctypes.sizeof(info)) + if received != ctypes.sizeof(info): + raise OSError(ctypes.get_errno(), "PROC_PIDTBSDINFO") + return info + + +def _darwin_generation(info: _DarwinBsdInfo) -> int: + seconds = int(info.pbi_start_tvsec) + microseconds = int(info.pbi_start_tvusec) + if seconds <= 0 or microseconds < 0 or microseconds >= 1_000_000: + raise ValueError("invalid Darwin process start time") + return seconds * 1_000_000 + microseconds + + +def _decode_c_string(value: bytes) -> str: + return value.split(b"\0", 1)[0].decode("utf-8", errors="replace") + + +def scan_darwin_process_table( + max_nodes: int, + *, + pids: Sequence[int] | None = None, + bsd_reader: Callable[[int], _DarwinBsdInfo] | None = None, + args_reader: Callable[[int], bytes] | None = None, + darwin_libraries: Callable[[], tuple[Any, Any]], + darwin_bsd_info: Callable[[Any, int], _DarwinBsdInfo], + darwin_procargs2: Callable[[Any, int], bytes], + darwin_generation: Callable[[_DarwinBsdInfo], int], + parse_darwin_procargs2: Callable[[bytes], tuple[list[str], dict[str, str]]], + decode_c_string: Callable[[bytes], str], +) -> dict[int, dict[str, Any]]: + """Read one bounded Darwin process view using libproc and KERN_PROCARGS2.""" + libc: Any = None + libproc: Any = None + if pids is None or bsd_reader is None or args_reader is None: + libc, libproc = darwin_libraries() + if pids is None: + capacity = max_nodes + 1 + buffer = (ctypes.c_int * capacity)() + count = libproc.proc_listallpids(buffer, ctypes.sizeof(buffer)) + if count < 0: + raise CollectionError("cannot enumerate Darwin processes") + if count > max_nodes: + raise CollectionError( + f"Darwin has more than max_proc_nodes={max_nodes} processes" + ) + pids = [int(buffer[index]) for index in range(count) if buffer[index] > 0] + unique_pids = sorted(set(pids)) + if len(unique_pids) > max_nodes: + raise CollectionError( + f"Darwin has {len(unique_pids)} processes, above max_proc_nodes={max_nodes}" + ) + read_bsd = bsd_reader or (lambda pid: darwin_bsd_info(libproc, pid)) + read_args = args_reader or (lambda pid: darwin_procargs2(libc, pid)) + table = ProcessTable() + for pid in unique_pids: + try: + before = read_bsd(pid) + generation = darwin_generation(before) + except (OSError, ValueError): + # Same rule as the Linux scan: with no parent there is no tree to + # place this in, so the reading of the machine is incomplete and + # nobody may conclude an absence from it. + table.complete = False + continue + args_available = True + try: + argv, environ = parse_darwin_procargs2(read_args(pid)) + except (OSError, ValueError): + argv, environ = [], {} + args_available = False + try: + after = read_bsd(pid) + after_generation = darwin_generation(after) + except (OSError, ValueError): + table[pid] = _unreadable_process(pid, int(before.pbi_ppid)) + continue + before_identity = (int(before.pbi_pid), int(before.pbi_ppid), generation) + after_identity = ( + int(after.pbi_pid), + int(after.pbi_ppid), + after_generation, + ) + if before_identity != after_identity or before_identity[0] != pid: + table[pid] = _unreadable_process(pid, int(before.pbi_ppid)) + continue + table[pid] = { + "pid": pid, + "ppid": before_identity[1], + "start_ticks": generation, + "generation_kind": "darwin-start-usec", + "args_available": args_available, + # Darwin's argv read is denied for other uids and for hardened + # processes, so "no argv" here is routine. It is still an argv + # nobody read, and a reader deciding an absence must refuse it for + # the same reason it refuses one on Linux. + "argv_unreadable": not args_available, + "cmdline": argv, + "comm": clean_text( + decode_c_string(bytes(before.pbi_name)) + or decode_c_string(bytes(before.pbi_comm)), + 128, + ), + "cwd": ( + environ.get("PWD", "") if environ.get("PWD", "").startswith("/") else "" + ), + "session_name": environ.get("SHPOOL_SESSION_NAME", ""), + "codex_thread_id": environ.get("CODEX_THREAD_ID", ""), + "claude_session_id": environ.get("CLAUDE_SESSION_ID", ""), + "claude_config_dir": environ.get("CLAUDE_CONFIG_DIR", ""), + "codex_home": environ.get("CODEX_HOME", ""), + "account_alias": environ.get("SESSION_KIT_ACCOUNT_ALIAS", ""), + "account_capable": environ.get("SESSION_KIT_ACCOUNT_CAPABLE", ""), + } + return table + + +def platform_process_table( + proc_root: Path, + max_nodes: int, + *, + require_supported_platform: Callable[[], str], + darwin_process_table: Callable[[int], dict[int, dict[str, Any]]], + linux_process_table: Callable[[Path, int], dict[int, dict[str, Any]]], +) -> dict[int, dict[str, Any]]: + platform = require_supported_platform() + if platform == DARWIN_PLATFORM: + return darwin_process_table(max_nodes) + return linux_process_table(proc_root, max_nodes) + + +def _children_index( + process_table: Mapping[int, Mapping[str, Any]], +) -> dict[int, list[int]]: + children: dict[int, list[int]] = {} + for pid, process in process_table.items(): + children.setdefault(int(process.get("ppid", 0)), []).append(pid) + for values in children.values(): + values.sort() + return children + + +def _is_shpool_daemon(process: Mapping[str, Any]) -> bool: + argv = process.get("cmdline") or [] + executable = Path(argv[0]).name if argv else process.get("comm", "") + return executable == "shpool" and "daemon" in argv[1:] + + +def _is_unknown_shpool_daemon(process: Mapping[str, Any]) -> bool: + """Whether unreadable argv leaves an exact shpool comm as a candidate.""" + return ( + process.get("args_available") is False + and not process.get("cmdline") + and clean_text(process.get("comm"), 128) == "shpool" + ) + + +def _kit_socket_path(environ: Mapping[str, str]) -> str | None: + """Where a client started the way this kit starts one connects. + + The pinned shpool resolves ``--socket``, then + ``$XDG_RUNTIME_DIR/shpool/shpool.socket``, then + ``~/.local/run/shpool/shpool.socket``. It does NOT read ``SHPOOL_SOCKET``: + that was checked against the pinned source and the installed binary after a + reviewer showed an earlier version of this code inventing support for it. + This kit passes no ``--socket``, so its client lands on one of the last two. + """ + runtime_dir = environ.get("XDG_RUNTIME_DIR") or "" + if runtime_dir: + return os.path.normpath(f"{runtime_dir}/shpool/shpool.socket") + home = environ.get("HOME") or "" + if home: + return os.path.normpath(f"{home}/.local/run/shpool/shpool.socket") + return None + + +# `/proc/net/unix` flags carry SO_ACCEPTCON for a socket that is listening. +# Connected peers of a unix socket appear on their own rows carrying the same +# pathname, so without this the first row for a path is as likely to be a +# conversation with the daemon as the daemon's own listener. +_ACCEPTCON = 0x10000 + + +def _listening_inodes(path: str, proc_root: Path) -> set[int] | None: + """Every listening unix socket bound at ``path``, per the kernel. + + A pathname is not unique in this table. A daemon whose socket file was + replaced keeps its row while a new daemon binds the same path, and every + connected peer repeats the pathname too. Reading only the first match let a + stranger's inode stand in for ours, so all of them are collected and the + caller refuses if they do not agree on one holder. ``None`` means the + kernel table could not be read completely. + """ + inodes: set[int] = set() + try: + raw = (proc_root / "net" / "unix").read_text(encoding="utf-8", errors="replace") + except OSError: + return None + # proc's sequence-file records are newline terminated. An unterminated + # final record means the table was not read whole, so an earlier matching + # row cannot be treated as the only listener. + if not raw or not raw.endswith("\n"): + return None + lines = raw.splitlines() + if not lines or len(lines[0].split()) < 7: + return None + for line in lines[1:]: + fields = line.split(None, 7) + if len(fields) < 7: + return None + # Abstract and unnamed sockets legitimately have no pathname column. + if len(fields) == 7: + continue + # The pathname is the remainder of the line: it may contain spaces. + if os.path.normpath(fields[7].rstrip("\n")) != path: + continue + try: + if not int(fields[3], 16) & _ACCEPTCON: + continue + inodes.add(int(fields[6])) + except ValueError: + return None + return inodes + + +class _SocketHolding(Enum): + HOLDS = "holds" + DOES_NOT_HOLD = "does-not-hold" + UNKNOWN = "unknown" + + +def _holds_any_socket(pid: int, inodes: set[int], proc_root: Path) -> _SocketHolding: + targets = {f"socket:[{inode}]" for inode in inodes} + try: + entries = list((proc_root / str(pid) / "fd").iterdir()) + except OSError: + return _SocketHolding.UNKNOWN + unreadable = False + for entry in entries: + try: + if os.readlink(entry) in targets: + return _SocketHolding.HOLDS + except OSError: + unreadable = True + return _SocketHolding.UNKNOWN if unreadable else _SocketHolding.DOES_NOT_HOLD + + +def _owned_by_this_account(process: Mapping[str, Any]) -> bool: + """Whether this process could possibly be serving THIS account's kit. + + A shpool daemon answers on a listener under ``/run/user/<uid>``, which the + kernel keeps mode 0700 for that uid alone, so a daemon running as another + account is never ours no matter what it is called. Without this question, + a second account's daemon on the same host was a permanent outage: its + ``/proc/<pid>/fd`` is unreadable, the socket-holder check answered + "unknown", the uniqueness rule then refused to name any daemon, and every + session on the board became unprovable (found live on a shared host, + 2026-08-17). + + The uid comes from the census row, which read it from ``/proc/<pid>`` + while it was there. A row without one, an old snapshot, a hand-built + table, is a candidate as before: an unproven guess about a process is + not evidence against it. + """ + uid = process.get("uid") + if isinstance(uid, bool) or not isinstance(uid, int): + return True + return uid == os.getuid() + + +def _kit_daemons( + process_table: Mapping[int, Mapping[str, Any]], + is_shpool_daemon: Callable[[Mapping[str, Any]], bool], + *, + environ: Mapping[str, str] | None = None, + proc_root: Path | None = None, + require_socket_holder: bool = False, +) -> list[int]: + """Which of the running daemons is the one serving this kit. + + Counting every process named ``shpool`` was the wrong question: on 15 August + a review lane's own sandboxed daemon, on its own socket and holding nothing, + left the kit unable to identify a single one of the operator's ten live + sessions. + + Two earlier answers were wrong in ways reviewers proved. Resolving a name + among the children of ANY daemon let a stranger's process take over one of + their rows. Inferring each daemon's socket from its command line and + environment is not authoritative either: the pinned shpool ignores + ``SHPOOL_SOCKET`` entirely, and under systemd socket activation it + disregards ``--socket`` and serves the listener it inherited, so both + strings can say the opposite of the truth. + + The kernel is asked instead. The socket this kit's client connects to has an + inode in ``/proc/net/unix``; whichever daemon holds that inode open is the + one answering us. Nothing a command line or an environment says can change + that. When the question cannot be answered -- no ``/proc/net/unix``, no such + path, no holder, or more than one -- every daemon is returned and the caller + is exactly as strict as it was before any of this existed. + """ + if not table_is_complete(process_table) and not require_socket_holder: + # A hole in the census invalidates every conclusion built on absence, + # so the census fast paths below refuse. The socket-holder question is + # different: its answer is the kernel's positive statement that one + # daemon holds the one listener inode at our path, and a process that + # vanished between readdir and stat cannot un-hold that listener. On a + # busy host some scan is nearly always churned, and treating every + # churned scan as "no daemon" unresolved every session on the board. + return [] + # Ownership first, and before every later question. Another account's + # daemon is not a candidate this kit has to rule out; it is one the kernel + # already ruled out. + daemons = sorted( + pid + for pid, process in process_table.items() + if is_shpool_daemon(process) and _owned_by_this_account(process) + ) + unknown = sorted( + pid + for pid, process in process_table.items() + if pid not in daemons + and _is_unknown_shpool_daemon(process) + and _owned_by_this_account(process) + ) + if unknown: + # An unreadable exact shpool command line is the same kind of evidence + # gap as an unreadable daemon fd directory: it cannot promote another + # namesake to the unique answer. A sole unknown is also not proof of a + # daemon, so return no selection rather than laundering it through the + # one-element fast path. + candidates = sorted((*daemons, *unknown)) + if require_socket_holder: + return [] + return candidates if len(candidates) > 1 else [] + if len(daemons) <= 1 and not require_socket_holder: + return daemons + values = os.environ if environ is None else environ + path = _kit_socket_path(values) + if path is None: + return [] if require_socket_holder else daemons + root = default_proc_root(values) if proc_root is None else proc_root + inodes = _listening_inodes(path, root) + # More than one listener bound at our path means an old daemon's socket was + # replaced under it and both rows survive. Which one our client reached is + # not knowable from here, and three reviewers built the same attack out of + # guessing: a stranger holding the stale row got selected outright. One row + # or none. + if inodes is None or len(inodes) != 1: + return [] if require_socket_holder else daemons + holding = {pid: _holds_any_socket(pid, inodes, root) for pid in daemons} + # An unreadable descriptor directory or entry is not evidence that a + # daemon does not hold the listener. Until every candidate can be checked, + # uniqueness has not been established. + if _SocketHolding.UNKNOWN in holding.values(): + return [] if require_socket_holder else daemons + holders = [pid for pid, state in holding.items() if state is _SocketHolding.HOLDS] + # More than one daemon holding a listener on our path is a real state -- an + # old daemon whose socket file was replaced, or one that inherited the + # descriptor -- and there is no way to tell from here which one answered us. + # That is refusal, not a coin toss. + if len(holders) == 1: + return holders + return [] if require_socket_holder else daemons + + +def shpool_roots( + session_names: Iterable[str], + process_table: Mapping[int, Mapping[str, Any]], + *, + is_shpool_daemon: Callable[[Mapping[str, Any]], bool], + daemon_pids: Sequence[int] | None = None, +) -> tuple[dict[str, int], dict[str, list[str]]]: + """Map a shpool name only through a unique daemon direct child.""" + wanted = set(session_names) + daemons = ( + _kit_daemons(process_table, is_shpool_daemon) + if daemon_pids is None + else list(daemon_pids) + ) + roots: dict[str, int] = {} + diagnostics: dict[str, list[str]] = {name: [] for name in wanted} + if len(daemons) != 1: + reason = ( + "expected one shpool daemon serving this kit in the process table, " + f"found {len(daemons)}" + ) + for name in wanted: + diagnostics[name].append(reason) + return roots, diagnostics + daemon = daemons[0] + # A daemon child whose environment would not read cannot state its session + # name, so every name that came up empty may in truth belong to it. The + # ambiguity travels with the row instead of being read as absence. + unreadable = sorted( + pid + for pid, process in process_table.items() + if process.get("ppid") == daemon and process.get("environ_unreadable") + ) + for name in wanted: + candidates = sorted( + pid + for pid, process in process_table.items() + if process.get("ppid") == daemon and process.get("session_name") == name + ) + if len(candidates) == 1: + roots[name] = candidates[0] + else: + diagnostics[name].append( + f"expected one daemon child for {name!r}, found {len(candidates)}" + ) + if unreadable: + diagnostics[name].append( + f"{len(unreadable)} daemon child process(es) have an " + "unreadable environment; session names cannot be proven" + ) + return roots, diagnostics + + +def daemon_generation( + process_table: Mapping[int, Mapping[str, Any]], + *, + is_shpool_daemon: Callable[[Mapping[str, Any]], bool], + daemon_pids: Sequence[int] | None = None, + require_socket_holder: bool = False, +) -> dict[str, int] | None: + candidates = ( + _kit_daemons( + process_table, + is_shpool_daemon, + require_socket_holder=require_socket_holder, + ) + if daemon_pids is None + else list(daemon_pids) + ) + if len(candidates) != 1: + return None + process = process_table[candidates[0]] + pid = process.get("pid") + start_ticks = process.get("start_ticks") + if not isinstance(pid, int) or not isinstance(start_ticks, int): + return None + return {"pid": pid, "process_start_ticks": start_ticks} + + +def descendants( + root_pid: int, + children: Mapping[int, Sequence[int]], + *, + max_nodes: int, + max_depth: int, +) -> list[int]: + """Bounded breadth-first descendant walk, including root_pid.""" + found: list[int] = [] + seen: set[int] = set() + queue: list[tuple[int, int]] = [(root_pid, 0)] + while queue: + pid, depth = queue.pop(0) + if pid in seen: + continue + seen.add(pid) + found.append(pid) + if len(found) > max_nodes: + raise CollectionError( + f"process tree rooted at PID {root_pid} exceeded max_proc_nodes" + ) + if depth >= max_depth: + continue + queue.extend((child, depth + 1) for child in children.get(pid, ())) + return found + + +def _expected_proc_identity( + proc_root: Path, + pid: int, + expected_process: Mapping[str, Any], + *, + proc_stat: Callable[[Path], tuple[int, int, int]], +) -> tuple[int, int, int] | None: + try: + identity = proc_stat(proc_root / str(pid) / "stat") + except (OSError, ValueError): + return None + expected = ( + pid, + expected_process.get("ppid"), + expected_process.get("start_ticks"), + ) + return identity if identity == expected else None + + +def _process_age( + pid: int | None, process_table: Mapping[int, Mapping[str, Any]], now: float +) -> int | None: + if not pid: + return None + ticks = process_table.get(pid, {}).get("start_ticks") + if not isinstance(ticks, int): + return None + if process_table.get(pid, {}).get("generation_kind") == "darwin-start-usec": + age = now - (ticks / 1_000_000) + return max(0, int(age)) if age >= 0 else None + try: + hz = os.sysconf(os.sysconf_names["SC_CLK_TCK"]) + uptime = float(Path("/proc/uptime").read_text().split()[0]) + except (OSError, ValueError, KeyError): + return None + age = uptime - (ticks / hz) + return max(0, int(age)) if age >= 0 else None + + +def _process_ancestor_chain( + process_table: Mapping[int, Mapping[str, Any]], current_pid: int +) -> list[int]: + chain: list[int] = [] + seen: set[int] = set() + pid = current_pid + for _ in range(128): + if pid in seen or pid <= 0: + raise CollectionError("automatic name caller ancestry is ambiguous") + process = process_table.get(pid) + if not isinstance(process, Mapping): + raise CollectionError("automatic name caller process is stale") + chain.append(pid) + seen.add(pid) + parent = process.get("ppid") + if isinstance(parent, bool) or not isinstance(parent, int) or parent <= 0: + break + if parent not in process_table: + break + pid = parent + else: + raise CollectionError("automatic name caller ancestry exceeds safety bound") + return chain + + +def _boot_id( + *, + environ: Mapping[str, str], + runtime_platform: Callable[[], str], + darwin_libraries: Callable[[], tuple[Any, Any]], +) -> str | None: + override = environ.get("SESSION_KIT_BOOT_ID_FILE") + if override: + try: + value = Path(override).read_text(encoding="utf-8").strip() + except OSError: + return None + return value or None + if runtime_platform() == DARWIN_PLATFORM: + try: + libc, _ = darwin_libraries() + boot_time = _DarwinTimeval() + size = ctypes.c_size_t(ctypes.sizeof(boot_time)) + if ( + libc.sysctlbyname( + b"kern.boottime", + ctypes.byref(boot_time), + ctypes.byref(size), + None, + 0, + ) + != 0 + ): + return None + if boot_time.tv_sec <= 0 or boot_time.tv_usec < 0: + return None + return f"darwin:{boot_time.tv_sec}:{boot_time.tv_usec}" + except (OSError, ValueError): + return None + try: + value = ( + Path("/proc/sys/kernel/random/boot_id").read_text(encoding="utf-8").strip() + ) + except OSError: + return None + return value or None diff --git a/lib/sessionkit_inventory/project_dir_name.py b/lib/sessionkit_inventory/project_dir_name.py new file mode 100644 index 0000000..2f7c6ee --- /dev/null +++ b/lib/sessionkit_inventory/project_dir_name.py @@ -0,0 +1,385 @@ +"""Choose the short Claude project-directory name for one launch. + +Claude Code 2.1.234 added ``CLAUDE_CODE_PROJECT_DIR_NAME``: when +``CLAUDE_CONFIG_DIR`` is set, the variable names the per-project directory +under ``<config>/projects/`` instead of the munged working-directory path. +This helper decides whether one launch may use it, and prints the name on +stdout when, and only when, every proof holds: + +* the installed Claude launcher resolves to a version that knows the + variable (2.1.234 or newer); an older Claude ignores an exported name + silently, which would strand new transcripts under the munged name the + moment the export started working after an upgrade; +* the launch directory is exactly the registered root of a project shortcut, + and exactly one shortcut claims that root; +* the alias satisfies Claude's own validator (``[A-Za-z0-9_-]{1,64}``, not a + Windows reserved device name), Claude falls back *silently* on a bad + value, so the refusal has to happen here where it can be reasoned about; +* the profile holds no legacy munged directory for this root, or that + directory was just renamed to the alias. The rename is refused while any + process still carries this profile with a working directory inside the + root, because Claude re-resolves the path per write: renaming under a live + session splits its transcript, and auto memory is only ever read from the + resolved name, a half-migrated project is a session with amnesia. + +Anything unprovable prints nothing and exits 0: the launch simply keeps +today's munged-name behaviour. This file is invoked by path from the session +shell (like ``session_inventory.py``) and therefore imports nothing from the +package. +""" + +from __future__ import annotations + +import argparse +import fcntl +import json +import os +from pathlib import Path +import re +import shutil +import sys + + +MINIMUM_CLAUDE_VERSION = (2, 1, 234) +CLAUDE_NAME_RE = re.compile(r"[A-Za-z0-9_-]{1,64}") +RESERVED_NAME_RE = re.compile(r"(?:con|prn|aux|nul|com[0-9]|lpt[0-9])", re.IGNORECASE) +LAUNCHER_VERSION_RE = re.compile(r"/versions/([0-9]+)\.([0-9]+)\.([0-9]+)$") +MUNGE_RE = re.compile(r"[^A-Za-z0-9]") + + +def claude_supports_project_dir_name(command: str = "claude") -> bool: + """True only when the resolved launcher proves a new-enough version. + + The proof is the immutable-release layout the installer creates + (``.../versions/<major>.<minor>.<patch>``). A launcher that resolves + anywhere else proves nothing and the answer is no, never a guess via + ``claude --version``, which costs a process start on every launch. + """ + located = shutil.which(command) + if located is None: + return False + try: + resolved = os.path.realpath(located) + except OSError: + return False + match = LAUNCHER_VERSION_RE.search(resolved) + if match is None: + return False + version = tuple(int(part) for part in match.groups()) + return version >= MINIMUM_CLAUDE_VERSION + + +def alias_for_root(projects_file: Path, cwd: Path) -> str | None: + """The single registered alias whose root is exactly ``cwd``. + + Rows follow ``projects.tsv``: ``alias<TAB>provider<TAB>root``. Two rows + claiming the same root is ambiguity, and ambiguity is a refusal, not a + pick. + """ + try: + payload = projects_file.read_text(encoding="utf-8") + resolved_cwd = cwd.resolve(strict=True) + except (OSError, ValueError): + return None + matches: list[str] = [] + for line in payload.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + fields = line.split("\t") + if len(fields) < 3: + continue + alias, _provider, root = fields[0], fields[1], fields[2] + try: + if Path(root).resolve(strict=True) != resolved_cwd: + continue + except OSError: + continue + matches.append(alias) + if len(matches) != 1: + return None + alias = matches[0] + if not CLAUDE_NAME_RE.fullmatch(alias) or RESERVED_NAME_RE.fullmatch(alias): + return None + return alias + + +# The launch chain a migration may excuse: shells, multiplexers, and session +# plumbing. A provider process is deliberately NOT here, a Claude ancestor +# that carries this profile inside this root is a live session whatever its +# position in the tree, and renaming under it splits its store. Anything +# unrecognised counts as live; refusal is the safe direction. +EXCUSABLE_ANCESTOR_COMMS = frozenset( + { + "bash", + "zsh", + "fish", + "sh", + "dash", + "script", + "shpool", + "sshd", + "systemd", + "login", + "tmux", + "screen", + "python3", + "python", + } +) + + +def _ancestor_pids() -> set[int]: + pids: set[int] = set() + pid = os.getpid() + for _ in range(64): + pids.add(pid) + try: + stat_line = Path(f"/proc/{pid}/stat").read_text(encoding="ascii") + pid = int(stat_line.rsplit(") ", 1)[1].split()[1]) + except (OSError, ValueError, IndexError): + break + if pid <= 1: + pids.add(pid) + break + return pids + + +def profile_live_in_root(profile: Path, root: Path, proc: Path = Path("/proc")) -> bool: + """True when any *other* process runs this profile inside this root. + + A live session is any process whose environment carries this exact + ``CLAUDE_CONFIG_DIR`` and whose working directory sits at or under the + project root. The launching shell and this helper both match that + description themselves, their whole ancestor chain is excluded, or the + migration would wait on the very launch that requested it. Unreadable + process entries count as live: an unproven quiet is not quiet. + """ + needle = b"CLAUDE_CONFIG_DIR=" + os.fsencode(os.fspath(profile)) + b"\0" + root_text = os.fspath(root) + own = _ancestor_pids() + try: + entries = list(proc.iterdir()) + except OSError: + return True + for entry in entries: + if not entry.name.isdigit(): + continue + excused_ancestor = int(entry.name) in own + try: + environ = (entry / "environ").read_bytes() + except OSError: + continue + if needle not in environ: + continue + if excused_ancestor: + # The chain that requested this launch matches its own scan by + # construction, but only its shells and plumbing are excused. A + # provider process above us is a session inside a session, and a + # rename under it splits ITS store (proven by review lane + # rv-pdn-1, 2026-08-17). An unreadable comm counts as live. + try: + comm = (entry / "comm").read_text(encoding="ascii").strip() + except OSError: + return True + if comm in EXCUSABLE_ANCESTOR_COMMS: + continue + try: + cwd = os.readlink(entry / "cwd") + except OSError: + return True + if cwd == root_text or cwd.startswith(root_text + os.sep): + return True + return False + + +def resolve_project_dir_name( + profile: Path, cwd: Path, projects_file: Path, claude_command: str = "claude" +) -> str | None: + if os.environ.get("SESSION_KIT_PROJECT_DIR_NAME") == "off": + return None + if not claude_supports_project_dir_name(claude_command): + return None + alias = alias_for_root(projects_file, cwd) + if alias is None: + return None + try: + root = cwd.resolve(strict=True) + except OSError: + return None + munged = MUNGE_RE.sub("-", os.fspath(root)) + projects_dir = profile / "projects" + legacy = projects_dir / munged + renamed = projects_dir / alias + # The alias path must be a real directory or absent. Claude cannot use a + # regular file or a symlink as its project directory, and exporting the + # name anyway would strand the launch's writes (review lane rv-pdn-1). + if renamed.exists() and (renamed.is_symlink() or not renamed.is_dir()): + return None + if not legacy.is_dir() or legacy.is_symlink(): + return alias + # Two windows of the same project can launch in the same second, and both + # would pass the liveness scan (neither is running yet). Unserialised, + # the rename loser would print nothing and its Claude would recreate the + # munged directory beside the renamed one, the exact split state the + # both-names refusal below exists to prevent. One advisory lock makes the + # decision serial: the loser re-reads the world the winner left behind. + try: + projects_dir.mkdir(mode=0o700, exist_ok=True) + lock = os.open( + projects_dir / ".project-dir-name.lock", + os.O_CREAT | os.O_RDWR, + 0o600, + ) + except OSError: + return None + try: + fcntl.flock(lock, fcntl.LOCK_EX) + if not legacy.is_dir() or legacy.is_symlink(): + return alias + if renamed.exists(): + # Both directories exist: someone already holds state under each + # name. Renaming would clobber and exporting would split memory + # between them, this needs a person, not a launch-time guess. + return None + if profile_live_in_root(profile, root): + return None + try: + legacy.rename(renamed) + except OSError: + return None + # A raw provider launch takes no part in this lock, so one can start + # in the gap between the scan above and the rename. Scan again on the + # far side: if a same-profile process now runs inside the root, its + # first write would recreate the munged path beside the moved one, + # the split-store defect review lane rv-pdn-1 reproduced. Undo the + # move and refuse; the raw session then finds the world exactly as it + # was. An undo that fails leaves the both-names state the next launch + # refuses on and doctor reports, visible, never silent. + if profile_live_in_root(profile, root): + try: + renamed.rename(legacy) + except OSError: + pass + return None + return alias + finally: + os.close(lock) + + +def doctor_report(projects_file: Path, accounts_root: Path) -> tuple[str, str]: + """One ``(status, detail)`` line for ``session-kit doctor``. + + ``warn`` names the states a person has to resolve, a registry root + claimed twice, a profile holding state under both the munged and the + short name, or a profile settings file without the auto-continue switch. + Directories still waiting on their automatic launch-time rename are + ordinary and reported inside ``ok``. + """ + problems: list[str] = [] + pending = 0 + rows: list[tuple[str, Path]] = [] + try: + payload = projects_file.read_text(encoding="utf-8") + except OSError: + payload = "" + seen_roots: dict[Path, str] = {} + for line in payload.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + fields = line.split("\t") + if len(fields) < 3: + continue + alias, root_raw = fields[0], fields[2] + try: + root = Path(root_raw).resolve(strict=True) + except OSError: + continue + if root in seen_roots and seen_roots[root] != alias: + problems.append( + f"{root} is registered as both {seen_roots[root]} and {alias}" + ) + continue + seen_roots[root] = alias + if CLAUDE_NAME_RE.fullmatch(alias) and not RESERVED_NAME_RE.fullmatch(alias): + rows.append((alias, root)) + try: + profiles = sorted(entry for entry in accounts_root.iterdir() if entry.is_dir()) + except OSError: + profiles = [] + for profile in profiles: + settings = profile / "settings.json" + try: + value = json.loads(settings.read_text(encoding="utf-8")) + if not isinstance(value, dict) or "autoContinueAtUsageLimit" not in value: + problems.append( + f"{profile.name} settings lack autoContinueAtUsageLimit" + ) + except (OSError, ValueError): + problems.append(f"{profile.name} settings are unreadable") + for alias, root in rows: + munged = profile / "projects" / MUNGE_RE.sub("-", os.fspath(root)) + renamed = profile / "projects" / alias + if munged.is_dir() and renamed.exists(): + problems.append(f"{profile.name} holds {alias} under both names") + elif munged.is_dir(): + pending += 1 + if problems: + return "warn", "; ".join(problems)[:500] + detail = f"{len(rows)} named projects across {len(profiles)} profiles" + if pending: + detail += f"; {pending} awaiting their automatic rename at next quiet launch" + return "ok", detail + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--profile") + parser.add_argument("--cwd") + parser.add_argument( + "--projects-file", + # The same registry every other consumer reads: the explicit override + # first, then the XDG location. A custom registry that named one root + # while this default read another exported the wrong short name + # (review lane rv-pdn-2, 2026-08-17). + default=os.environ.get("SESSION_KIT_PROJECTS_FILE") + or os.path.join( + os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), + "session-kit", + "projects.tsv", + ), + ) + parser.add_argument("--claude-command", default="claude") + parser.add_argument("--doctor", action="store_true") + parser.add_argument("--accounts-root") + options = parser.parse_args(argv) + if options.doctor: + account_root = os.environ.get("SESSION_KIT_ACCOUNT_ROOT") + default_root = ( + Path(account_root) / "claude" + if account_root + else Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share") + / "session-kit" + / "accounts" + / "claude" + ) + status, detail = doctor_report( + Path(options.projects_file), + Path(options.accounts_root) if options.accounts_root else default_root, + ) + print(f"{status}\t{detail}") + return 0 + if not options.profile or not options.cwd: + parser.error("--profile and --cwd are required outside --doctor") + name = resolve_project_dir_name( + Path(options.profile), + Path(options.cwd), + Path(options.projects_file), + options.claude_command, + ) + if name is not None: + print(name) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lib/sessionkit_inventory/projects.py b/lib/sessionkit_inventory/projects.py new file mode 100644 index 0000000..7c3e047 --- /dev/null +++ b/lib/sessionkit_inventory/projects.py @@ -0,0 +1,1140 @@ +"""Discover and manage Session Kit project shortcuts. + +Discovery is deliberately local and bounded. It reads only project paths that +Claude Code or Codex already recorded, then keeps paths that still name an +existing directory. It never searches the rest of the filesystem. +""" + +from __future__ import annotations + +import argparse +import fcntl +import json +import os +from pathlib import Path +import re +import sqlite3 +import stat +import sys +import tempfile +import time +from typing import Any, Iterable, Mapping, Sequence +from urllib.parse import quote + +try: + from .common import CollectionError, parse_number_selection +except ImportError: # the picker and the installer run this file as a script + sys.path.insert(0, os.fspath(Path(__file__).resolve().parents[1])) + from sessionkit_inventory.common import ( # noqa: E402 + CollectionError, + parse_number_selection, + ) + +try: + import tomllib +except ImportError: # Python 3.10 is supported on Linux. + tomllib = None # type: ignore[assignment] + + +PROVIDERS = ("claude", "codex") +# A row kind of "ignore" keeps a directory out of the picker for good: it is +# never launchable and never listed, and because import gives each directory +# at most one row, discovery can never add a shortcut for it again. +IGNORE_KIND = "ignore" +# A directory is one project, whatever it is worked on with. The kind column +# on a live row is that project's DEFAULT provider, never a lock: the provider +# is chosen when a session starts, and naming one there overrides the default. +# "any" is a project with no default at all, the row a plain `projects add` +# writes, because adding a directory is not the moment to decide that. +ANY_KIND = "any" +LAUNCH_KINDS = (*PROVIDERS, "shell") +ROW_KINDS = (*LAUNCH_KINDS, ANY_KIND, IGNORE_KIND) +MAX_PROVIDER_CONFIG_BYTES = 32 * 1024 * 1024 +MAX_CLAUDE_HISTORY_BYTES = 64 * 1024 * 1024 +MAX_PROJECTS_FILE_BYTES = 4 * 1024 * 1024 +MAX_PROJECTS = 2048 +ALIAS_RE = re.compile(r"[a-z0-9_-]+") +GENERIC_DIRECTORY_NAMES = { + "app", + "code", + "main", + "project", + "projects", + "repo", + "repository", + "source", + "src", + "v1", + "v2", + "v3", +} +CODEX_PROJECT_HEADER_RE = re.compile( + r'^\s*\[projects\.((?:"(?:[^"\\]|\\.)*")|(?:\'[^\']*\'))\]\s*(?:#.*)?$' +) + + +class ProjectError(RuntimeError): + """A provider project record or projects file is unsafe or invalid.""" + + +def _read_owner_file( + path: Path, *, label: str, max_bytes: int, allow_missing: bool = True +) -> bytes | None: + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except FileNotFoundError: + if allow_missing: + return None + raise ProjectError(f"{label} does not exist: {path}") + except OSError as exc: + raise ProjectError(f"cannot open {label}: {path}") from exc + try: + before = os.fstat(descriptor) + try: + pathname = path.lstat() + except OSError as exc: + raise ProjectError(f"cannot inspect {label}: {path}") from exc + if ( + not stat.S_ISREG(before.st_mode) + or before.st_uid != os.geteuid() + or before.st_nlink != 1 + or stat.S_IMODE(before.st_mode) & 0o022 + or before.st_size < 0 + or before.st_size > max_bytes + or (before.st_dev, before.st_ino) != (pathname.st_dev, pathname.st_ino) + ): + raise ProjectError(f"unsafe {label}: {path}") + payload = bytearray() + remaining = before.st_size + while remaining: + block = os.read(descriptor, min(remaining, 65536)) + if not block: + raise ProjectError(f"short read from {label}: {path}") + payload.extend(block) + remaining -= len(block) + if os.read(descriptor, 1): + raise ProjectError(f"{label} grew while being read: {path}") + after = os.fstat(descriptor) + stable = ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns") + if any(getattr(before, field) != getattr(after, field) for field in stable): + raise ProjectError(f"{label} changed while being read: {path}") + return bytes(payload) + finally: + os.close(descriptor) + + +def _directory(raw: object) -> str | None: + if not isinstance(raw, str) or not raw.startswith("/") or len(raw) > 4096: + return None + if any(ord(character) < 32 or ord(character) == 127 for character in raw): + return None + try: + resolved = Path(raw).resolve(strict=True) + except OSError: + return None + # A provider can retain a path copied from another account or host. It is + # not useful as a shortcut unless this user can both list and traverse it. + # Do not require ownership: group/shared repositories are valid when their + # permissions make them usable. + return ( + os.fspath(resolved) + if resolved.is_dir() and os.access(resolved, os.R_OK | os.X_OK) + else None + ) + + +def _outside_roots(path: str | None, roots: Iterable[Path]) -> str | None: + if path is None: + return None + candidate = Path(path) + return ( + None + if any(candidate == root or candidate.is_relative_to(root) for root in roots) + else path + ) + + +def _json_object(payload: bytes, label: str) -> Mapping[str, Any]: + try: + value = json.loads(payload.decode("utf-8", "strict")) + except (UnicodeDecodeError, ValueError) as exc: + raise ProjectError(f"{label} is not valid JSON") from exc + if not isinstance(value, Mapping): + raise ProjectError(f"{label} is not a JSON object") + return value + + +def _claude_paths(home: Path, warnings: list[str]) -> set[str]: + paths: set[str] = set() + excluded = [ + root + for root in (Path("/tmp"), Path("/private/tmp")) + if not home.is_relative_to(root) + ] + tmpdir = os.environ.get("TMPDIR") + if tmpdir and (temporary := _directory(tmpdir)): + temporary_root = Path(temporary) + if not home.is_relative_to(temporary_root): + excluded.append(temporary_root) + settings = home / ".claude" / "settings.json" + try: + settings_payload = _read_owner_file( + settings, label="Claude Code settings", max_bytes=MAX_PROVIDER_CONFIG_BYTES + ) + if settings_payload is not None: + environment = _json_object(settings_payload, "Claude Code settings").get( + "env" + ) + configured_tmp = ( + environment.get("CLAUDE_CODE_TMPDIR") + if isinstance(environment, Mapping) + else None + ) + if temporary := _directory(configured_tmp): + excluded.append(Path(temporary)) + except ProjectError as exc: + warnings.append(str(exc)) + config = Path(os.environ.get("SESSION_KIT_CLAUDE_CONFIG", home / ".claude.json")) + try: + payload = _read_owner_file( + config, + label="Claude Code project configuration", + max_bytes=MAX_PROVIDER_CONFIG_BYTES, + ) + if payload is not None: + projects = _json_object(payload, "Claude Code project configuration").get( + "projects" + ) + if isinstance(projects, Mapping): + paths.update( + path + for raw in projects + if (path := _outside_roots(_directory(raw), excluded)) + ) + except ProjectError as exc: + warnings.append(str(exc)) + + history = Path( + os.environ.get("SESSION_KIT_CLAUDE_HISTORY", home / ".claude" / "history.jsonl") + ) + try: + payload = _read_owner_file( + history, label="Claude Code history", max_bytes=MAX_CLAUDE_HISTORY_BYTES + ) + if payload is not None: + for line in payload.splitlines(): + try: + row = json.loads(line) + except ValueError: + continue + if isinstance(row, Mapping) and ( + path := _outside_roots(_directory(row.get("project")), excluded) + ): + paths.add(path) + except ProjectError as exc: + warnings.append(str(exc)) + return paths + + +def _toml_projects(payload: bytes) -> set[object]: + text = payload.decode("utf-8", "strict") + if tomllib is not None: + parsed = tomllib.loads(text) + projects = parsed.get("projects") + return set(projects) if isinstance(projects, Mapping) else set() + found: set[object] = set() + for line in text.splitlines(): + match = CODEX_PROJECT_HEADER_RE.fullmatch(line) + if not match: + continue + encoded = match.group(1) + if encoded.startswith('"'): + try: + found.add(json.loads(encoded)) + except ValueError: + continue + else: + found.add(encoded[1:-1]) + return found + + +def _codex_databases(codex_home: Path) -> list[Path]: + candidates: list[tuple[int, str, Path]] = [] + try: + entries = list(codex_home.iterdir()) + except OSError: + return [] + for path in entries: + match = re.fullmatch(r"state_(\d+)\.sqlite", path.name) + if match and path.is_file() and not path.is_symlink(): + candidates.append((int(match.group(1)), path.name, path)) + return [row[2] for row in sorted(candidates)] + + +def _codex_db_paths(database: Path, warnings: list[str]) -> set[str]: + try: + info = database.stat() + if ( + not stat.S_ISREG(info.st_mode) + or info.st_uid != os.geteuid() + or stat.S_IMODE(info.st_mode) & 0o022 + ): + raise ProjectError(f"unsafe Codex state database: {database}") + uri = f"file:{quote(os.fspath(database.resolve()))}?mode=ro" + connection = sqlite3.connect(uri, uri=True, timeout=1.0) + try: + connection.execute("PRAGMA query_only=ON") + columns = { + row[1] for row in connection.execute("PRAGMA table_info(threads)") + } + if "cwd" not in columns: + raise ProjectError( + "Codex threads table has no project directory column" + ) + return { + path + for (raw,) in connection.execute( + "SELECT DISTINCT cwd FROM threads WHERE cwd IS NOT NULL AND cwd != ''" + ) + if (path := _directory(raw)) + } + finally: + connection.close() + except (OSError, sqlite3.Error, ProjectError) as exc: + warnings.append(str(exc)) + return set() + + +def _codex_paths(home: Path, warnings: list[str]) -> set[str]: + codex_home = Path( + os.environ.get("SESSION_KIT_CODEX_HOME") + or os.environ.get("CODEX_HOME") + or (home / ".codex") + ) + paths: set[str] = set() + config = codex_home / "config.toml" + try: + payload = _read_owner_file( + config, label="Codex configuration", max_bytes=MAX_PROVIDER_CONFIG_BYTES + ) + if payload is not None: + paths.update( + path for raw in _toml_projects(payload) if (path := _directory(raw)) + ) + except (ProjectError, UnicodeDecodeError, ValueError) as exc: + warnings.append(f"Codex configuration could not be read: {exc}") + databases = _codex_databases(codex_home) + if databases: + paths.update(_codex_db_paths(databases[-1], warnings)) + return paths + + +def discover_projects(home: Path | None = None) -> dict[str, Any]: + root = (home or Path(os.environ.get("HOME", Path.home()))).resolve() + warnings: list[str] = [] + records = [ + {"provider": provider, "cwd": cwd} + for provider, paths in ( + ("claude", _claude_paths(root, warnings)), + ("codex", _codex_paths(root, warnings)), + ) + for cwd in paths + ] + records.sort(key=lambda row: (row["cwd"].casefold(), row["provider"])) + if len(records) > MAX_PROJECTS: + warnings.append(f"project discovery was limited to {MAX_PROJECTS} records") + records = records[:MAX_PROJECTS] + return {"projects": records, "warnings": sorted(set(warnings))} + + +def _row(line: str) -> dict[str, str] | None: + if not line.strip() or line.lstrip().startswith("#"): + return None + fields = line.split("\t") + if len(fields) < 3: + return None + alias, provider, cwd = fields[:3] + if not ALIAS_RE.fullmatch(alias) or provider not in ROW_KINDS: + return None + return {"alias": alias, "provider": provider, "cwd": cwd} + + +def _parse_projects(payload: bytes) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for line in payload.decode("utf-8", "strict").splitlines(): + row = _row(line) + if row is not None: + rows.append(row) + return rows + + +def _slug(value: str) -> str: + value = re.sub(r"[^a-z0-9]+", "-", value.casefold()).strip("-") + return value[:48].strip("-") or "project" + + +def _path_aliases(paths: Iterable[str]) -> dict[str, str]: + unique = sorted(set(paths)) + components = { + path: [_slug(part) for part in Path(path).parts if part not in ("/", "")] + for path in unique + } + result: dict[str, str] = {} + for path in unique: + parts = components[path] + first_width = ( + 2 if len(parts) > 1 and parts[-1] in GENERIC_DIRECTORY_NAMES else 1 + ) + for width in range(first_width, min(len(parts), 4) + 1): + candidate = "-".join(parts[-width:]) + if ( + sum( + "-".join(other[-width:]) == candidate + for other in components.values() + if len(other) >= width + ) + == 1 + ): + result[path] = candidate + break + else: + result[path] = "-".join(parts[-4:]) + return result + + +def _new_rows( + discovered: Sequence[Mapping[str, str]], existing: Sequence[Mapping[str, str]] +) -> list[dict[str, str]]: + # A directory gets at most one shortcut. Deduplicating on the + # (provider, directory) pair used to add a second row whenever the same + # path was also seen under the other provider, and the picker shows only + # the alias and directory, so those rows read as exact duplicates. + existing_paths = {row["cwd"] for row in existing} + reserved = {row["alias"] for row in existing} + pending: list[dict[str, str]] = [] + pending_paths: set[str] = set() + for row in discovered: + if row["cwd"] in existing_paths or row["cwd"] in pending_paths: + continue + pending_paths.add(row["cwd"]) + pending.append({"provider": row["provider"], "cwd": row["cwd"]}) + bases = _path_aliases(row["cwd"] for row in pending) + added: list[dict[str, str]] = [] + for row in pending: + base = bases[row["cwd"]] + alias = base + if alias in reserved: + alias = f"{base}-{row['provider']}" + counter = 2 + while alias in reserved: + alias = f"{base}-{counter}" + counter += 1 + reserved.add(alias) + added.append({"alias": alias, **row}) + return added + + +def _atomic_write(path: Path, payload: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb", closefd=True) as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + +def _backup(payload: bytes, state_dir: Path) -> str: + backup_dir = state_dir / "backups" + backup_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + stamp = time.strftime("%Y%m%d-%H%M%S", time.gmtime()) + for serial in range(1000): + path = backup_dir / f"projects-{stamp}-{os.getpid()}-{serial}.tsv" + try: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError: + continue + with os.fdopen(descriptor, "wb") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + return os.fspath(path) + raise ProjectError("cannot allocate a unique projects backup") + + +def _mutate_projects( + projects_file: Path, + state_dir: Path, + mutate: Any, + drop: Any = None, + replace: Any = None, +) -> dict[str, Any]: + state_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + lock_path = state_dir / "projects.lock" + lock_descriptor = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) + try: + fcntl.flock(lock_descriptor, fcntl.LOCK_EX) + original = _read_owner_file( + projects_file, + label="Session Kit projects file", + max_bytes=MAX_PROJECTS_FILE_BYTES, + ) + if original is None: + original = b"# alias<TAB>provider<TAB>absolute cwd\n" + try: + existing = _parse_projects(original) + except (UnicodeDecodeError, ValueError) as exc: + raise ProjectError("Session Kit projects file is not valid UTF-8") from exc + added = mutate(existing) + payload = original + removed: list[dict[str, str]] = [] + changed: list[dict[str, str]] = [] + if replace is not None: + # A row edited in place keeps its line, and therefore its number in + # the picker. Withdrawing it and appending the new one would + # renumber every project below it for a change to one of them. + lines: list[str] = [] + for line in original.decode("utf-8", "strict").splitlines(keepends=True): + row = _row(line.rstrip("\n")) + replacement = replace(row) if row is not None else None + if replacement is None: + lines.append(line) + continue + changed.append(replacement) + lines.append( + f"{replacement['alias']}\t{replacement['provider']}" + f"\t{replacement['cwd']}\n" + ) + payload = "".join(lines).encode("utf-8") + if not changed: + return {"added": [], "removed": [], "changed": [], "backup": None} + backup = _backup(original, state_dir) if projects_file.exists() else None + _atomic_write(projects_file, payload) + return { + "added": [], + "removed": [], + "changed": changed, + "backup": backup, + } + if drop is not None: + # Rewrite only the withdrawn rows so comments, blank lines, and + # every unrelated line survive byte for byte. + kept: list[str] = [] + for line in original.decode("utf-8", "strict").splitlines(keepends=True): + row = _row(line.rstrip("\n")) + if row is not None and drop(row): + removed.append(row) + continue + kept.append(line) + payload = "".join(kept).encode("utf-8") + if not added and not removed: + return {"added": [], "removed": [], "backup": None} + if payload and not payload.endswith(b"\n"): + payload += b"\n" + payload += "".join( + f"{row['alias']}\t{row['provider']}\t{row['cwd']}\n" for row in added + ).encode("utf-8") + backup = _backup(original, state_dir) if projects_file.exists() else None + _atomic_write(projects_file, payload) + return {"added": added, "removed": removed, "backup": backup} + finally: + os.close(lock_descriptor) + + +def suggest_alias(cwd: str, existing: Sequence[Mapping[str, str]]) -> str: + """The alias import would pick for a directory, free of collisions.""" + reserved = {row["alias"] for row in existing} + alias = base = _path_aliases([cwd])[cwd] + counter = 2 + while alias in reserved: + alias = f"{base}-{counter}" + counter += 1 + return alias + + +def select_rows(rows: Sequence[Mapping[str, str]], answer: str) -> list[dict[str, str]]: + """Resolve a "1,3-5" / "all" selection against a numbered list. + + The grammar itself lives in ``common.parse_number_selection``, one + implementation, so no two surfaces can disagree about what a range means. + """ + try: + picked = parse_number_selection(answer, len(rows)) + except CollectionError as reason: + raise ProjectError(str(reason)) from reason + return [dict(rows[number - 1]) for number in picked] + + +def collapse_rows( + rows: Sequence[Mapping[str, str]], +) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + """One entry per directory, and the duplicates that were folded into it. + + A directory listed twice, the shape the old add flow forced on anyone who + worked in it with both providers, is one project, so the first row keeps + its alias and the later rows contribute only their default. Two rows that + disagree about the provider cancel to no default: a directory worked on + with both is exactly the case where guessing one is wrong. + + Nothing is dropped silently; the second return value names every folded + row so a caller can say which alias it retired. + """ + kept: list[dict[str, str]] = [] + index: dict[str, dict[str, str]] = {} + folded: list[dict[str, str]] = [] + for row in rows: + if row["provider"] == IGNORE_KIND: + kept.append(dict(row)) + continue + first = index.get(row["cwd"]) + if first is None: + entry = dict(row) + index[row["cwd"]] = entry + kept.append(entry) + continue + if first["provider"] != row["provider"]: + first["provider"] = ANY_KIND + folded.append({**dict(row), "kept_alias": first["alias"]}) + return kept, folded + + +def project_candidates(projects_file: Path) -> dict[str, Any]: + """Directories a provider has used that have no row yet, each with the + alias and provider import would give it. Nothing is written.""" + discovery = discover_projects() + payload = _read_owner_file( + projects_file, + label="Session Kit projects file", + max_bytes=MAX_PROJECTS_FILE_BYTES, + ) + existing = _parse_projects(payload or b"") + candidates = [ + {"alias": row["alias"], "provider": row["provider"], "cwd": row["cwd"]} + for row in _new_rows(discovery["projects"], existing) + ] + collapsed, duplicates = collapse_rows(existing) + return { + "candidates": candidates, + "configured": [row for row in collapsed if row["provider"] != IGNORE_KIND], + "duplicates": duplicates, + "ignored": [row["cwd"] for row in existing if row["provider"] == IGNORE_KIND], + "warnings": discovery["warnings"], + } + + +def normalize_projects(projects_file: Path, state_dir: Path) -> dict[str, Any]: + """Rewrite the file to one row per directory, keeping the first alias. + + Only directories that carry more than one row are touched; every other + line, comment included, survives byte for byte. + """ + payload = _read_owner_file( + projects_file, + label="Session Kit projects file", + max_bytes=MAX_PROJECTS_FILE_BYTES, + ) + _, duplicates = collapse_rows(_parse_projects(payload or b"")) + if not duplicates: + return {"added": [], "removed": [], "backup": None, "duplicates": []} + directories = {row["cwd"] for row in duplicates} + + def merge(existing: Sequence[Mapping[str, str]]) -> list[dict[str, str]]: + collapsed, _ = collapse_rows(existing) + return [ + {"alias": row["alias"], "provider": row["provider"], "cwd": row["cwd"]} + for row in collapsed + if row["cwd"] in directories and row["provider"] != IGNORE_KIND + ] + + result = _mutate_projects( + projects_file, + state_dir, + merge, + drop=lambda row: row["provider"] != IGNORE_KIND and row["cwd"] in directories, + ) + return {**result, "duplicates": duplicates} + + +def import_projects( + projects_file: Path, state_dir: Path, only: Sequence[str] | None = None +) -> dict[str, Any]: + """Import discovered directories. `only` limits the import to those exact + directories, so a first run never has to be all or nothing.""" + discovery = discover_projects() + wanted = None if only is None else {os.fspath(Path(path)) for path in only} + + def merge(existing: Sequence[Mapping[str, str]]) -> list[dict[str, str]]: + rows = _new_rows(discovery["projects"], existing) + if wanted is None: + return rows + return [row for row in rows if row["cwd"] in wanted] + + result = _mutate_projects(projects_file, state_dir, merge) + return {**result, **discovery} + + +def add_project( + projects_file: Path, + state_dir: Path, + alias: str | None, + provider: str | None = None, + cwd: str = "", +) -> dict[str, Any]: + """Add a project. An omitted alias is derived from the directory, and an + ignore on that directory is withdrawn so adding always reverses ignoring. + + ``provider`` is a *default* for that project and may be omitted, which is + what the add flow does: which provider opens a directory is answered when + a session starts, not once and for all when the directory is listed. A + directory already on the list is refused by name rather than given a + second entry, that second entry is what used to be needed to work in one + directory with both providers. + """ + if alias is not None and not ALIAS_RE.fullmatch(alias): + raise ProjectError("alias must use lowercase letters, numbers, _ or -") + if provider is None: + provider = ANY_KIND + if provider not in ROW_KINDS: + raise ProjectError("provider must be claude, codex, shell, any, or ignore") + directory = _directory(cwd) + if directory is None: + raise ProjectError("project directory must be an existing absolute directory") + + def add(existing: Sequence[Mapping[str, str]]) -> list[dict[str, str]]: + live = [ + row + for row in existing + if not (row["provider"] == IGNORE_KIND and row["cwd"] == directory) + ] + chosen = alias if alias is not None else suggest_alias(directory, live) + for row in live: + if row["cwd"] == directory: + if row["alias"] == chosen and row["provider"] == provider: + return [] + raise ProjectError( + f"{directory} is already on the list as {row['alias']}; " + "one directory is one project, and which provider opens it " + "is chosen when a session starts" + ) + if row["alias"] == chosen: + raise ProjectError(f"project alias already exists: {chosen}") + return [{"alias": chosen, "provider": provider, "cwd": directory}] + + return _mutate_projects( + projects_file, + state_dir, + add, + drop=lambda row: row["provider"] == IGNORE_KIND and row["cwd"] == directory, + ) + + +def set_default_provider( + projects_file: Path, state_dir: Path, target: str, provider: str +) -> dict[str, Any]: + """Set (or with ``any``, clear) the provider a project opens with by default. + + The default exists so `sp new sl` need not repeat what is nearly always + true. It is changed here rather than by removing and re-adding the project, + and it never prevents `sp new codex sl` from opening the same directory + with the other provider. + """ + if provider not in (*LAUNCH_KINDS, ANY_KIND): + raise ProjectError("a default provider is claude, codex, shell, or any") + directory = _directory(target) if target.startswith("/") else None + matched: dict[str, str] | None = None + + def find(existing: Sequence[Mapping[str, str]]) -> list[dict[str, str]]: + nonlocal matched + for row in existing: + if row["provider"] == IGNORE_KIND: + continue + if row["alias"] == target or ( + directory is not None and row["cwd"] == directory + ): + matched = dict(row) + break + if matched is None: + raise ProjectError(f"no project on the list is called {target}") + return [] + + def rewrite(row: Mapping[str, str]) -> dict[str, str] | None: + if matched is None or row["provider"] == IGNORE_KIND: + return None + if row["cwd"] != matched["cwd"] or row["provider"] == provider: + return None + return {"alias": row["alias"], "provider": provider, "cwd": row["cwd"]} + + result = _mutate_projects(projects_file, state_dir, find, replace=rewrite) + # The caller prints from `added`; an in-place edit reports the same shape. + return {**result, "added": result.get("changed", [])} + + +def ignore_project(projects_file: Path, state_dir: Path, cwd: str) -> dict[str, Any]: + """Keep a directory out of the picker and withdraw any shortcut it has.""" + directory = _directory(cwd) + if directory is None: + raise ProjectError("project directory must be an existing absolute directory") + unchanged = False + + def ignore(existing: Sequence[Mapping[str, str]]) -> list[dict[str, str]]: + nonlocal unchanged + current = [row for row in existing if row["cwd"] == directory] + if len(current) == 1 and current[0]["provider"] == IGNORE_KIND: + unchanged = True + return [] + if current: + # Reuse the alias of the shortcut being withdrawn so the row stays + # recognisable in the file. + alias = current[0]["alias"] + else: + reserved = {row["alias"] for row in existing} + alias = base = _path_aliases([directory])[directory] + counter = 2 + while alias in reserved: + alias = f"{base}-{counter}" + counter += 1 + return [{"alias": alias, "provider": IGNORE_KIND, "cwd": directory}] + + return _mutate_projects( + projects_file, + state_dir, + ignore, + drop=lambda row: not unchanged and row["cwd"] == directory, + ) + + +def unignore_project(projects_file: Path, state_dir: Path, cwd: str) -> dict[str, Any]: + """Withdraw an ignore so the directory can be offered again. + + An ignore is a decision, not a shortcut, and it outlives the directory it + names: a row for a directory that has since been deleted or moved must + still be withdrawable, so this does not require the path to resolve.""" + if ( + not isinstance(cwd, str) + or not cwd.startswith("/") + or len(cwd) > 4096 + or any(ord(character) < 32 or ord(character) == 127 for character in cwd) + ): + raise ProjectError("project directory must be an absolute path") + wanted = {cwd.rstrip("/") or "/"} + resolved = _directory(cwd) + if resolved is not None: + wanted.add(resolved) + return _mutate_projects( + projects_file, + state_dir, + lambda existing: [], + drop=lambda row: row["provider"] == IGNORE_KIND and row["cwd"] in wanted, + ) + + +def _add_words( + words: Sequence[str], default_provider: str | None +) -> tuple[str | None, str | None, str]: + """Read ``add``'s positional words as (alias, default provider, directory). + + The directory is the last word and is always absolute; a provider, when + one is typed at all, is the recognised word before it. Anything else in + front is the alias. Refusing an ambiguous form beats adding a project + under the alias "codex". + """ + words = list(words) + if len(words) > 3: + raise ProjectError("add takes at most an alias, a provider, and a directory") + cwd = words[-1] + if not cwd.startswith("/"): + raise ProjectError("the last word must be the project's absolute directory") + rest = words[:-1] + provider = default_provider + if rest and rest[-1] in ROW_KINDS: + if provider is not None and provider != rest[-1]: + raise ProjectError("the provider was given twice and does not agree") + provider = rest.pop() + if len(rest) > 1: + raise ProjectError("add takes at most an alias, a provider, and a directory") + if provider is not None and provider not in (*LAUNCH_KINDS, ANY_KIND): + raise ProjectError("a default provider is claude, codex, shell, or any") + return (rest[0] if rest else None), provider, cwd + + +def _rollback_note(alias: str) -> None: + """Say out loud what a row with no default costs on a rollback. + + A release built before this one skips a kind it does not know, so a project + written as `any` is not listed at all by an older kit. `session-kit + rollback` is a verb this operator uses; a project quietly vanishing from + the picker after one is not something to leave in a report footnote. + """ + print( + f"Note: a kit older than this release does not understand a project " + f"with no default, so {alias} would not be listed if you roll back. " + f"`session-kit projects default {alias} claude` gives it one." + ) + + +def _human_discovery(value: Mapping[str, Any]) -> None: + projects = value.get("projects", []) + if not projects: + print("No existing Claude Code or Codex project folders were found.") + else: + print(f"Found {len(projects)} existing provider project folder(s):") + for row in projects: + print(f" {row['provider'].capitalize():6} {row['cwd']}") + for warning in value.get("warnings", []): + print(f"Warning: {warning}") + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--projects-file", type=Path) + parser.add_argument("--state-dir", type=Path) + parser.add_argument("--json", action="store_true") + subparsers = parser.add_subparsers(dest="action", required=True) + subparsers.add_parser("discover") + candidates = subparsers.add_parser("candidates") + candidates.add_argument( + "--select", + metavar="CHOICE", + help='resolve "1,3-4" or "a" against the candidate list and print those directories', + ) + importer = subparsers.add_parser("import") + importer.add_argument( + "--only", + action="append", + metavar="DIRECTORY", + help="import just this directory; repeat for more", + ) + importer.add_argument( + "--select", + metavar="CHOICE", + help='import the candidates named by "1,3-4" or "a"', + ) + add = subparsers.add_parser("add") + # One directory, one project. The provider used to be a required third + # word here and a third question in the picker; it is now an optional + # default, so `add /srv/app` and `add app /srv/app` are the whole form. + # `add ALIAS claude /srv/app` still parses: it is what every existing + # script, document, and habit types. + add.add_argument("words", nargs="+", metavar="[ALIAS] [PROVIDER] CWD") + add.add_argument( + "--default", + dest="default_provider", + help="the provider this project opens with unless another is named", + ) + here = subparsers.add_parser("here") + here.add_argument("alias", nargs="?") + here.add_argument("--provider", default=None) + default = subparsers.add_parser("default") + default.add_argument("target", help="project alias or absolute directory") + default.add_argument( + "provider", help="claude, codex, shell, or any to clear the default" + ) + subparsers.add_parser("normalize") + suggest = subparsers.add_parser("suggest") + suggest.add_argument("cwd") + ignore = subparsers.add_parser("ignore") + ignore.add_argument("cwd") + unignore = subparsers.add_parser("unignore") + unignore.add_argument("cwd") + subparsers.add_parser("list") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + home = Path(os.environ.get("HOME", Path.home())) + projects_file = args.projects_file or Path( + os.environ.get( + "SESSION_KIT_PROJECTS_FILE", + Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")) + / "session-kit" + / "projects.tsv", + ) + ) + state_dir = args.state_dir or Path( + os.environ.get( + "SESSION_KIT_STATE_DIR", + Path(os.environ.get("XDG_STATE_HOME", home / ".local" / "state")) + / "session-kit", + ) + ) + try: + if args.action == "discover": + value = discover_projects() + elif args.action == "candidates": + value = project_candidates(projects_file) + if args.select is not None: + value = { + **value, + "selected": select_rows(value["candidates"], args.select), + } + elif args.action == "import": + only = list(args.only or []) + if args.select is not None: + only.extend( + row["cwd"] + for row in select_rows( + project_candidates(projects_file)["candidates"], args.select + ) + ) + if not only: + raise ProjectError("nothing was selected") + value = import_projects(projects_file, state_dir, only=only or None) + elif args.action == "add": + alias, provider, cwd = _add_words(args.words, args.default_provider) + value = add_project(projects_file, state_dir, alias, provider, cwd) + elif args.action == "here": + value = add_project( + projects_file, state_dir, args.alias, args.provider, os.getcwd() + ) + elif args.action == "default": + value = set_default_provider( + projects_file, state_dir, args.target, args.provider + ) + elif args.action == "normalize": + value = normalize_projects(projects_file, state_dir) + elif args.action == "suggest": + directory = _directory(args.cwd) + if directory is None: + raise ProjectError( + "project directory must be an existing absolute directory" + ) + payload = _read_owner_file( + projects_file, + label="Session Kit projects file", + max_bytes=MAX_PROJECTS_FILE_BYTES, + ) + value = { + "alias": suggest_alias(directory, _parse_projects(payload or b"")), + "cwd": directory, + } + elif args.action == "ignore": + value = ignore_project(projects_file, state_dir, args.cwd) + elif args.action == "unignore": + value = unignore_project(projects_file, state_dir, args.cwd) + else: + payload = _read_owner_file( + projects_file, + label="Session Kit projects file", + max_bytes=MAX_PROJECTS_FILE_BYTES, + ) + value = {"projects": _parse_projects(payload or b"")} + if args.json: + json.dump(value, fp=sys.stdout, indent=2, sort_keys=True) + print() + elif args.action == "discover": + _human_discovery(value) + elif args.action == "import": + _human_discovery(value) + print(f"Imported {len(value['added'])} new project shortcut(s).") + if value.get("backup"): + print(f"Previous projects file backed up to {value['backup']}") + elif args.action == "suggest": + print(value["alias"]) + elif args.action == "candidates" and args.select is not None: + for row in value["selected"]: + print(row["cwd"]) + elif args.action == "candidates": + if not value["candidates"]: + print("Every directory your providers use already has a decision.") + else: + print(f"{len(value['candidates'])} directory(s) with no shortcut yet:") + for row in value["candidates"]: + print(f" {row['alias']}\t{row['provider']}\t{row['cwd']}") + for warning in value["warnings"]: + print(f"Warning: {warning}") + elif args.action in ("add", "here"): + if value["added"]: + row = value["added"][0] + if value["removed"]: + print(f"No longer ignoring {row['cwd']}.") + if row["provider"] == ANY_KIND: + print(f"Added {row['alias']}: {row['cwd']}") + print( + "Claude, Codex, or a shell is chosen when you start a " + "session in it." + ) + _rollback_note(row["alias"]) + else: + print( + f"Added {row['alias']}: {row['cwd']} " + f"(opens with {row['provider']} unless you name another)" + ) + else: + print("That project is already on the list.") + elif args.action == "default": + if value["added"]: + row = value["added"][0] + if row["provider"] == ANY_KIND: + print( + f"{row['alias']} has no default provider now; name one " + "when you start a session." + ) + _rollback_note(row["alias"]) + else: + print(f"{row['alias']} opens with {row['provider']} by default.") + else: + print("That project already opens that way. Nothing changed.") + elif args.action == "normalize": + if not value["duplicates"]: + print("Every directory already has exactly one entry.") + else: + for row in value["duplicates"]: + print( + f"{row['cwd']} was listed twice; kept {row['kept_alias']} " + f"and retired {row['alias']}." + ) + for row in value["added"]: + if row["provider"] == ANY_KIND: + print( + f"{row['alias']} has no default provider: it is worked " + "on with more than one." + ) + _rollback_note(row["alias"]) + if value.get("backup"): + print(f"Previous projects file backed up to {value['backup']}") + elif args.action == "ignore": + if value["added"]: + row = value["added"][0] + withdrawn = [ + other + for other in value["removed"] + if other["provider"] != IGNORE_KIND + ] + if withdrawn: + print(f"Withdrew the {withdrawn[0]['alias']} shortcut.") + print(f"Ignoring {row['cwd']}. It stays out of the project list.") + else: + print("That directory is already ignored.") + elif args.action == "unignore": + if value["removed"]: + print( + f"No longer ignoring {value['removed'][0]['cwd']}." + " It can be listed and added again." + ) + else: + print("That directory was not ignored. Nothing changed.") + else: + for row in value["projects"]: + print(f"{row['alias']}\t{row['provider']}\t{row['cwd']}") + return 0 + except (OSError, ProjectError, sqlite3.Error, ValueError) as exc: + print(f"session-kit projects: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lib/sessionkit_inventory/providers.py b/lib/sessionkit_inventory/providers.py index 6fd23ec..3a2a8f2 100644 --- a/lib/sessionkit_inventory/providers.py +++ b/lib/sessionkit_inventory/providers.py @@ -1,129 +1,68 @@ -"""Provider-specific structured state readers.""" +"""Shared provider helpers, the shpool snapshot reader, and the live join. -from __future__ import annotations +Claude Code and Codex readers live in ``providers_claude`` and +``providers_codex``; this module holds what both sides share, the shpool +session list, and the one bounded pass that joins every provider snapshot. +""" -import json -import os -from typing import Any, Mapping +from __future__ import annotations +from pathlib import Path +import shutil +from typing import Any, Callable, Mapping, Sequence -ROLLOUT_TAIL_BYTES = 128 * 1024 -ROLLOUT_LIFECYCLE_SEARCH_BYTES = 8 * 1024 * 1024 +from .common import CollectionError, clean_text -def _aligned_bounded_tail( - descriptor: int, - *, - max_bytes: int, -) -> bytes | None: +def _arg_value(argv: Sequence[str], option: str) -> str: try: - size = os.fstat(descriptor).st_size - offset = max(0, size - max_bytes) - raw = os.pread(descriptor, min(size, max_bytes), offset) - preceding = os.pread(descriptor, 1, offset - 1) if offset else b"\n" - except OSError: - return None - if not raw or not raw.endswith(b"\n"): - return None - if preceding != b"\n": - boundary = raw.find(b"\n") - if boundary < 0: - return None - raw = raw[boundary + 1 :] - return raw + index = argv.index(option) + except ValueError: + return "" + return argv[index + 1] if index + 1 < len(argv) else "" -def _parse_rollout_state(raw: bytes) -> str: - lifecycle_state = "state unavailable" - pending_questions: dict[str, bool] = {} - for line in raw.splitlines(): - if not line: - continue - try: - event = json.loads(line) - except (UnicodeDecodeError, ValueError): - return "state unavailable" - if not isinstance(event, Mapping): - return "state unavailable" - payload = event.get("payload") - event_type = event.get("type") - if event_type == "event_msg": - if not isinstance(payload, Mapping): - return "state unavailable" - lifecycle = payload.get("type") - if lifecycle == "task_started": - pending_questions.clear() - lifecycle_state = "working" - elif lifecycle in {"task_complete", "turn_aborted"}: - pending_questions.clear() - lifecycle_state = "idle" - continue - if event_type != "response_item": - continue - if not isinstance(payload, Mapping): - return "state unavailable" - kind = payload.get("type") - if kind == "function_call_output": - call_id = payload.get("call_id") - if not isinstance(call_id, str) or not call_id: - return "state unavailable" - if call_id in pending_questions: - pending_questions.pop(call_id) - lifecycle_state = "working" - continue - if kind != "function_call" or payload.get("name") != "request_user_input": - continue - call_id = payload.get("call_id") - arguments = payload.get("arguments") - if ( - not isinstance(call_id, str) - or not call_id - or not isinstance(arguments, str) - ): - return "state unavailable" - try: - request: Any = json.loads(arguments) - except (TypeError, ValueError): - return "state unavailable" - if not isinstance(request, Mapping): - return "state unavailable" - auto_resolution = request.get("autoResolutionMs") - if "autoResolutionMs" in request and ( - isinstance(auto_resolution, bool) - or not isinstance(auto_resolution, int) - or auto_resolution <= 0 - ): - return "state unavailable" - pending_questions[call_id] = "autoResolutionMs" in request +def _shpool_executable(*, home_factory: Callable[[], Path]) -> str: + """Resolve shpool for contexts (systemd user services) whose PATH omits it.""" + found = shutil.which("shpool") + if found: + return found + fallback = home_factory() / ".cargo" / "bin" / "shpool" + return str(fallback) if fallback.is_file() else "shpool" - if pending_questions: - if any(not optional for optional in pending_questions.values()): - return "needs your reply" - return "reply optional" - return lifecycle_state - -def rollout_turn_state(descriptor: int) -> str: - """Classify one Codex turn from bounded, structured rollout events. - - The normal status tail is small, but a single long tool result can push the - turn boundary out of that tail. Read backward through a larger fixed window - only when the fast path has no decisive state. This remains bounded and - fails closed when the most recent decisive event is still unavailable. - """ - normal_tail = _aligned_bounded_tail( - descriptor, - max_bytes=ROLLOUT_TAIL_BYTES, - ) - if normal_tail is None: - return "state unavailable" - state = _parse_rollout_state(normal_tail) - if state != "state unavailable": - return state - lifecycle_tail = _aligned_bounded_tail( - descriptor, - max_bytes=ROLLOUT_LIFECYCLE_SEARCH_BYTES, - ) - if lifecycle_tail is None: - return "state unavailable" - return _parse_rollout_state(lifecycle_tail) +def _parse_shpool_payload(payload: Any) -> list[dict[str, Any]]: + if not isinstance(payload, Mapping) or not isinstance( + payload.get("sessions"), list + ): + raise CollectionError("shpool list --json returned an invalid object") + result: list[dict[str, Any]] = [] + seen: set[str] = set() + for item in payload["sessions"]: + if not isinstance(item, Mapping): + raise CollectionError("shpool list --json contained a non-object session") + name = item.get("name") + status_raw = clean_text(item.get("status"), 32).casefold() + if ( + not isinstance(name, str) + or not name + or name in seen + or status_raw not in {"attached", "disconnected"} + ): + raise CollectionError( + "shpool list --json contained an invalid or duplicate session" + ) + started = item.get("started_at_unix_ms") + if not isinstance(started, int) or started < 0: + raise CollectionError( + f"shpool session {name!r} has invalid started_at_unix_ms" + ) + seen.add(name) + result.append( + { + "name": name, + "status": "Attached" if status_raw == "attached" else "Disconnected", + "started_at_unix_ms": started, + } + ) + return result diff --git a/lib/sessionkit_inventory/providers_claude.py b/lib/sessionkit_inventory/providers_claude.py new file mode 100644 index 0000000..ab7879d --- /dev/null +++ b/lib/sessionkit_inventory/providers_claude.py @@ -0,0 +1,445 @@ +"""Read-only Claude Code state readers: agent records and transcripts.""" + +from __future__ import annotations + +from datetime import datetime +import json +import os +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +from . import attention as _attention +from .common import CollectionError, _valid_name_since, clean_text, valid_uuid +from .transcripts import claude_roots + + +MAX_CLAUDE_TITLE_SCAN_BYTES = 64 * 1024 +MAX_CLAUDE_QUESTION_TAIL_BYTES = 256 * 1024 +# How many copies of one conversation are read, newest first. +MAX_CLAUDE_TITLE_TRANSCRIPTS = 4 + + +def _parse_claude_payload( + payload: Any, + *, + palette: Sequence[str], + attention_records: Mapping[str, Mapping[str, Any]] | None = None, + attention_source: str = "poll", +) -> list[dict[str, Any]]: + if not isinstance(payload, list): + raise CollectionError("claude agents --json returned a non-array") + result: list[dict[str, Any]] = [] + seen_pids: set[int] = set() + for item in payload: + if not isinstance(item, Mapping): + continue + pid = item.get("pid") + uuid = valid_uuid(item.get("sessionId")) + if not isinstance(pid, int) or pid <= 0 or not uuid or pid in seen_pids: + continue + seen_pids.add(pid) + raw_status = clean_text(item.get("status"), 40).casefold() + waiting_for = item.get("waitingFor") + status, needs_you = _attention.poll_attention(raw_status, waiting_for) + # The poll says what was true when it ran; the Notification hook said + # so at the moment it happened. Whichever is newer decides -- and the + # poll's age is the vendor's own statusUpdatedAt, attached during + # enrichment, never the time we ran the command. + stamp = item.get("_session_kit_status_updated_at") + record = (attention_records or {}).get(uuid) + decided = _attention.merge( + poll_status=status, + poll_needs_you=needs_you, + poll_stamp_ms=stamp if isinstance(stamp, int) and stamp > 0 else None, + record=record, + source=attention_source, + ) + status = decided["agent_status"] + needs_you = decided["needs_you"] + permission_recorded = ( + record.get("recorded_at_ms") if isinstance(record, Mapping) else None + ) + pending_tool_at = item.get("_session_kit_pending_tool_use_at_unix_ms") + correlated_permission = bool( + decided.get("notification_type") == "permission_prompt" + and isinstance(permission_recorded, int) + and not isinstance(permission_recorded, bool) + and isinstance(pending_tool_at, int) + and not isinstance(pending_tool_at, bool) + and pending_tool_at <= permission_recorded <= pending_tool_at + 30_000 + ) + blocking_question = bool( + item.get("_session_kit_pending_ask_user_question") + ) or bool( + needs_you + and correlated_permission + and item.get("_session_kit_pending_tool_use") + ) + result.append( + { + "pid": pid, + "attention_source": decided["attention_source"], + "uuid": uuid, + "cwd": clean_text(item.get("cwd"), 4096), + "kind": clean_text(item.get("kind"), 40), + "started_at_unix_ms": item.get("startedAt") + if isinstance(item.get("startedAt"), int) + else None, + "title": clean_text(item.get("name"), 120), + "ai_title": clean_text(item.get("aiTitle"), 120), + "agent_name": clean_text(item.get("agentName"), 120), + "agent_color": item.get("agentColor") + if item.get("agentColor") in palette + else "", + "name_source": clean_text(item.get("nameSource"), 20), + "name_since": _valid_name_since(item.get("nameSince")), + "status": status or "unknown", + "needs_you": needs_you, + "blocking_question": blocking_question, + } + ) + return result + + +def _record_time_ms(record: Mapping[str, Any]) -> int | None: + raw = record.get("timestamp") + if not isinstance(raw, str): + return None + try: + return int( + datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp() * 1000 + ) + except ValueError: + return None + + +def _pending_claude_tool_evidence(raw: bytes) -> tuple[bool, bool, int | None]: + """Whether the bounded tail holds an unanswered picker or tool request. + + Claude writes an assistant ``tool_use`` with an exact id, followed by a + user ``tool_result`` carrying that id. The same pair is present for a + tool waiting at the permission gate; the live attention reading is what + separates that wait from a tool which is merely still running. + """ + pending: dict[str, tuple[str, int | None]] = {} + for line in raw.splitlines(): + try: + record = json.loads(line) + except (UnicodeDecodeError, ValueError): + continue + if not isinstance(record, Mapping): + continue + # Subagent/sidechain records can be embedded beside the parent turn; + # only the interactive conversation can be blocking this row. + if record.get("isSidechain") is not False: + continue + message = record.get("message") + if not isinstance(message, Mapping): + continue + content = message.get("content") + if not isinstance(content, list): + continue + for part in content: + if not isinstance(part, Mapping): + continue + if part.get("type") == "tool_use": + # Only an assistant turn can open a real tool request. A + # malformed user record carrying tool-use-shaped content must + # not invent a visible picker or permission question. + if ( + record.get("type") != "assistant" + or message.get("role") != "assistant" + ): + continue + tool_id = part.get("id") + name = part.get("name") + if isinstance(tool_id, str) and tool_id and isinstance(name, str): + pending[tool_id] = (name, _record_time_ms(record)) + elif part.get("type") == "tool_result": + tool_id = part.get("tool_use_id") + if isinstance(tool_id, str): + pending.pop(tool_id, None) + timestamps = [stamp for _, stamp in pending.values() if stamp is not None] + return ( + any(name == "AskUserQuestion" for name, _ in pending.values()), + bool(pending), + max(timestamps) if timestamps else None, + ) + + +def _pending_claude_tools(raw: bytes) -> tuple[bool, bool]: + """Compatibility-sized answer used by focused transcript tests.""" + ask, pending, _ = _pending_claude_tool_evidence(raw) + return ask, pending + + +def read_claude_transcript_signals( + uuid: str, + home: Path | None = None, + *, + environ: Mapping[str, str], + home_factory: Callable[[], Path], + palette: Sequence[str], + config_dir: Path | None = None, +) -> dict[str, Any]: + """Return Claude's persisted per-conversation title and color evidence. + + The TUI stores its conversation auto-title as an ``ai-title`` transcript + record and its session color as an ``agent-color`` record, neither lives + in the session record, so the derived window label and the visible + conversation state can disagree. Reads are bounded to the transcript's + head and tail; the last record of each kind wins. Any problem returns + empty evidence. + + Every profile is searched, not just the default one. A session launched + on an enrolled account runs with CLAUDE_CONFIG_DIR set, so its transcript + lives under that profile and the default root does not have it, this read + returned "no title, no color" for those sessions and every caller treated + that as "the provider never set one". The write side already searches all + roots (`names_push._claude_transcripts`, fixed for the same reason); this + is that fix on the read side. An explicit `config_dir` still pins the + search to exactly one root. When the same conversation exists in more + than one profile the NEWEST copy is believed, never the one whose alias + happens to sort last. + """ + signals: dict[str, Any] = { + "ai_title": "", + "agent_name": "", + "agent_color": "", + "pending_ask_user_question": False, + "pending_tool_use": False, + "pending_tool_use_at_unix_ms": None, + } + exact_uuid = valid_uuid(uuid) + if not exact_uuid: + return signals + base = home if home is not None else Path(environ.get("HOME") or home_factory()) + if config_dir is not None: + roots = [config_dir] + else: + search_env = {**environ, "HOME": os.fspath(base)} + if home is not None: + # An explicit home means "search THAT tree". The caller's own + # CLAUDE_CONFIG_DIR describes the process doing the reading, not + # the session being read, so it is not a root here. + search_env.pop("CLAUDE_CONFIG_DIR", None) + roots = claude_roots(search_env) or [base / ".claude"] + # One conversation can exist in more than one profile, an account switch + # copies it, and "the last record wins" over a root list in alias order + # handed the answer to whichever profile sorted last, so a stale copy + # could out-rank the file the live session is writing, and a fixed cut-off + # taken in that order could drop the current profile entirely. Order by + # recency instead (the rule `transcripts._claude_transcript` already uses + # for this same question) and keep the NEWEST few, so the cap can never + # hide the file that is actually in use. + dated: list[tuple[float, str, Path]] = [] + for claude_root in roots: + try: + found = list((claude_root / "projects").glob(f"*/{exact_uuid}.jsonl")) + except OSError: + continue + for path in found: + try: + if path.is_symlink() or not path.is_file(): + continue + dated.append((path.stat().st_mtime, os.fspath(path), path)) + except OSError: + continue + dated.sort() + transcripts = [path for _, _, path in dated[-MAX_CLAUDE_TITLE_TRANSCRIPTS:]] + for transcript_index, transcript in enumerate(transcripts): + if transcript.is_symlink(): + continue + try: + size = transcript.stat().st_size + with open(transcript, "rb") as handle: + chunks = [handle.read(MAX_CLAUDE_TITLE_SCAN_BYTES)] + if size > 2 * MAX_CLAUDE_TITLE_SCAN_BYTES: + handle.seek(size - MAX_CLAUDE_TITLE_SCAN_BYTES) + chunks.append(handle.read(MAX_CLAUDE_TITLE_SCAN_BYTES)) + elif size > MAX_CLAUDE_TITLE_SCAN_BYTES: + chunks.append(handle.read()) + question_tail = b"" + if transcript_index == len(transcripts) - 1: + offset = max(0, size - MAX_CLAUDE_QUESTION_TAIL_BYTES) + handle.seek(offset) + question_tail = handle.read(MAX_CLAUDE_QUESTION_TAIL_BYTES) + if offset: + boundary = question_tail.find(b"\n") + question_tail = ( + question_tail[boundary + 1 :] if boundary >= 0 else b"" + ) + except OSError: + continue + if transcript_index == len(transcripts) - 1: + ask, pending_tool, tool_at = _pending_claude_tool_evidence(question_tail) + signals["pending_ask_user_question"] = ask + signals["pending_tool_use"] = pending_tool + signals["pending_tool_use_at_unix_ms"] = tool_at + for chunk in chunks: + for line in chunk.split(b"\n"): + if not any( + marker in line + for marker in (b'"ai-title"', b'"agent-name"', b'"agent-color"') + ): + continue + try: + record = json.loads(line.decode("utf-8", "strict")) + except (UnicodeDecodeError, ValueError): + continue + if ( + not isinstance(record, Mapping) + or record.get("sessionId") != exact_uuid + ): + continue + if record.get("type") == "ai-title": + candidate = clean_text(record.get("aiTitle"), 120) + if candidate: + signals["ai_title"] = candidate + elif record.get("type") == "agent-name": + candidate = clean_text(record.get("agentName"), 120) + if candidate: + signals["agent_name"] = candidate + elif record.get("type") == "agent-color": + color = record.get("agentColor") + if isinstance(color, str) and color in palette: + signals["agent_color"] = color + return signals + + +def read_claude_ai_title( + uuid: str, + home: Path | None = None, + *, + transcript_signals: Callable[[str, Path | None], dict[str, str]], +) -> str: + """Compatibility wrapper: the auto-title half of the transcript signals.""" + return transcript_signals(uuid, home)["ai_title"] + + +def _enrich_claude_payload( + payload: Any, + *, + environ: Mapping[str, str], + home_factory: Callable[[], Path], + palette: Sequence[str], + transcript_signals: Callable[[str, Path | None], dict[str, str]], +) -> Any: + """Attach per-session nameSource and ai-title evidence for the collector.""" + if not isinstance(payload, list): + return payload + home = Path(environ.get("HOME") or home_factory()) + default_root = home / ".claude" + for item in payload: + if not isinstance(item, dict): + continue + pid = item.get("pid") + uuid = valid_uuid(item.get("sessionId")) + if not isinstance(pid, int) or pid <= 0 or not uuid: + continue + raw_root = item.get("_session_kit_claude_config_dir") + claude_root = ( + Path(raw_root) + if isinstance(raw_root, str) and Path(raw_root).is_absolute() + else default_root + ) + record_path = claude_root / "sessions" / f"{pid}.json" + if not record_path.is_symlink() and record_path.is_file(): + try: + record = json.loads(record_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + record = None + if isinstance(record, Mapping) and record.get("sessionId") == uuid: + if "nameSource" not in item: + item["nameSource"] = record.get("nameSource") + if "nameSince" not in item: + item["nameSince"] = record.get("nameSince") + # When Claude last changed this session's status, by its own + # clock. It is the only way to tell a poll answer that is + # older than a hook record from one that supersedes it; a + # session record without it simply leaves the comparison + # undecided (see attention.merge). + stamp = record.get("statusUpdatedAt") + if isinstance(stamp, int) and stamp > 0: + item["_session_kit_status_updated_at"] = stamp + needs_title = not item.get("aiTitle") + needs_name = not item.get("agentName") + needs_color = item.get("agentColor") not in palette + # Blocking state is live transcript evidence, so it is read on every + # pass even when all three cosmetic fields are already populated. + if claude_root == default_root: + signals = transcript_signals(uuid, home) + else: + signals = read_claude_transcript_signals( + uuid, + home, + environ=environ, + home_factory=home_factory, + palette=palette, + config_dir=claude_root, + ) + if needs_title or needs_name or needs_color: + if needs_title: + item["aiTitle"] = signals["ai_title"] + if needs_name: + item["agentName"] = signals["agent_name"] + if needs_color: + item["agentColor"] = signals["agent_color"] + item["_session_kit_pending_ask_user_question"] = bool( + signals.get("pending_ask_user_question") + ) + item["_session_kit_pending_tool_use"] = bool(signals.get("pending_tool_use")) + item["_session_kit_pending_tool_use_at_unix_ms"] = signals.get( + "pending_tool_use_at_unix_ms" + ) + return payload + + +def claude_subagents( + root_uuid: str, + process_table: Mapping[int, Mapping[str, Any]], + *, + arg_value: Callable[[Sequence[str], str], str], +) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for pid, process in process_table.items(): + argv = process.get("cmdline") or [] + if arg_value(argv, "--parent-session-id").lower() != root_uuid: + continue + title = clean_text(arg_value(argv, "--agent-name"), 80) or f"PID {pid}" + result.append( + { + "provider": "claude", + "uuid": None, + "pid": pid, + "title": title, + "status": "running", + } + ) + return sorted(result, key=lambda item: (item["title"].casefold(), item["pid"])) + + +def _is_native_claude(process: Mapping[str, Any]) -> bool: + argv = process.get("cmdline") or [] + executable = Path(str(argv[0])).name if argv else "" + comm = clean_text(process.get("comm"), 128) + return executable == "claude" or comm in {"claude", "claude.exe"} + + +def _native_claude_uuid( + process: Mapping[str, Any], + *, + is_native_claude: Callable[[Mapping[str, Any]], bool], + arg_value: Callable[[Sequence[str], str], str], +) -> str | None: + if not is_native_claude(process): + return None + argv = list(process.get("cmdline") or []) + if "--parent-session-id" in argv or "--fork-session" in argv: + return None + candidate = ( + arg_value(argv, "--session-id") + or arg_value(argv, "--resume") + or str(process.get("claude_session_id", "")) + ) + return valid_uuid(candidate) diff --git a/lib/sessionkit_inventory/providers_codex.py b/lib/sessionkit_inventory/providers_codex.py new file mode 100644 index 0000000..ecfe666 --- /dev/null +++ b/lib/sessionkit_inventory/providers_codex.py @@ -0,0 +1,730 @@ +"""Read-only Codex state readers: rollouts, session index, and state store.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import sqlite3 +import stat +from typing import Any, Callable, Mapping, Sequence + +from .common import CollectionError, clean_text, valid_uuid + + +ROLLOUT_TAIL_BYTES = 128 * 1024 +ROLLOUT_LIFECYCLE_SEARCH_BYTES = 8 * 1024 * 1024 + + +def _aligned_bounded_tail( + descriptor: int, + *, + max_bytes: int, +) -> bytes | None: + try: + size = os.fstat(descriptor).st_size + offset = max(0, size - max_bytes) + raw = os.pread(descriptor, min(size, max_bytes), offset) + preceding = os.pread(descriptor, 1, offset - 1) if offset else b"\n" + except OSError: + return None + if not raw or not raw.endswith(b"\n"): + return None + if preceding != b"\n": + boundary = raw.find(b"\n") + if boundary < 0: + return None + raw = raw[boundary + 1 :] + return raw + + +def _final_text_asks_a_question(payload: Mapping) -> bool: + """True when an assistant message ends by asking the human something. + + Codex serializes only explicit request_user_input calls as questions, but + assistants routinely finish a turn with prose like "which hostname should + I use?" and then task_complete: the human is being waited on while every + structured signal says idle. Conservative test: the LAST assistant + message's final non-empty line ends with a question mark. + """ + if payload.get("role") != "assistant": + return False + content = payload.get("content") + if not isinstance(content, list): + return False + text = "" + for part in content: + if isinstance(part, Mapping) and isinstance(part.get("text"), str): + text = part["text"] + lines = [line.strip() for line in text.strip().splitlines() if line.strip()] + return bool(lines) and lines[-1].endswith("?") + + +def _parse_rollout_state(raw: bytes) -> str: + lifecycle_state = "state unavailable" + pending_questions: dict[str, bool] = {} + final_message_asks = False + for line in raw.splitlines(): + if not line: + continue + try: + event = json.loads(line) + except (UnicodeDecodeError, ValueError): + return "state unavailable" + if not isinstance(event, Mapping): + return "state unavailable" + payload = event.get("payload") + event_type = event.get("type") + if event_type == "event_msg": + if not isinstance(payload, Mapping): + return "state unavailable" + lifecycle = payload.get("type") + if lifecycle == "task_started": + pending_questions.clear() + final_message_asks = False + lifecycle_state = "working" + elif lifecycle in {"task_complete", "turn_aborted"}: + pending_questions.clear() + lifecycle_state = "idle" + continue + if event_type != "response_item": + continue + if not isinstance(payload, Mapping): + return "state unavailable" + kind = payload.get("type") + if kind == "function_call_output": + call_id = payload.get("call_id") + if not isinstance(call_id, str) or not call_id: + return "state unavailable" + if call_id in pending_questions: + pending_questions.pop(call_id) + lifecycle_state = "working" + continue + if kind == "message": + final_message_asks = _final_text_asks_a_question(payload) + continue + if kind != "function_call" or payload.get("name") != "request_user_input": + continue + call_id = payload.get("call_id") + arguments = payload.get("arguments") + if ( + not isinstance(call_id, str) + or not call_id + or not isinstance(arguments, str) + ): + return "state unavailable" + try: + request: Any = json.loads(arguments) + except (TypeError, ValueError): + return "state unavailable" + if not isinstance(request, Mapping): + return "state unavailable" + auto_resolution = request.get("autoResolutionMs") + if "autoResolutionMs" in request and ( + isinstance(auto_resolution, bool) + or not isinstance(auto_resolution, int) + or auto_resolution <= 0 + ): + return "state unavailable" + pending_questions[call_id] = "autoResolutionMs" in request + + if pending_questions: + if any(not optional for optional in pending_questions.values()): + return "needs your reply" + return "reply optional" + if lifecycle_state == "idle" and final_message_asks: + # A completed turn whose last words are a question: the human is + # being waited on even though no structured request exists. Soft + # signal only, never the hard "needs your reply". + return "reply optional" + return lifecycle_state + + +def rollout_turn_state(descriptor: int) -> str: + """Classify one Codex turn from bounded, structured rollout events. + + The normal status tail is small, but a single long tool result can push the + turn boundary out of that tail. Read backward through a larger fixed window + only when the fast path has no decisive state. This remains bounded and + fails closed when the most recent decisive event is still unavailable. + """ + normal_tail = _aligned_bounded_tail( + descriptor, + max_bytes=ROLLOUT_TAIL_BYTES, + ) + if normal_tail is None: + return "state unavailable" + state = _parse_rollout_state(normal_tail) + if state != "state unavailable": + return state + lifecycle_tail = _aligned_bounded_tail( + descriptor, + max_bytes=ROLLOUT_LIFECYCLE_SEARCH_BYTES, + ) + if lifecycle_tail is None: + return "state unavailable" + return _parse_rollout_state(lifecycle_tail) + + +def _is_native_codex(process: Mapping[str, Any]) -> bool: + argv = process.get("cmdline") or [] + if not argv: + return False + executable = Path(argv[0]).name + return executable == "codex" and process.get("comm") != "node" + + +def _is_codex_app_server( + process: Mapping[str, Any], + *, + is_native_codex: Callable[[Mapping[str, Any]], bool], +) -> bool: + """Recognize the native Codex process that owns remote TUI threads.""" + if not is_native_codex(process): + return False + argv = list(process.get("cmdline") or []) + return "app-server" in argv[1:] + + +def codex_refresh_target( + process_table: Mapping[int, Mapping[str, Any]], + shell_pid: int, + shell_generation: int, + provider_pid: int, + provider_generation: int, + *, + is_native_codex: Callable[[Mapping[str, Any]], bool], + is_codex_app_server: Callable[[Mapping[str, Any]], bool], + children_index: Callable[[Mapping[int, Mapping[str, Any]]], dict[int, list[int]]], + descendants: Callable[..., list[int]], + arg_value: Callable[[Sequence[str], str], str], + max_proc_nodes: int, +) -> tuple[int, int]: + """Prove the exact Codex process a title refresh may restart. + + A direct CLI owns both the rollout and TUI. For a remote TUI the App + Server owns the rollout, but restarting it would destroy the resume socket; + select the one native TUI connected to that exact server instead. + """ + shell = process_table.get(shell_pid, {}) + provider = process_table.get(provider_pid, {}) + if shell.get("start_ticks") != shell_generation: + raise CollectionError("managed shell generation changed") + if provider.get("start_ticks") != provider_generation or not is_native_codex( + provider + ): + raise CollectionError("Codex provider generation changed") + children = children_index(process_table) + tree = descendants( + shell_pid, + children, + max_nodes=max_proc_nodes, + max_depth=32, + ) + if provider_pid not in tree: + raise CollectionError("Codex provider is outside the managed shell") + if not is_codex_app_server(provider): + return provider_pid, provider_generation + provider_argv = list(provider.get("cmdline") or []) + listen = arg_value(provider_argv, "--listen") + if not listen.startswith("unix://"): + raise CollectionError("Codex App Server socket is unavailable") + candidates: list[tuple[int, int]] = [] + for pid in tree: + if pid == provider_pid: + continue + process = process_table.get(pid, {}) + if not is_native_codex(process) or is_codex_app_server(process): + continue + argv = list(process.get("cmdline") or []) + generation = process.get("start_ticks") + if ( + arg_value(argv, "--remote") == listen + and "--no-alt-screen" in argv + and isinstance(generation, int) + ): + candidates.append((pid, generation)) + if len(candidates) != 1: + raise CollectionError(f"expected one remote Codex TUI, found {len(candidates)}") + return candidates[0] + + +def _rollout_meta_fd( + descriptor: int, + *, + rollout_turn_state: Callable[[int], str], +) -> dict[str, Any] | None: + """Parse bounded rollout metadata from an already-open descriptor.""" + try: + raw = os.pread(descriptor, 1024 * 1024, 0) + except OSError: + return None + for line in raw.splitlines()[:64]: + try: + event = json.loads(line) + except (UnicodeDecodeError, ValueError): + continue + if event.get("type") == "session_meta" and isinstance( + event.get("payload"), Mapping + ): + meta = dict(event["payload"]) + meta["_turn_state"] = rollout_turn_state(descriptor) + return meta + return None + + +def codex_open_rollouts( + pid: int, + proc_root: Path, + codex_home: Path, + expected_process: Mapping[str, Any], + *, + expected_proc_identity: Callable[ + [Path, int, Mapping[str, Any]], tuple[int, int, int] | None + ], + rollout_meta_fd: Callable[[int], dict[str, Any] | None], +) -> list[dict[str, Any]]: + """Read metadata through stable, native Codex-owned proc descriptors.""" + initial_identity = expected_proc_identity(proc_root, pid, expected_process) + if initial_identity is None: + return [] + fd_dir = proc_root / str(pid) / "fd" + try: + descriptors = sorted(fd_dir.iterdir(), key=lambda item: int(item.name)) + except (OSError, ValueError): + return [] + home_resolved = codex_home.resolve(strict=False) + result: list[dict[str, Any]] = [] + seen: set[tuple[int, int]] = set() + for descriptor in descriptors: + before_identity = expected_proc_identity(proc_root, pid, expected_process) + if before_identity != initial_identity: + return [] + try: + opened = os.open( + descriptor, + os.O_RDONLY | getattr(os, "O_CLOEXEC", 0), + ) + except OSError: + continue + try: + opened_stat = os.fstat(opened) + if not stat.S_ISREG(opened_stat.st_mode): + continue + actual_link = Path(os.readlink(f"/proc/self/fd/{opened}")) + actual_text = str(actual_link) + if actual_text.endswith(" (deleted)"): + actual_link = Path(actual_text[: -len(" (deleted)")]) + if "rollout-" not in actual_link.name or actual_link.suffix != ".jsonl": + continue + resolved = actual_link.resolve(strict=False) + resolved.relative_to(home_resolved) + file_identity = (opened_stat.st_dev, opened_stat.st_ino) + if file_identity in seen: + continue + meta = rollout_meta_fd(opened) + after_stat = os.fstat(opened) + if ( + opened_stat.st_dev, + opened_stat.st_ino, + opened_stat.st_mode, + ) != ( + after_stat.st_dev, + after_stat.st_ino, + after_stat.st_mode, + ): + continue + except (OSError, ValueError): + continue + finally: + os.close(opened) + after_identity = expected_proc_identity(proc_root, pid, expected_process) + if after_identity != before_identity: + return [] + seen.add(file_identity) + if meta is not None: + result.append(meta) + if expected_proc_identity(proc_root, pid, expected_process) != initial_identity: + return [] + return result + + +def codex_rollout_by_uuid( + codex_home: Path, + uuid: str, + *, + max_files: int = 10_000, + rollout_meta_fd: Callable[[int], dict[str, Any] | None], +) -> list[dict[str, Any]]: + """Read one exact Darwin Codex rollout selected only by its process UUID.""" + sessions = codex_home / "sessions" + try: + root = sessions.resolve(strict=True) + except OSError: + return [] + seen = 0 + candidates: list[Path] = [] + try: + for directory, names, files in os.walk(root): + names[:] = sorted(name for name in names if not name.startswith(".")) + for name in sorted(files): + seen += 1 + if seen > max_files: + raise CollectionError("Codex rollout tree exceeds safety bound") + if ( + uuid in name + and name.startswith("rollout-") + and name.endswith(".jsonl") + ): + candidates.append(Path(directory) / name) + except OSError: + return [] + if len(candidates) != 1: + return [] + path = candidates[0] + try: + resolved = path.resolve(strict=True) + resolved.relative_to(root) + descriptor = os.open( + resolved, + os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), + ) + except (OSError, ValueError): + return [] + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.geteuid(): + return [] + meta = rollout_meta_fd(descriptor) + if meta is None or valid_uuid(meta.get("id")) != uuid: + return [] + return [meta] + finally: + os.close(descriptor) + + +def codex_rollout_state_by_path( + codex_home: Path, + rollout_path: object, + *, + rollout_turn_state: Callable[[int], str], +) -> str: + """Read one child thread's state from an owner-controlled Codex rollout.""" + if not isinstance(rollout_path, str) or not rollout_path.startswith("/"): + return "state unavailable" + try: + root = codex_home.resolve(strict=True) + raw_path = Path(rollout_path) + pathname = raw_path.lstat() + resolved = raw_path.resolve(strict=True) + resolved.relative_to(root) + descriptor = os.open( + raw_path, + os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), + ) + except (OSError, ValueError): + return "state unavailable" + try: + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or metadata.st_nlink != 1 + or (metadata.st_dev, metadata.st_ino) != (pathname.st_dev, pathname.st_ino) + ): + return "state unavailable" + return rollout_turn_state(descriptor) + finally: + os.close(descriptor) + + +def index_codex_processes( + process_table: Mapping[int, Mapping[str, Any]], + proc_root: Path, + codex_home: Path, + *, + runtime_platform: Callable[[], str], + darwin_platform: str, + is_native_codex: Callable[[Mapping[str, Any]], bool], + rollout_by_uuid: Callable[[Path, str], list[dict[str, Any]]], + open_rollouts: Callable[[int, Path, Path, Mapping[str, Any]], list[dict[str, Any]]], +) -> dict[int, list[dict[str, Any]]]: + def process_home(process: Mapping[str, Any]) -> Path | None: + raw = process.get("codex_home") + if not isinstance(raw, str) or not raw: + return codex_home + candidate = Path(raw) + if not candidate.is_absolute() or ".." in candidate.parts: + return None + return candidate + + if runtime_platform() == darwin_platform: + result: dict[int, list[dict[str, Any]]] = {} + for pid, process in process_table.items(): + if not is_native_codex(process): + continue + profile_home = process_home(process) + uuid = valid_uuid(process.get("codex_thread_id")) + result[pid] = ( + rollout_by_uuid(profile_home, uuid) + if profile_home is not None and uuid + else [] + ) + return result + return { + pid: ( + open_rollouts(pid, proc_root, profile_home, process) + if (profile_home := process_home(process)) is not None + else [] + ) + for pid, process in process_table.items() + if is_native_codex(process) + } + + +def _root_codex_uuid( + process: Mapping[str, Any], + metadata: Sequence[Mapping[str, Any]], + *, + is_codex_app_server: Callable[[Mapping[str, Any]], bool], +) -> list[str]: + """Return exact root threads owned by one native Codex process. + + Direct Codex writes ``source=cli``. A remote TUI delegates its thread to + the native ``codex app-server`` process, whose open rollout is currently + marked ``source=vscode``. Only that validated process type may claim the + second source; an ordinary Codex process cannot turn arbitrary editor + rollouts into managed-session identity evidence. + """ + identities: set[str] = set() + app_server = is_codex_app_server(process) + for meta in metadata: + uuid = valid_uuid(meta.get("session_id")) + event_id = valid_uuid(meta.get("id")) + source = meta.get("source") + accepted_source = source == "cli" or (app_server and source == "vscode") + if accepted_source and uuid and event_id == uuid: + identities.add(uuid) + return sorted(identities) + + +def _codex_turn_state(metadata: Sequence[Mapping[str, Any]], wanted_uuid: str) -> str: + """Turn state for one exact conversation, from its own rollout metadata.""" + for meta in metadata: + uuid = valid_uuid(meta.get("session_id")) + event_id = valid_uuid(meta.get("id")) + if uuid == wanted_uuid and event_id == uuid: + state = meta.get("_turn_state") + if state in { + "needs your reply", + "reply optional", + "working", + "idle", + "state unavailable", + }: + return str(state) + return "state unavailable" + + +def _codex_state_databases(codex_root: Path) -> list[Path]: + """Codex state stores ordered oldest-to-newest by schema number. + + Lexical order breaks at two digits (state_10 sorts before state_5), so + the numeric suffix is the key. Empty files are placeholder artifacts, + never the live store. + """ + + def version(path: Path) -> tuple[int, str]: + suffix = path.stem.rsplit("_", 1)[-1] + return (int(suffix) if suffix.isdigit() else -1, path.name) + + try: + candidates = [ + path + for path in codex_root.glob("state_*.sqlite") + if path.stat().st_size > 0 + ] + except OSError: + return [] + return sorted(candidates, key=version) + + +def _codex_home( + environ: Mapping[str, str] | None = None, + *, + default_home: Path | None = None, + process_environ: Mapping[str, str], + home: Callable[[], Path], +) -> Path: + """Resolve one Codex home consistently for reads and writes.""" + env = environ if environ is not None else process_environ + base = default_home if default_home is not None else home() + return Path( + env.get("SESSION_KIT_CODEX_HOME") or env.get("CODEX_HOME") or (base / ".codex") + ).expanduser() + + +def _codex_paths( + *, + environ: Mapping[str, str], + codex_home: Callable[[], Path], + codex_state_databases: Callable[[Path], list[Path]], +) -> tuple[Path, Path]: + home = codex_home() + db_override = environ.get("SESSION_KIT_CODEX_DB") + if db_override: + return home, Path(db_override).expanduser() + databases = codex_state_databases(home) + if databases: + return home, databases[-1] + return home, home / "state_5.sqlite" + + +def read_codex_session_index( + path: Path, + warning_sink: list[str] | None = None, + *, + read_bounded_owner_file: Callable[..., bytes | None], + max_bytes: int, +) -> dict[str, str]: + """Read exact UUID-bound Codex names from the append-only local index.""" + payload = read_bounded_owner_file( + path, + label="Codex session index", + max_bytes=max_bytes, + allow_missing=True, + ) + if payload is None: + return {} + try: + text = payload.decode("utf-8", "strict") + except UnicodeDecodeError as exc: + raise CollectionError("Codex session index is not valid UTF-8") from exc + names: dict[str, str] = {} + skipped = 0 + for number, line in enumerate(text.splitlines(), start=1): + if not line: + continue + try: + item = json.loads(line) + except ValueError: + skipped += 1 + continue + if not isinstance(item, Mapping): + skipped += 1 + continue + uuid = valid_uuid(item.get("id")) + title = clean_text(item.get("thread_name"), 120) + if not uuid or not title: + skipped += 1 + continue + names[uuid] = title + if skipped and warning_sink is not None: + warning_sink.append( + f"Codex session index ignored {skipped} malformed or unnamed " + f"entr{'y' if skipped == 1 else 'ies'}" + ) + return names + + +def read_codex_db( + db_path: Path, + session_index_names: Mapping[str, str] | None = None, + *, + session_index_reader: Callable[[Path], dict[str, str]], + rollout_state_by_path: Callable[[Path, object], str], +) -> tuple[dict[str, dict[str, Any]], dict[str, list[dict[str, Any]]]]: + """Read titles and spawn edges from Codex state without creating journal files.""" + index_names = ( + dict(session_index_names) + if session_index_names is not None + else session_index_reader(db_path.parent / "session_index.jsonl") + ) + threads = { + uuid: { + "id": uuid, + "title": "", + "cwd": "", + "session_index_name": clean_text(title, 120), + } + for raw_uuid, title in index_names.items() + if (uuid := valid_uuid(raw_uuid)) and clean_text(title, 120) + } + if not db_path.is_file(): + return threads, {} + uri = f"file:{db_path.resolve()}?mode=ro" + connection = sqlite3.connect(uri, uri=True, timeout=1.0) + connection.row_factory = sqlite3.Row + try: + connection.execute("PRAGMA query_only=ON") + columns = { + row["name"] + for row in connection.execute("PRAGMA table_info(threads)").fetchall() + } + required = {"id", "title", "cwd"} + if not required.issubset(columns): + raise CollectionError("Codex threads table is missing required columns") + optional = [ + name + for name in ( + "name", + "agent_nickname", + "agent_path", + "thread_source", + "first_user_message", + "rollout_path", + ) + if name in columns + ] + select = ", ".join(["id", "title", "cwd", *optional]) + database_threads = { + row["id"]: dict(row) + for row in connection.execute(f"SELECT {select} FROM threads").fetchall() + if valid_uuid(row["id"]) + } + for uuid, thread in database_threads.items(): + index_title = threads.get(uuid, {}).get("session_index_name") + threads[uuid] = thread + if index_title: + threads[uuid]["session_index_name"] = index_title + edges: dict[str, list[dict[str, Any]]] = {} + edge_tables = { + row["name"] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + if "thread_spawn_edges" in edge_tables: + for row in connection.execute( + "SELECT parent_thread_id, child_thread_id, status FROM thread_spawn_edges" + ).fetchall(): + child = threads.get(row["child_thread_id"], {}) + edge_status = clean_text(row["status"], 40) or "unknown" + if edge_status.casefold() == "open": + observed = rollout_state_by_path( + db_path.parent, child.get("rollout_path") + ) + if observed != "state unavailable": + edge_status = observed + edges.setdefault(row["parent_thread_id"], []).append( + { + "provider": "codex", + "uuid": valid_uuid(row["child_thread_id"]), + "pid": None, + "title": clean_text( + child.get("session_index_name") + or child.get("name") + or child.get("agent_nickname") + or child.get("agent_path") + or child.get("title"), + 80, + ) + or str(row["child_thread_id"])[:8], + "status": edge_status, + } + ) + for items in edges.values(): + items.sort(key=lambda item: (item["title"].casefold(), item["uuid"] or "")) + return threads, edges + finally: + connection.close() diff --git a/lib/sessionkit_inventory/pulse.py b/lib/sessionkit_inventory/pulse.py new file mode 100644 index 0000000..2001b9a --- /dev/null +++ b/lib/sessionkit_inventory/pulse.py @@ -0,0 +1,292 @@ +"""Say when anything the "needs you" column is made of has changed. + +The daemon's event stream carries exactly four things -- a session created, +attached, detached, removed -- and a session that stops working and starts +WAITING FOR A PERSON does none of them. Measured on a live estate: 25 seconds of +`shpool events`, eleven live sessions with agents mid-turn, zero lines. So the +stream cannot be the reason to poll less often, because the one column an +operator actually watches would get slower, not faster. + +This is the missing half. It watches the small set of files the needs-you +answer is derived from, and prints one line when any of them moves. The picker +reads those lines exactly as it reads daemon events: collect now. With both +streams live, every source of an attention change pushes, and the timed poll is +free to widen without the picker ever learning about a waiting session later +than it used to. + +What it watches, and why each one is here: + + * ``<state>/attention/claude/`` -- the Notification hook's records: a Claude + session raising its hand writes here at the moment it happens; + * every Claude session record (``~/.claude/sessions/<pid>.json`` and the same + under each account profile below ``~/.local/share/session-kit/accounts/``, + which is where most sessions on a multi-account estate live) -- + ``statusUpdatedAt`` moves on a status transition, which is what the poll + reads; + * Codex rollouts -- a Codex session waiting for approval shows up as an + appended rollout record and nothing else; there is no hook and no event; + * the fleet's ``stalls.json`` -- the other feed the picker's attention count + reads. + +Everything is bounded, and that now includes every directory this READS, not +only the set it fingerprints: a node cap on the rollout walk, a cap on how many +entries any one directory listing may look at, a cap on how many files are +fingerprinted, and stat-only reads (nothing here opens a file). The listing cap +is the one that was missing -- ``sorted(dir.glob(...))[:cap]`` bounds what is +watched while still reading the whole directory, and a session directory grown +to 60 000 entries cost 0.5-0.8 s of a 1 s loop. One pass is a few +hundred stats, which is why it can afford to run every second while the +expensive thing -- a whole status collection -- runs when there is a reason. + +It also has to END with the picker that started it. The picker's trap stops it +on every ordinary exit, but a SIGKILLed picker runs no trap, and stdout here is +the picker's event file -- a regular file, so there is no reader to go away and +no BrokenPipeError to notice. ``--parent-pid`` is the answer: the launcher +hands over its own pid and this loop stops the moment that process is gone. + +Usage: ``python3 pulse.py [--interval 1.0] [--parent-pid N] [--once]``; one +JSON line per change on stdout, flushed. Kill switch: the picker starts it only +when SESSION_KIT_PICKER_PULSE is not 0/off/no/false, in any capitalisation and +with whitespace around it. +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys +import time +from typing import Iterator, Mapping + + +# A pass must stay cheap enough to run every second on a busy estate, and must +# never wander: a stalled or enormous tree is a reason to stop looking, not to +# hold the loop open. +MAX_NODES = 4000 +MAX_TRACKED = 1500 +MAX_DEPTH = 6 +# The session directories fill with kit markers next to the records (315 +# .nameintent, 196 .colorset, 182 .titleset here today), so a listing must not +# sort the whole directory -- but it MUST look at every entry, because scandir +# order is name-hash order and the records the watcher exists for can land +# anywhere in it. An entries-examined cap here once made the watcher blind to +# every session record past ~4 000 entries while the poll stayed widened on +# its word; looking at everything and keeping only matches costs ~13 ms at +# 60 000 entries against the 1 s loop, and the `keep` bound still caps what +# is fingerprinted. +# Account profiles are directories too, and the same rule applies to them. +MAX_PROFILES = 64 +DEFAULT_INTERVAL_SECONDS = 1.0 + + +def _state_dir(environ: Mapping[str, str], home: Path) -> Path: + configured = environ.get("SESSION_KIT_STATE_DIR") + if configured: + return Path(configured) + state_home = environ.get("XDG_STATE_HOME") + base = Path(state_home) if state_home else home / ".local" / "state" + return base / "session-kit" + + +def _stamp(path: Path) -> str: + try: + info = os.stat(path) + except OSError: + return "" + return f"{int(info.st_mtime_ns)}:{info.st_size}" + + +def _bounded_walk(root: Path, suffix: str, budget: list[int]) -> Iterator[Path]: + """Files under root ending in suffix, with a hard node budget.""" + stack = [(root, 0)] + while stack and budget[0] > 0: + directory, depth = stack.pop() + if depth > MAX_DEPTH: + continue + try: + with os.scandir(directory) as entries: + for entry in entries: + budget[0] -= 1 + if budget[0] <= 0: + return + try: + if entry.is_dir(follow_symlinks=False): + stack.append((Path(entry.path), depth + 1)) + elif entry.name.endswith(suffix): + yield Path(entry.path) + except OSError: + continue + except OSError: + continue + + +def _bounded_listing( + directory: Path, suffix: str, *, keep: int = MAX_TRACKED +) -> list[Path]: + """Files ending in suffix; every entry examined, at most `keep` kept. + + ``sorted(directory.glob(...))[:keep]`` reads and sorts the WHOLE directory + before the slice throws most of it away; scandir with a keep-bound avoids + the sort without ever skipping an entry, so a record can never be missed + because of where its filename hashes in the listing order. + """ + found: list[Path] = [] + try: + with os.scandir(directory) as entries: + for entry in entries: + if len(found) >= keep: + break + if entry.name.endswith(suffix): + found.append(Path(entry.path)) + except OSError: + return sorted(found) + return sorted(found) + + +def watched_paths(environ: Mapping[str, str], home: Path) -> list[Path]: + """Every file whose movement can change what needs a person.""" + state = _state_dir(environ, home) + paths: list[Path] = [] + budget = [MAX_NODES] + + attention = state / "attention" / "claude" + paths.append(attention) + paths.extend(sorted(_bounded_walk(attention, ".json", budget))[:MAX_TRACKED]) + + # Every profile, not just the ambient one: on a multi-account estate nearly + # every live Claude session is registered under an account profile, so + # watching ~/.claude alone would have watched the one session nobody is + # waiting on. + session_roots = [Path(environ.get("CLAUDE_CONFIG_DIR") or home / ".claude")] + override = environ.get("SESSION_KIT_ACCOUNT_ROOT") + if override: + accounts = Path(override) / "claude" + else: + data_home = environ.get("XDG_DATA_HOME") + base = Path(data_home) if data_home else home / ".local" / "share" + accounts = base / "session-kit" / "accounts" / "claude" + profiles: list[Path] = [] + try: + with os.scandir(accounts) as entries: + for entry in entries: + if len(profiles) >= MAX_PROFILES: + break + try: + if entry.is_dir(): + profiles.append(Path(entry.path)) + except OSError: + continue + except OSError: + profiles = [] + session_roots.extend(sorted(profiles)) + for root in session_roots: + sessions = root / "sessions" + paths.append(sessions) + paths.extend(_bounded_listing(sessions, ".json")) + + codex_home = Path(environ.get("CODEX_HOME") or home / ".codex") + paths.extend( + sorted(_bounded_walk(codex_home / "sessions", ".jsonl", budget))[:MAX_TRACKED] + ) + paths.append(codex_home / "session_index.jsonl") + + fleet = Path(environ.get("FLEET_STATE_DIR") or home / ".local" / "state" / "fleet") + paths.append(fleet / "stalls.json") + paths.append(fleet / "inbox.jsonl") + return paths + + +def _parent_is_zombie(pid: int) -> bool: + """A killed parent nobody has waited for yet is already gone. + + Linux only, and unknown counts as alive: on a box without /proc this says + nothing and the ppid check below is what decides. + """ + try: + with open(f"/proc/{pid}/stat", encoding="utf-8", errors="replace") as handle: + fields = handle.read().rsplit(")", 1)[-1].split() + except OSError: + return False + return bool(fields) and fields[0] == "Z" + + +def parent_gone(parent_pid: int | None) -> bool: + """True once the process that started this watcher is no longer there. + + The picker stops this child from its EXIT trap, and a SIGKILLed picker + runs no trap -- so without this check one killed picker leaves a process + stat-walking the estate once a second forever, appending to a temp file + nothing reaps. A CHANGED ppid is the proof, not ``getppid() == 1``: the + orphan is adopted by whatever subreaper is closest, which on a systemd + user session is not init. + """ + if parent_pid is None or parent_pid <= 0: + return os.getppid() == 1 + if os.getppid() != parent_pid: + return True + try: + os.kill(parent_pid, 0) + except ProcessLookupError: + return True + except OSError: + # Still there, and not ours to signal. Alive is the safe reading. + return False + return _parent_is_zombie(parent_pid) + + +def fingerprint(environ: Mapping[str, str], home: Path) -> str: + parts = [f"{path}={_stamp(path)}" for path in watched_paths(environ, home)] + return "\n".join(parts) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="pulse", description="Print a line when the attention inputs move." + ) + parser.add_argument( + "--interval", type=float, default=DEFAULT_INTERVAL_SECONDS + ) + parser.add_argument( + "--parent-pid", + type=int, + default=None, + help="stop as soon as this process is gone (the picker that started us)", + ) + parser.add_argument( + "--once", + action="store_true", + help="print the current fingerprint and exit (for tests and doctor)", + ) + arguments = parser.parse_args(argv) + interval = arguments.interval + if not 0.05 <= interval <= 60: + interval = DEFAULT_INTERVAL_SECONDS + home = Path(os.environ.get("HOME") or Path.home()) + if arguments.once: + print(fingerprint(os.environ, home)) + return 0 + parent_pid = arguments.parent_pid + previous = fingerprint(os.environ, home) + while True: + time.sleep(interval) + # Before the work, not after it: a watcher whose picker is gone has + # nobody to tell, and one wasted pass is one pass too many. + if parent_gone(parent_pid): + return 0 + try: + current = fingerprint(os.environ, home) + except Exception: # pragma: no cover - a watcher may never take the box down + continue + if current == previous: + continue + previous = current + print(json.dumps({"type": "attention.changed"}), flush=True) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (BrokenPipeError, KeyboardInterrupt): + sys.exit(0) diff --git a/lib/sessionkit_inventory/reaper.py b/lib/sessionkit_inventory/reaper.py index a140014..25a83ed 100644 --- a/lib/sessionkit_inventory/reaper.py +++ b/lib/sessionkit_inventory/reaper.py @@ -7,9 +7,10 @@ import os from pathlib import Path import re +import stat import sys import time -from typing import Any, Mapping +from typing import Any, Callable, Iterable, Mapping from .common import CollectionError, PROVIDERS, valid_uuid from .state_io import atomic_write_private_json, read_private_json @@ -18,9 +19,10 @@ AUTO_CLOSE_SCHEMA_VERSION = 1 DEFAULT_AUTO_CLOSE_HOURS = 72 MAX_AUTO_CLOSE_HOURS = 24 * 365 -GENERATED_SESSION_RE = re.compile( - r"s[0-9]{8}-[0-9]{6}-[1-9][0-9]*(?:-[1-9][0-9]*)?" -) +GENERATED_SESSION_RE = re.compile(r"s[0-9]{8}-[0-9]{6}-[1-9][0-9]*(?:-[1-9][0-9]*)?") +APP_SERVER_STALE_SECONDS = 3600 +MAX_APP_SERVER_REMOVALS = 64 +APP_SERVER_DIR_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}") def _positive_int(value: Any) -> bool: @@ -53,9 +55,15 @@ def scan_shell_facts( proc_root: Path, daemon_pid: int, *, + daemon_start_ticks: int | None = None, max_nodes: int = 16384, ) -> dict[str, dict[str, Any]]: """Return exact daemon-child shell generations and empty-tree proof.""" + daemon_before = _proc_stat(proc_root / str(daemon_pid) / "stat") + if daemon_start_ticks is not None and ( + daemon_before is None or daemon_before[1] != daemon_start_ticks + ): + raise CollectionError("bound shpool daemon generation changed") try: entries = [entry for entry in proc_root.iterdir() if entry.name.isdigit()] except OSError as exc: @@ -69,14 +77,42 @@ def scan_shell_facts( before = _proc_stat(entry / "stat") if before is None: continue + environ: list[bytes] = [] try: comm = (entry / "comm").read_text(encoding="utf-8").strip() - cmdline = (entry / "cmdline").read_bytes().replace(b"\0", b" ").decode( - "utf-8", "replace" + cmdline = ( + (entry / "cmdline") + .read_bytes() + .replace(b"\0", b" ") + .decode("utf-8", "replace") ) - environ = (entry / "environ").read_bytes().split(b"\0") except OSError: continue + # A shell-less manager record can still have a provider that escaped + # reaping and was adopted elsewhere. Every process started inside a + # managed shell inherits SHPOOL_SESSION_NAME, so read the environment + # of every process owned by this account, not only daemon children. + # Foreign processes cannot belong to this user's shpool and are never + # opened. An unreadable own-uid environment makes absence unprovable. + try: + owned_by_us = entry.stat(follow_symlinks=False).st_uid == os.geteuid() + except OSError: + owned_by_us = False + if before[0] == daemon_pid or owned_by_us: + try: + environ = (entry / "environ").read_bytes().split(b"\0") + except OSError as exc: + # A child that exited mid-scan is an ordinary race: it is gone + # from the table below too. A child that is still there and + # will not say its session name is not, it leaves a listed + # session unaccounted for, and an unaccounted session is + # exactly what the phantom rule closes. Refuse the whole run, + # as the Darwin adapter already does on the same ambiguity. + if _proc_stat(entry / "stat") is None: + continue + raise CollectionError( + "an own-uid process has an unreadable environment; cleanup refused" + ) from exc after = _proc_stat(entry / "stat") if before != after: continue @@ -109,7 +145,7 @@ def empty_shell(shell_pid: int) -> bool: return not children.get(inner[0], []) facts: dict[str, dict[str, Any]] = {} - duplicates: set[str] = set() + direct_by_name: dict[str, list[int]] = {} for pid in children.get(daemon_pid, []): process = processes.get(pid) if process is None: @@ -121,19 +157,72 @@ def empty_shell(shell_pid: int) -> bool: break if not session_id: continue - if session_id in facts: - duplicates.add(session_id) + direct_by_name.setdefault(session_id, []).append(pid) + for session_id, pids in direct_by_name.items(): + if len(pids) != 1: + facts[session_id] = {"ambiguous_shell_generations": len(pids)} continue + pid = pids[0] facts[session_id] = { "shell_pid": pid, - "shell_start_ticks": process["start_ticks"], + "shell_start_ticks": processes[pid]["start_ticks"], "empty": empty_shell(pid), } - for session_id in duplicates: - facts.pop(session_id, None) + + named_processes: dict[str, list[int]] = {} + for pid, process in processes.items(): + for item in process.get("environ", ()): + if item.startswith(b"SHPOOL_SESSION_NAME="): + session_id = item.split(b"=", 1)[1].decode("utf-8", "replace") + if session_id: + named_processes.setdefault(session_id, []).append(pid) + break + + def subtree(root: int) -> set[int]: + found, pending = {root}, [root] + while pending: + for child in children.get(pending.pop(), ()): + if child not in found: + found.add(child) + pending.append(child) + return found + + for session_id, named_pids in named_processes.items(): + fact = facts.get(session_id) + shell_pid = fact.get("shell_pid") if fact is not None else None + if fact is not None and isinstance(shell_pid, int): + outside = sorted(set(named_pids) - subtree(shell_pid)) + if outside: + fact["empty"] = False + fact["unbound_session_processes"] = outside + elif fact is None: + # Positive evidence of a surviving process without a daemon child. + # Neither candidate predicate accepts this non-shell fact. + facts[session_id] = {"unbound_session_processes": sorted(set(named_pids))} + if ( + daemon_start_ticks is not None + and _proc_stat(proc_root / str(daemon_pid) / "stat") != daemon_before + ): + raise CollectionError("bound shpool daemon generation changed") return facts +def _bound_daemon_generation(inventory: Mapping[str, Any]) -> tuple[int, int]: + """Return the exact socket-bound generation carried by a guard document.""" + daemon = inventory.get("daemon_generation") + pid = daemon.get("pid") if isinstance(daemon, Mapping) else None + start = daemon.get("process_start_ticks") if isinstance(daemon, Mapping) else None + if ( + not _positive_int(pid) + or not isinstance(start, int) + or isinstance(start, bool) + or start < 0 + ): + raise CollectionError("guard-live daemon identity is unavailable") + assert isinstance(pid, int) and isinstance(start, int) + return pid, start + + def _inventory_by_id(inventory: Mapping[str, Any]) -> dict[str, Mapping[str, Any]]: sessions = inventory.get("sessions") if ( @@ -161,23 +250,33 @@ def _safe_candidate( *, now_monotonic_ns: int, ) -> tuple[Any, ...] | None: + """A provider-exited terminal this pass may close, or None. + + A BACKSTOP since 2026-08-15: the managed shell closes itself the moment + its provider is gone for good, so a row that reaches the full window + means the shell could not, an older release still running, or a close + that failed. bin/shpool_reaper's header lists the cases. + + Two husk classes sit deliberately outside this predicate. Without + `agent_status == "provider exited"` there is no proof a provider ever + exited; without a uuid there is no conversation to promise back. Both + stay visible until a person closes them from the picker, because a row + whose state cannot be proven is never swept up quietly. + """ session_id = row.get("name") shell = item.get("shpool_shell") if isinstance(item, Mapping) else None recovery = item.get("recovery") if isinstance(item, Mapping) else None - exited_identity = ( - item.get("exited_identity") if isinstance(item, Mapping) else None - ) + exited_identity = item.get("exited_identity") if isinstance(item, Mapping) else None provider = item.get("exited_provider") if isinstance(item, Mapping) else None exit_ns = ( item.get("provider_exited_at_monotonic_ns") if isinstance(item, Mapping) else None ) - uuid = ( - valid_uuid(recovery.get("uuid")) - if isinstance(recovery, Mapping) - else None - ) + if not _positive_int(exit_ns): + return None + assert isinstance(exit_ns, int) + uuid = valid_uuid(recovery.get("uuid")) if isinstance(recovery, Mapping) else None if ( not isinstance(session_id, str) or not GENERATED_SESSION_RE.fullmatch(session_id) @@ -196,7 +295,6 @@ def _safe_candidate( or item.get("provider_exit_input_tracking") is not True or item.get("user_input_after_provider_exit") is not False or item.get("provider_exit_keep") is not False - or not _positive_int(exit_ns) or exit_ns > now_monotonic_ns or not isinstance(shell, Mapping) or not isinstance(fact, Mapping) @@ -225,6 +323,57 @@ def _safe_candidate( ) +def _phantom_candidate( + row: Mapping[str, Any], + item: Mapping[str, Any] | None, + fact: Mapping[str, Any] | None, +) -> tuple[Any, ...] | None: + """A daemon-listed session whose shell process no longer exists at all. + + The daemon keeps advertising it (an ESRCH during a kill aborts removal on + stock shpool), the kit quarantines it, and no picker action can touch it, + it lies in the list until a daemon restart. There is nothing to preserve: + no shell, no provider, no identity. Nominated on the first complete pass: + exact absence is already the proof, and waiting cannot make it safer. + """ + session_id = row.get("name") + if ( + not isinstance(session_id, str) + or not GENERATED_SESSION_RE.fullmatch(session_id) + or str(row.get("status", "")).casefold() != "disconnected" + or not _positive_int(row.get("started_at_unix_ms")) + or not isinstance(item, Mapping) + or item.get("shpool_id_raw") != session_id + or item.get("provider") != "unknown" + or item.get("mutation_allowed") is not False + or item.get("mutation_rejection_reason") != "missing-shell-generation" + or item.get("shpool_shell") is not None + or item.get("subagents") + or fact is not None + ): + return None + identity = item.get("identity") + if isinstance(identity, Mapping) and identity.get("pid") is not None: + return None + diagnostics = item.get("diagnostics") + if not isinstance(diagnostics, list) or not any( + isinstance(entry, str) and "found 0" in entry for entry in diagnostics + ): + return None + if any( + isinstance(entry, str) and "unreadable environment" in entry + for entry in diagnostics + ): + # The shell may be alive and merely unable to name itself. Nothing to + # preserve is a proof, not a default. + return None + return ( + "phantom", + session_id, + row["started_at_unix_ms"], + ) + + def plan_auto_close( shpool_payload: Any, inventory: Mapping[str, Any], @@ -250,6 +399,9 @@ def plan_auto_close( candidates: list[dict[str, Any]] = [] for row in _rows(shpool_payload): session_id = row.get("name") + if not isinstance(session_id, str): + continue + phantom = None proof = _safe_candidate( row, inventory_rows.get(session_id), @@ -257,7 +409,14 @@ def plan_auto_close( now_monotonic_ns=now_monotonic_ns, ) if proof is None: - continue + phantom = _phantom_candidate( + row, + inventory_rows.get(session_id), + shell_facts.get(session_id), + ) + if phantom is None: + continue + proof = phantom proof_hash = hashlib.sha256( json.dumps(proof, separators=(",", ":")).encode("utf-8") ).hexdigest() @@ -269,10 +428,23 @@ def plan_auto_close( and _positive_int(prior.get("first_verified_monotonic_ns")) else now_monotonic_ns ) + if not isinstance(first, int): + raise CollectionError("auto-close observation clock is invalid") observations[session_id] = { "proof_hash": proof_hash, "first_verified_monotonic_ns": first, } + if phantom is not None: + candidates.append( + { + "class": "shell-less-phantom", + "shpool_id": proof[1], + "started_at_unix_ms": proof[2], + "proof_hash": proof_hash, + "first_verified_monotonic_ns": first, + } + ) + continue if now_monotonic_ns - max(first, proof[-1]) < minimum_age_ns: continue candidates.append( @@ -297,12 +469,138 @@ def plan_auto_close( candidate_doc = { "schema_version": AUTO_CLOSE_SCHEMA_VERSION, "generated_at_monotonic_ns": now_monotonic_ns, - "policy": "provider-exited-exact-72h", + "policy": "provider-exited-exact-window+shell-less-immediate", "candidates": candidates, } return observation_doc, candidate_doc +def app_server_socket_answers(socket_path: Path) -> bool: + """Whether an App Server socket still has a listener behind it. + + Only an outright refusal proves nobody is there. An unreadable path, an + unexpected file type, a timeout, or any other error answers "yes" so the + caller leaves the directory alone. + """ + import socket as socketmod + + try: + metadata = os.lstat(socket_path) + except FileNotFoundError: + return False + except OSError: + return True + if not stat.S_ISSOCK(metadata.st_mode): + return True + connection = socketmod.socket(socketmod.AF_UNIX, socketmod.SOCK_STREAM) + try: + connection.settimeout(1.0) + connection.connect(os.fspath(socket_path)) + except (ConnectionRefusedError, FileNotFoundError): + return False + except OSError: + return True + finally: + connection.close() + return True + + +def sweep_stale_app_server_dirs( + state_dir: Path, + live_session_ids: Iterable[str], + *, + now_unix: float, + stale_after_seconds: int = APP_SERVER_STALE_SECONDS, + max_removals: int = MAX_APP_SERVER_REMOVALS, + socket_answers: Callable[[Path], bool] = app_server_socket_answers, +) -> list[str]: + """Remove App Server directories no session can still be using. + + A `codex app-server` socket dies with its process, but the directory + holding it survives every reboot, so the tree grows one entry per Codex + window forever. A directory is removed only when all three proofs hold: + the daemon no longer lists its session, its socket refuses a connection, + and nothing has touched the directory or its files for the stale window. + Anything unexpected, a foreign owner, a symlink, an unreadable entry, a + socket that answers, leaves the directory in place. Returns the removed + session IDs. + """ + live = set(live_session_ids) + app_root = state_dir / "app-server" + try: + names = sorted(os.listdir(app_root)) + except OSError: + return [] + cutoff = now_unix - stale_after_seconds + owner = os.geteuid() + removed: list[str] = [] + for name in names: + if len(removed) >= max_removals: + break + if name in live or not APP_SERVER_DIR_RE.fullmatch(name): + continue + directory = app_root / name + try: + metadata = os.lstat(directory) + except OSError: + continue + if ( + not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != owner + or metadata.st_mtime > cutoff + ): + continue + children: list[str] = [] + newest = metadata.st_mtime + try: + for child in os.listdir(directory): + child_metadata = os.lstat(directory / child) + if ( + not ( + stat.S_ISREG(child_metadata.st_mode) + or stat.S_ISSOCK(child_metadata.st_mode) + ) + or child_metadata.st_uid != owner + ): + raise OSError("unexpected App Server directory entry") + newest = max(newest, child_metadata.st_mtime) + children.append(child) + except OSError: + continue + if newest > cutoff or socket_answers(directory / "app.sock"): + continue + try: + for child in children: + os.unlink(directory / child) + os.rmdir(directory) + except OSError: + continue + removed.append(name) + return removed + + +def sweep_app_servers_from_environment() -> list[str]: + """Sweep using the reaper's exact daemon list; any doubt removes nothing.""" + try: + payload = json.loads(os.environ["SK_REAPER_SHPOOL_JSON"]) + state_dir = Path(os.environ["SK_REAPER_STATE_DIR"]) + except (KeyError, ValueError, json.JSONDecodeError): + return [] + try: + rows = _rows(payload) + except CollectionError: + return [] + live: list[str] = [] + for row in rows: + name = row.get("name") + if not isinstance(name, str) or not name: + # An unreadable row means the live set is unknown, and an unknown + # live set may never authorize a removal. + return [] + live.append(name) + return sweep_stale_app_server_dirs(state_dir, live, now_unix=time.time()) + + def plan_from_environment( *, write_state: bool, @@ -312,7 +610,6 @@ def plan_from_environment( inventory = json.loads(os.environ["SK_REAPER_INVENTORY_JSON"]) state_dir = Path(os.environ["SK_REAPER_STATE_DIR"]) proc_root = Path(os.environ["SK_REAPER_PROC_ROOT"]) - daemon_pid = int(os.environ["SK_REAPER_DAEMON_PID"]) hours = int( os.environ.get( "SK_REAPER_AUTO_CLOSE_HOURS", @@ -323,6 +620,7 @@ def plan_from_environment( raise CollectionError("auto-close environment is invalid") from exc if not 1 <= hours <= MAX_AUTO_CLOSE_HOURS: raise CollectionError("auto-close age is invalid") + daemon_pid, daemon_start = _bound_daemon_generation(inventory) now_monotonic_ns = time.monotonic_ns() if os.environ.get("SESSION_KIT_TESTING") == "1": raw_test_clock = os.environ.get("SESSION_KIT_TEST_MONOTONIC_NS") @@ -330,9 +628,7 @@ def plan_from_environment( try: now_monotonic_ns = int(raw_test_clock) except ValueError as exc: - raise CollectionError( - "auto-close test clock is invalid" - ) from exc + raise CollectionError("auto-close test clock is invalid") from exc if not _positive_int(now_monotonic_ns): raise CollectionError("auto-close test clock is invalid") observation_path = state_dir / "auto-close-observations.json" @@ -344,7 +640,11 @@ def plan_from_environment( observation, candidates = plan_auto_close( shpool_payload, inventory, - scan_shell_facts(proc_root, daemon_pid), + scan_shell_facts( + proc_root, + daemon_pid, + daemon_start_ticks=daemon_start, + ), previous, now_monotonic_ns=now_monotonic_ns, minimum_age_ns=hours * 3600 * 1_000_000_000, @@ -367,15 +667,21 @@ def main(argv: list[str] | None = None) -> int: plan_parser.add_argument("--no-write", action="store_true") verify = subparsers.add_parser("verify") verify.add_argument("candidate") + subparsers.add_parser("sweep-app-servers") args = parser.parse_args(argv) try: + if args.command == "sweep-app-servers": + swept = sweep_app_servers_from_environment() + for session_id in swept: + print( + f"App Server GC removed {session_id}", + file=sys.stderr, + ) + print(len(swept)) + return 0 if args.command == "plan": - _, candidates = plan_from_environment( - write_state=not args.no_write - ) - print( - f"auto_candidates={len(candidates['candidates'])} actions=0" - ) + _, candidates = plan_from_environment(write_state=not args.no_write) + print(f"auto_candidates={len(candidates['candidates'])} actions=0") return 0 candidate = read_private_json( Path(args.candidate), @@ -384,15 +690,9 @@ def main(argv: list[str] | None = None) -> int: if not isinstance(candidate, Mapping): raise CollectionError("auto-close candidate is malformed") _, current = plan_from_environment(write_state=False) - matches = [ - item - for item in current["candidates"] - if item == candidate - ] + matches = [item for item in current["candidates"] if item == candidate] if len(matches) != 1: - raise CollectionError( - "auto-close candidate changed or is no longer safe" - ) + raise CollectionError("auto-close candidate changed or is no longer safe") print("auto-close candidate verified") return 0 except (CollectionError, OSError) as exc: diff --git a/lib/sessionkit_inventory/recovery.py b/lib/sessionkit_inventory/recovery.py new file mode 100644 index 0000000..6eec050 --- /dev/null +++ b/lib/sessionkit_inventory/recovery.py @@ -0,0 +1,850 @@ +"""Exact recovery transactions: the manifest, the pending queue, and the ack. + +Recovery state is the only private state that has to outlive the event it +describes. ``recovery-manifest.json`` holds the latest exact generation; +``recovery-pending.json`` holds the generations that vanished before anyone +claimed them. Nothing here decides when to run (the snapshot does that), and +nothing here reads a clock, a process table, or a configuration file. + +Every collaborator arrives as a call-time argument instead of an import. That +is deliberate rather than uniform style: recovery rewrites the state a person +depends on immediately after losing a session, so a facade patch aimed at the +state reader, the publisher, the live collector, or the strict live guard has to +reach the transaction it was aimed at. A patch that silently applies against +nothing is worse in this module than anywhere else in the tree. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +from .common import ( + PROVIDERS, + CollectionError, + _utc_now, + clean_text, + display_shpool_id, + natural_name_key, + valid_uuid, +) + + +def recovery_manifest( + inventory: Mapping[str, Any], + *, + schema_version: int, + boot_id: Callable[[], str | None], +) -> dict[str, Any]: + sessions: dict[str, dict[str, Any]] = {} + for item in inventory.get("sessions", ()): + recovery = item.get("recovery", {}) + shpool_id = item.get("shpool_id_raw") or item.get("shpool_id") + if not shpool_id or not recovery.get("available"): + continue + sessions[shpool_id] = { + "scope": "shpool", + "provider": recovery["provider"], + "uuid": recovery["uuid"], + "title": item.get("title", ""), + "started_at_unix_ms": item.get("started_at_unix_ms"), + "cwd": recovery.get("cwd"), + "argv": recovery.get("argv", []), + "command": recovery.get("command"), + } + outside_agents: dict[str, dict[str, Any]] = {} + for item in inventory.get("outside_agents", ()): + recovery = item.get("recovery", {}) + provider = recovery.get("provider") + uuid = valid_uuid(recovery.get("uuid")) + if not recovery.get("available") or provider not in PROVIDERS or not uuid: + continue + key = f"outside:{provider}:{uuid}" + outside_agents[key] = { + "scope": "outside", + "provider": provider, + "uuid": uuid, + "title": item.get("title", ""), + "started_at_unix_ms": item.get("started_at_unix_ms"), + "cwd": recovery.get("cwd"), + "argv": recovery.get("argv", []), + "command": recovery.get("command"), + } + return { + "schema_version": schema_version, + "generated_at": inventory.get("generated_at"), + "collection_start": inventory.get("collection_start"), + "boot_id": boot_id(), + "daemon_generation": inventory.get("daemon_generation"), + "sessions": sessions, + "outside_agents": outside_agents, + } + + +def _generation_key(value: Mapping[str, Any]) -> tuple[str, int, int] | None: + generation = value.get("daemon_generation") + if not isinstance(generation, Mapping): + return None + boot_id = value.get("boot_id") + pid = generation.get("pid") + start_ticks = generation.get("process_start_ticks") + if ( + not isinstance(boot_id, str) + or not boot_id + or not isinstance(pid, int) + or pid <= 0 + or not isinstance(start_ticks, int) + or start_ticks <= 0 + ): + return None + return boot_id, pid, start_ticks + + +def _valid_recovery_state(value: Any, *, schema_version: int) -> bool: + return ( + isinstance(value, Mapping) + and value.get("schema_version") == schema_version + and isinstance(value.get("sessions"), Mapping) + and ( + "outside_agents" not in value + or isinstance(value.get("outside_agents"), Mapping) + ) + ) + + +def _has_recovery_entries( + value: Any, + *, + valid_recovery_state: Callable[[Any], bool], +) -> bool: + return bool( + valid_recovery_state(value) + and (value.get("sessions") or value.get("outside_agents")) + ) + + +def _pending_conversation_keys(value: Any, *, schema_version: int) -> set[str]: + found: set[str] = set() + if not _valid_recovery_state(value, schema_version=schema_version): + return found + generations: list[Mapping[str, Any]] = [value] + generations.extend( + item + for item in value.get("queued_generations", ()) + if isinstance(item, Mapping) + ) + for generation in generations: + if ( + _generation_key( + { + "boot_id": generation.get("source_boot_id"), + "daemon_generation": generation.get("source_daemon_generation"), + } + ) + is None + ): + continue + for group in ("sessions", "outside_agents"): + entries = generation.get(group, {}) + if not isinstance(entries, Mapping): + continue + for entry in entries.values(): + if not isinstance(entry, Mapping): + continue + provider = entry.get("provider") + uuid = valid_uuid(entry.get("uuid")) + if provider in PROVIDERS and uuid: + found.add(f"{provider}:{uuid}") + return found + + +def update_recovery_state( + paths: Mapping[str, Path], + inventory: Mapping[str, Any], + *, + schema_version: int, + recovery_manifest: Callable[[Mapping[str, Any]], dict[str, Any]], + read_state_json: Callable[[Path], Any], + has_recovery_entries: Callable[[Any], bool], + generation_key: Callable[[Mapping[str, Any]], tuple[str, int, int] | None], + atomic_write_json: Callable[[Path, Any], None], + collection_start: int | None = None, + write_collection_json: Callable[..., None] | None = None, +) -> None: + """Preserve exact pre-generation recovery data until explicitly resolved. + + ``recovery-manifest.json`` is the latest nonempty generation. When a new + boot or daemon generation appears, the prior manifest is copied to + ``recovery-pending.json`` before the current manifest can advance. Empty + and partial post-reboot inventories therefore cannot erase the queue. + """ + detected = recovery_manifest(inventory) + publish = ( + ( + lambda path, payload: write_collection_json( + paths, path, payload, collection_start=collection_start + ) + ) + if write_collection_json is not None + else atomic_write_json + ) + existing = read_state_json(paths["manifest"]) + pending = read_state_json(paths["pending"]) + existing_valid = has_recovery_entries(existing) + pending_valid = has_recovery_entries(pending) + detected_key = generation_key(detected) + existing_key = generation_key(existing) if existing_valid else None + + # An unavailable/ambiguous daemon identity is not evidence of a restart. + # Do not advance or queue anything until both sides have exact generation + # keys; a later complete refresh can make the transition safely. + if detected_key is None or (existing_valid and existing_key is None): + return + + if existing_valid and existing_key == detected_key: + existing_semantic = dict(existing) + detected_semantic = dict(detected) + existing_semantic.pop("generated_at", None) + detected_semantic.pop("generated_at", None) + if existing_semantic == detected_semantic: + # Timestamp-only refreshes must not invalidate an exact migration + # postimage or create needless state churn. + return + + if existing_valid and existing_key != detected_key: + assert existing_key is not None + boot_changed = existing_key[0] != detected_key[0] + new_pending = { + "schema_version": schema_version, + "generated_at": inventory.get("generated_at"), + "source_boot_id": existing.get("boot_id"), + "source_daemon_generation": existing.get("daemon_generation"), + "detected_boot_id": detected.get("boot_id"), + "detected_daemon_generation": detected.get("daemon_generation"), + "sessions": dict(existing["sessions"]), + # Outside-provider roots survive a daemon-only restart; they are + # recovery work only when the host boot itself changed. + "outside_agents": ( + dict(existing.get("outside_agents", {})) if boot_changed else {} + ), + } + if pending_valid: + pending_source = ( + pending.get("source_boot_id"), + json.dumps(pending.get("source_daemon_generation"), sort_keys=True), + ) + new_source = ( + new_pending.get("source_boot_id"), + json.dumps(new_pending.get("source_daemon_generation"), sort_keys=True), + ) + if pending_source == new_source: + # Never shrink an unresolved queue during a partial restore. + new_pending["sessions"] = { + **new_pending["sessions"], + **dict(pending["sessions"]), + } + new_pending["outside_agents"] = { + **new_pending.get("outside_agents", {}), + **dict(pending.get("outside_agents", {})), + } + else: + # A second generation arrived before the first was reviewed. + # Keep the oldest queue primary and retain the later one + # explicitly instead of silently overwriting either. + new_pending = dict(pending) + queued = list(new_pending.get("queued_generations", ())) + candidate = { + "source_boot_id": existing.get("boot_id"), + "source_daemon_generation": existing.get("daemon_generation"), + "sessions": dict(existing["sessions"]), + "outside_agents": ( + dict(existing.get("outside_agents", {})) if boot_changed else {} + ), + } + candidate_key = ( + candidate["source_boot_id"], + json.dumps(candidate["source_daemon_generation"], sort_keys=True), + ) + existing_keys = { + ( + item.get("source_boot_id"), + json.dumps( + item.get("source_daemon_generation"), sort_keys=True + ), + ) + for item in queued + if isinstance(item, Mapping) + } + if candidate_key not in existing_keys: + queued.append(candidate) + new_pending["queued_generations"] = queued + new_pending["detected_boot_id"] = detected.get("boot_id") + new_pending["detected_daemon_generation"] = detected.get( + "daemon_generation" + ) + if has_recovery_entries(new_pending): + publish(paths["pending"], new_pending) + expected = _pending_conversation_keys( + new_pending, schema_version=schema_version + ) + try: + published_pending = read_state_json(paths["pending"]) + except (OSError, ValueError) as exc: + expected_text = ", ".join(sorted(expected)) + raise CollectionError( + "pending recovery evidence changed before inventory/manifest " + f"advance; expected {expected_text}; found unreadable " + f"{paths['pending'].name}: {exc}" + ) from exc + prove_pending_losses( + published_pending, + tuple(expected), + schema_version=schema_version, + ) + + # Never replace the latest nonempty exact manifest with an empty snapshot. + if detected["sessions"] or detected["outside_agents"]: + publish(paths["manifest"], detected) + + +def _lost_entry( + item: Mapping[str, Any], + *, + now_unix_ms: int, +) -> tuple[str, str, dict[str, Any]] | None: + """One queue entry for a conversation whose session went away. + + Two shapes qualify, and both mean "nobody said to end this": + + * a row still carrying a LIVE provider with exact identity -- a window + killed mid-conversation, a daemon that took its session down, a machine + that went away; + * an idle shell whose provider had already exited, still carrying the + exact conversation it ran. That is what a crash left behind, and when + the reaper's proven-safe auto-close eventually takes the terminal, the + conversation inside it is lost rather than finished. An automatic reap + is not intent (operator ruling, 2026-08-11): only a person's explicit + verb is, and a person's verb leaves a tombstone. + + The net invariant: a conversation leaves the visible world either because + somebody said so, or by entering this queue. Never silently. + """ + session_id = item.get("shpool_id_raw") or item.get("shpool_id") + provider = item.get("provider") + identity = item.get("identity") + recovery = item.get("recovery") + if not isinstance(session_id, str) or not isinstance(recovery, Mapping): + return None + if provider in PROVIDERS: + exact = isinstance(identity, Mapping) and identity.get("confidence") == "exact" + else: + # The idle shell an exited provider leaves behind. Its exact identity + # is the one the overlay put back on the row, from the committed + # conversation or the retained exact record -- never a guess. + provider = item.get("exited_provider") + exited_identity = item.get("exited_identity") + exact = ( + provider in PROVIDERS + and isinstance(exited_identity, Mapping) + and exited_identity.get("confidence") == "historical-exact" + ) + identity = exited_identity + if ( + not exact + or provider not in PROVIDERS + or recovery.get("available") is not True + or recovery.get("provider") != provider + ): + return None + uuid = valid_uuid(recovery.get("uuid")) + if not uuid or ( + isinstance(identity, Mapping) + and valid_uuid(identity.get("uuid")) + and valid_uuid(identity.get("uuid")) != uuid + ): + return None + number = item.get("terminal_number") + return ( + session_id, + uuid, + { + "provider": provider, + "uuid": uuid, + "title": item.get("title", ""), + "started_at_unix_ms": item.get("started_at_unix_ms"), + "cwd": recovery.get("cwd"), + "argv": list(recovery.get("argv", ())), + "command": recovery.get("command"), + "account_alias": item.get("account_alias"), + "terminal_number": ( + number + if isinstance(number, int) and not isinstance(number, bool) + else None + ), + "crashed_at_unix_ms": now_unix_ms, + }, + ) + + +def _live_conversations(inventory: Mapping[str, Any]) -> set[str]: + live: set[str] = set() + for group in ("sessions", "outside_agents"): + for item in inventory.get(group, ()): + if not isinstance(item, Mapping): + continue + for source in (item.get("identity"), item.get("recovery")): + if isinstance(source, Mapping): + uuid = valid_uuid(source.get("uuid")) + provider = source.get("provider") or item.get("provider") + if uuid and provider in PROVIDERS: + live.add(f"{provider}:{uuid}") + return live + + +def enqueue_lost_conversations( + inventory: Mapping[str, Any], + previous_inventory: Mapping[str, Any] | None, + *, + paths: Mapping[str, Path], + schema_version: int, + boot_id: str, + read_state_json: Callable[[Path], Any], + atomic_write_json: Callable[[Path, Any], None], + valid_recovery_state: Callable[[Any], bool], + closed_on_purpose: Callable[[Any, Any], bool], + now_unix_ms: int, + collection_start: int | None = None, + write_collection_json: Callable[..., None] | None = None, +) -> list[str]: + """Queue conversations whose session vanished with the provider still in it. + + The queue could only ever learn about a whole generation at once: when the + daemon restarted, everything the old manifest held became pending. One + session dying on its own -- the ordinary way a conversation is lost -- was + invisible to it until the next daemon restart, and by then the row it came + from had aged out of every screen. + + Exact loss evidence is always retained. A tombstone may suppress its + public offer while the matching closed-sessions row remains findable, but + neither that row nor a newly restored live session is permanent. Keeping + the raw candidate is what lets the projection offer it again if the record + that justified suppression later vanishes. + + Returns the conversation keys persisted, for the caller to report. + """ + if not isinstance(previous_inventory, Mapping): + return [] + generation = inventory.get("daemon_generation") + if not isinstance(generation, Mapping) or not boot_id: + # Without an exact generation the entry could not be acknowledged + # later, and an entry nobody can resolve is worse than no entry. + return [] + current_ids = { + item.get("shpool_id_raw") + for item in inventory.get("sessions", ()) + if isinstance(item, Mapping) + } + live = _live_conversations(inventory) + found: dict[str, dict[str, Any]] = {} + queued: list[str] = [] + for item in previous_inventory.get("sessions", ()): + if not isinstance(item, Mapping): + continue + parsed = _lost_entry(item, now_unix_ms=now_unix_ms) + if parsed is None: + continue + session_id, uuid, entry = parsed + key = f"{entry['provider']}:{uuid}" + if session_id in current_ids or key in live: + continue + # Evaluate the agreement here as well as in the public projection so + # a missing-row inconsistency is diagnosed at the moment the loss is + # observed. Never discard the candidate: a later ledger replacement + # must be able to make the recovery offer heal itself. + closed_on_purpose(entry["provider"], uuid) + found[session_id] = entry + queued.append(key) + if not found: + return [] + pending = read_state_json(paths["pending"]) + if valid_recovery_state(pending): + document = json.loads(json.dumps(pending)) + same_source = ( + document.get("source_boot_id") == boot_id + and document.get("source_daemon_generation") == generation + ) + if same_source: + # Never overwrite an entry already queued for this generation: the + # first record of a loss is the one that names when it happened. + document["sessions"] = {**found, **dict(document.get("sessions", {}))} + else: + bucket: dict[str, Any] | None = None + for candidate in document.get("queued_generations", ()): + if ( + isinstance(candidate, dict) + and candidate.get("source_boot_id") == boot_id + and candidate.get("source_daemon_generation") == generation + and isinstance(candidate.get("sessions"), Mapping) + ): + bucket = candidate + break + if bucket is None: + document.setdefault("queued_generations", []).append( + { + "source_boot_id": boot_id, + "source_daemon_generation": generation, + "sessions": found, + } + ) + else: + bucket["sessions"] = {**found, **dict(bucket["sessions"])} + else: + document = { + "schema_version": schema_version, + "generated_at": inventory.get("generated_at"), + "source_boot_id": boot_id, + "source_daemon_generation": generation, + "detected_boot_id": boot_id, + "detected_daemon_generation": generation, + "sessions": found, + } + document["updated_at"] = _utc_now() + if write_collection_json is not None: + write_collection_json( + paths, + paths["pending"], + document, + collection_start=collection_start, + ) + else: + atomic_write_json(paths["pending"], document) + return queued + + +def prove_pending_losses( + value: Any, + expected_conversation_keys: Sequence[str], + *, + schema_version: int, +) -> None: + """Prove the pending pathname still contains this snapshot's exact losses.""" + expected = set(expected_conversation_keys) + found = _pending_conversation_keys(value, schema_version=schema_version) + missing = expected - found + if missing: + expected_text = ", ".join(sorted(expected)) + found_text = ", ".join(sorted(found)) if found else "none" + raise CollectionError( + "pending recovery evidence changed before inventory/manifest advance; " + f"expected {expected_text}; found {found_text}" + ) + + +def source_generation_key( + boot_id: Any, + generation: Any, + *, + generation_key: Callable[[Mapping[str, Any]], tuple[str, int, int] | None], +) -> str | None: + if not isinstance(generation, Mapping): + return None + candidate = { + "boot_id": boot_id, + "daemon_generation": generation, + } + exact = generation_key(candidate) + if exact is None: + return None + return f"{exact[0]}:{exact[1]}:{exact[2]}" + + +def _pending_evidence(entry: Mapping[str, Any]) -> dict[str, Any]: + return { + "source_generation_key": entry.get("source_generation_key"), + "queue": entry.get("queue"), + "queue_index": entry.get("queue_index"), + "scope": entry.get("scope"), + "old_shpool_id": entry.get("old_shpool_id"), + } + + +def _pending_conflict_fields(entries: Sequence[Mapping[str, Any]]) -> list[str]: + fields = ("scope", "cwd", "argv", "command") + return [ + field + for field in fields + if len({json.dumps(item.get(field), sort_keys=True) for item in entries}) > 1 + ] + + +def _pending_preferred_entry(entries: Sequence[dict[str, Any]]) -> dict[str, Any]: + primary = [item for item in entries if item.get("queue") == "primary"] + if primary: + return primary[0] + + def queue_index(item: dict[str, Any]) -> int: + value = item.get("queue_index") + return value if isinstance(value, int) else -1 + + return max(entries, key=queue_index) + + +def flatten_pending( + value: Any, + *, + schema_version: int, + valid_recovery_state: Callable[[Any], bool], + source_generation_key: Callable[[Any, Any], str | None], + pending_preferred_entry: Callable[[Sequence[dict[str, Any]]], dict[str, Any]], + pending_conflict_fields: Callable[[Sequence[Mapping[str, Any]]], list[str]], + pending_evidence: Callable[[Mapping[str, Any]], dict[str, Any]], + closed_on_purpose: Callable[[Any, Any], bool] | None = None, +) -> dict[str, Any]: + """Return one safe recovery candidate per exact provider conversation. + + A conversation somebody closed on purpose is not recovery work. The + tombstone is honoured HERE, at the one place every consumer passes + through -- the picker's offers, the count in its header, and the ack -- + so an entry queued before the close disappears from all three together + rather than being rewritten out of a file under its own lock. + """ + entries: list[dict[str, Any]] = [] + if not valid_recovery_state(value): + return { + "schema_version": schema_version, + "generated_at": None, + "entries": [], + } + generations: list[ + tuple[ + str, + int | None, + Any, + Any, + Mapping[str, Any], + Mapping[str, Any], + ] + ] = [ + ( + "primary", + None, + value.get("source_boot_id"), + value.get("source_daemon_generation"), + value.get("sessions", {}), + value.get("outside_agents", {}), + ) + ] + for index, queued in enumerate(value.get("queued_generations", ())): + if not isinstance(queued, Mapping) or not isinstance( + queued.get("sessions"), Mapping + ): + continue + generations.append( + ( + "queued", + index, + queued.get("source_boot_id"), + queued.get("source_daemon_generation"), + queued["sessions"], + queued.get("outside_agents", {}) + if isinstance(queued.get("outside_agents", {}), Mapping) + else {}, + ) + ) + for ( + queue, + queue_index, + boot_id, + generation, + sessions, + outside_agents, + ) in generations: + key = source_generation_key(boot_id, generation) + scoped_entries = ( + ("shpool", sessions), + ("outside", outside_agents), + ) + for scope, stored_entries in scoped_entries: + for old_shpool_id, raw_session in stored_entries.items(): + if not isinstance(old_shpool_id, str) or not isinstance( + raw_session, Mapping + ): + continue + uuid = valid_uuid(raw_session.get("uuid")) + provider = raw_session.get("provider") + if ( + closed_on_purpose is not None + and provider in PROVIDERS + and uuid + and closed_on_purpose(provider, uuid) + ): + continue + started_at = raw_session.get("started_at_unix_ms") + entries.append( + { + "source_generation_key": key, + "source_boot_id": boot_id, + "source_daemon_generation": generation, + "queue": queue, + "queue_index": queue_index, + "scope": scope, + "old_shpool_id": old_shpool_id, + "display_old_shpool_id": display_shpool_id(old_shpool_id), + "provider": provider if provider in PROVIDERS else None, + "uuid": uuid, + "title": clean_text(raw_session.get("title"), 120), + "started_at_unix_ms": ( + started_at + if isinstance(started_at, int) + and not isinstance(started_at, bool) + and started_at > 0 + else None + ), + "cwd": clean_text(raw_session.get("cwd"), 4096) or None, + "argv": list(raw_session.get("argv", ())) + if isinstance(raw_session.get("argv"), list) + else [], + "command": clean_text(raw_session.get("command"), 8192) or None, + "actionable": bool(key and uuid and provider in PROVIDERS), + } + ) + entries.sort( + key=lambda item: ( + item["source_generation_key"] or "", + natural_name_key(item["old_shpool_id"]), + item["uuid"] or "", + ) + ) + grouped: dict[tuple[str, str], list[dict[str, Any]]] = {} + ungrouped: list[dict[str, Any]] = [] + for entry in entries: + provider = entry.get("provider") + uuid = entry.get("uuid") + if provider in PROVIDERS and isinstance(uuid, str): + grouped.setdefault((provider, uuid), []).append(entry) + else: + ungrouped.append(entry) + deduplicated: list[dict[str, Any]] = [] + for duplicates in grouped.values(): + preferred = dict(pending_preferred_entry(duplicates)) + conflicts = pending_conflict_fields(duplicates) + preferred["duplicate_count"] = len(duplicates) - 1 + preferred["evidence"] = [pending_evidence(item) for item in duplicates] + preferred["conflict_fields"] = conflicts + preferred["actionable"] = bool(preferred["actionable"] and not conflicts) + deduplicated.append(preferred) + for entry in ungrouped: + retained = dict(entry) + retained.update( + duplicate_count=0, + evidence=[pending_evidence(entry)], + conflict_fields=[], + ) + deduplicated.append(retained) + deduplicated.sort( + key=lambda item: ( + item["source_generation_key"] or "", + natural_name_key(item["old_shpool_id"]), + item["uuid"] or "", + ) + ) + return { + "schema_version": schema_version, + "generated_at": value.get("generated_at"), + "detected_boot_id": value.get("detected_boot_id"), + "detected_daemon_generation": value.get("detected_daemon_generation"), + "entries": deduplicated, + } + + +def list_pending( + config: Mapping[str, Any], + *, + state_paths: Callable[[Mapping[str, Any]], dict[str, Path]], + state_lock: Callable[..., Any], + read_state_json: Callable[[Path], Any], + flatten_pending: Callable[[Any], dict[str, Any]], +) -> dict[str, Any]: + paths = state_paths(config) + with state_lock(paths["root"], paths["lock"]): + return flatten_pending(read_state_json(paths["pending"])) + + +def acknowledge_pending( + config: Mapping[str, Any], + generation_key: str, + old_shpool_id: str, + uuid: str, + *, + collector: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, + schema_version: int, + state_paths: Callable[[Mapping[str, Any]], dict[str, Path]], + state_lock: Callable[..., Any], + read_state_json: Callable[[Path], Any], + valid_recovery_state: Callable[[Any], bool], + flatten_pending: Callable[[Any], dict[str, Any]], + collect_live: Callable[[Mapping[str, Any]], dict[str, Any]], + strict_live_inventory: Callable[[Mapping[str, Any]], bool], +) -> dict[str, Any]: + """Prove one pending entry is restored without deleting its evidence. + + The live projection suppresses retained evidence while the exact provider + conversation is active. If that live record disappears after this check, + the next projection offers the still-persisted candidate again. + """ + exact_uuid = valid_uuid(uuid) + if not generation_key or not old_shpool_id or not exact_uuid: + raise CollectionError( + "pending ack requires generation key, old shpool ID, and exact UUID" + ) + paths = state_paths(config) + with state_lock(paths["root"], paths["lock"]): + pending_raw = read_state_json(paths["pending"]) + if not valid_recovery_state(pending_raw): + raise CollectionError("no valid pending recovery queue exists") + pending = json.loads(json.dumps(pending_raw)) + flattened = flatten_pending(pending) + matches = [ + item + for item in flattened["entries"] + if item["source_generation_key"] == generation_key + and item["old_shpool_id"] == old_shpool_id + and item["uuid"] == exact_uuid + and item["actionable"] + ] + if len(matches) != 1: + raise CollectionError( + "pending entry no longer matches generation, shpool ID, and UUID" + ) + target = matches[0] + + settings = dict(config) + live = collector(settings) if collector is not None else collect_live(settings) + complete = bool(live.pop("_complete", True)) + if not complete or not strict_live_inventory(live): + raise CollectionError( + "strict live inventory unavailable; pending entry was not acknowledged" + ) + active_matches = [ + item + for item in [*live.get("sessions", ()), *live.get("outside_agents", ())] + if item.get("provider") == target["provider"] + and item.get("identity", {}).get("uuid") == exact_uuid + and item.get("identity", {}).get("confidence") == "exact" + ] + if len(active_matches) != 1: + raise CollectionError( + "exact pending provider UUID is not uniquely active; pending entry was not acknowledged" + ) + evidence = target.get("evidence") + if not isinstance(evidence, list) or not evidence: + raise CollectionError("pending recovery evidence is incomplete") + if not all(isinstance(source, Mapping) for source in evidence): + raise CollectionError("pending recovery evidence is malformed") + return { + "schema_version": schema_version, + "acknowledged": target, + "evidence_retained": True, + "remaining": flatten_pending(pending), + } diff --git a/lib/sessionkit_inventory/recovery_list.py b/lib/sessionkit_inventory/recovery_list.py new file mode 100644 index 0000000..a68ecc8 --- /dev/null +++ b/lib/sessionkit_inventory/recovery_list.py @@ -0,0 +1,566 @@ +"""One list of the conversations a person can bring back. + +There were two, built from different stores, and they disagreed about almost +everything. `sp recover` read the crash manifest and the closed-session +ledger; the picker's Closed-sessions screen read the pending-recovery store. +Measured live on 2026-08-15, the two shared three conversations out of +fifty-one: forty-eight were offered only by the picker, six only by the CLI. +Thirty-four of the picker's seventy-seven entries were conversations that were +LIVE at that moment, offered for restore. The one a person had just closed on +purpose was in neither of the picker's -- the pending store drops those by +design, because a deliberate close is not "recovery work" to it. + +So this module is the single projection both surfaces read. It takes every +store that knows about a session that is no longer open, and answers one +question in one order: + +* **Never a live conversation.** A conversation open right now is not + recoverable work; offering it invites a restore that collides with the + session already running. Live is read from the last published inventory. +* **One number, the one it always had.** The kit already binds a number to a + CONVERSATION (`ai:<provider>:<uuid>`) and keeps that binding after the + session closes. Both lists used to number their own rows 1..N instead, so a + session was 106 everywhere and 1 here -- and `sp restore 3` indexed a list + that had been rebuilt since it was printed, which is a wrong restore, not + just a confusing one. +* **A real name or "unnamed".** Titles are only shown when something actually + named the conversation. A generated label ("Claude in v2 at Aug 14 23:44") + is not a name, and "the Claude session" is not one either -- it is the + absence of one, phrased as though it were. +* **History-only says so, in a sentence.** A plain shell has no conversation + to reopen, and a conversation whose transcript this machine can no longer + read cannot come back. Both are listed; neither pretends to be restorable. + +Retention is decided by RESTORABILITY, not by age. A conversation stays on +this list for exactly as long as it could actually be brought back, which is +what the reader is being offered. An age cutoff would silently drop work that +still restores perfectly -- the same class of failure as the missing session +that prompted this. The newest-first cap bounds the history-only rows, which +exist to be read; it never drops a conversation that can come back, and it +never drops anything for being merely old. +""" + +from __future__ import annotations + +import datetime +import re +from typing import Any, Callable, Iterable, Mapping, Sequence + +from .common import clean_text, valid_uuid + + +# How many history-only rows the screen and the read will carry. Nothing that +# can still come back is ever dropped for it, and nothing at all is dropped for +# its age. Well above any real estate: 108 numbers had been issued on the +# machine this was written for, and its ledger held twenty closed +# conversations, of which the stores' own caps bound the rest. +MAX_ROWS = 500 + +# What the collector calls a title it MADE UP. `alias`, `native` and +# `automatic` are names something chose for this conversation; `context` is a +# directory and a timestamp, and `provider` is the bare word "Claude". A row +# whose title came from either of the last two has no name to show. +GENERATED_TITLE_SOURCES = frozenset({"context", "provider"}) + +# A record written before provenance was kept says nothing about its own +# title, so the shape has to answer instead -- and only for the labels the kit +# itself makes: "the Claude session" and "the shell session" from the human +# label, "Idle shell" and the bare provider word from the collector's last +# resort, "Claude in v2 at Aug 14 23:44" from the context title, and the +# generated session name ("v2-35", "v2-af"). Anything else recorded is treated +# as a name somebody chose, because it probably is. +GENERATED_TITLE_SHAPES = ( + re.compile(r"the(\s+\w+)?\s+session"), + re.compile(r"idle shell"), + re.compile(r"(claude|codex|shell)"), + re.compile(r"(claude|codex|shell)\s+(in\s+\S+\s+at|started)\s+.+"), + re.compile(r"[a-z0-9][a-z0-9._-]*-[0-9a-f]{2,4}"), +) + +UNNAMED = "unnamed" + +SHELL_HISTORY_REASON = ( + "a plain shell, so only its history is kept -- there is no conversation to reopen" +) +UNREADABLE_HISTORY_REASON = ( + "its transcript is no longer on this machine, so only its history is kept" +) +# Two launch records for one conversation that disagree. The kit will not +# choose between them for them, and saying "a plain shell" about a Claude +# conversation with a transcript is worse than saying nothing. +CONFLICT_HISTORY_REASON = ( + "its launch records disagree with each other, so it needs a look by hand" +) +# Actionable is false and nothing above explains it: the record is missing the +# keys a restore is made from. +INCOMPLETE_HISTORY_REASON = ( + "its launch record is incomplete, so only its history is kept" +) + + +def number_key(provider: Any, uuid: Any) -> str: + """The registry key a conversation's number is bound to.""" + exact = valid_uuid(uuid) or "" + if not isinstance(provider, str) or not provider or not exact: + return "" + return f"ai:{provider}:{exact}" + + +def _stable_number(numbers: Mapping[str, Any], provider: Any, uuid: Any) -> int | None: + """The number this conversation already has, or None if it never had one.""" + key = number_key(provider, uuid) + if not key: + return None + value = numbers.get(key) if isinstance(numbers, Mapping) else None + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + return None + return value + + +def _unambiguous_numbers(numbers: Mapping[str, Any]) -> dict[str, Any]: + """The bindings, minus any number that two conversations both claim. + + This read does not go through the registry's validating reader (a listing + verb takes no lock), so a half-written or stale file can hold one number + twice. A number that names two conversations names neither: both rows drop + to no number rather than one of them silently standing for the other. + """ + if not isinstance(numbers, Mapping): + return {} + counts: dict[Any, int] = {} + for key, value in numbers.items(): + if isinstance(key, str) and key.startswith("ai:"): + counts[value] = counts.get(value, 0) + 1 + return { + key: value + for key, value in numbers.items() + if counts.get(value, 0) <= 1 + } + + +def _looks_generated(title: str) -> bool: + """True when the kit, not a person, made this label up.""" + folded = " ".join(title.casefold().split()) + return any(shape.fullmatch(folded) for shape in GENERATED_TITLE_SHAPES) + + +def _live_keys(live_sessions: Iterable[Any]) -> set[tuple[str, str]]: + """Every conversation visible in the last published inventory.""" + keys: set[tuple[str, str]] = set() + for row in live_sessions or (): + if not isinstance(row, Mapping): + continue + identity = row.get("identity") + raw = identity.get("uuid") if isinstance(identity, Mapping) else None + exact = valid_uuid(raw) or "" + provider = row.get("provider") + if exact and isinstance(provider, str): + keys.add((provider, exact.casefold())) + return keys + + +def _real_name( + *, + provider: Any, + uuid: Any, + recorded_title: Any, + title_source: Any, + aliases: Mapping[str, Any], + automatic_titles: Mapping[str, Any], +) -> str: + """The name something actually gave this conversation, or "". + + The stores that hold a chosen name outlive the session, so they are asked + first and settle it. Only when neither has one does the recorded title + speak -- and then only if whatever wrote it said where it came from and + that source was a name. A record written before provenance was kept is + judged by shape against the labels the kit generates, and by nothing else: + never invent a name, never discard one somebody chose. + """ + # A shell has no conversation, so nothing ever named it. What its record + # holds is the name of whatever process happened to be running -- "python3", + # or "Idle shell" when none was -- and neither is a name a person chose. + if provider == "shell": + return "" + exact = valid_uuid(uuid) or "" + if isinstance(provider, str) and provider and exact: + key = f"{provider}:{exact}" + for store in (aliases, automatic_titles): + if not isinstance(store, Mapping): + continue + found = clean_text(store.get(key), 120) + if found: + return found + recorded = clean_text(recorded_title, 120) + if not recorded: + return "" + source = clean_text(title_source, 40).casefold() + if source: + return "" if source in GENERATED_TITLE_SOURCES else recorded + return "" if _looks_generated(recorded) else recorded + + +def _candidate( + *, + source: str, + provider: Any, + uuid: Any, + cwd: Any, + when: Any, + recorded_title: Any, + title_source: Any, + restorable: bool, + history_reason: str, + generation_key: Any = "", + old_shpool_id: Any = "", + conflict_fields: Any = (), + aliases: Mapping[str, Any], + automatic_titles: Mapping[str, Any], + numbers: Mapping[str, Any], +) -> dict[str, Any]: + exact = valid_uuid(uuid) or "" + name = _real_name( + provider=provider, + uuid=exact, + recorded_title=recorded_title, + title_source=title_source, + aliases=aliases, + automatic_titles=automatic_titles, + ) + when_ms = ( + when + if isinstance(when, int) and not isinstance(when, bool) and when > 0 + else 0 + ) + return { + "source": source, + "number": _stable_number(numbers, provider, exact), + # A number is bound per boot, so a conversation closed before the last + # one has none to be consistent with. It stays listed and stays + # reachable: `sp restore` takes this short id as well as a number. + "short_id": exact[:8], + "provider": provider if isinstance(provider, str) else "", + "uuid": exact, + "cwd": clean_text(cwd, 4096), + "name": name, + "named": bool(name), + "display_name": name or UNNAMED, + "restorable": bool(restorable and exact), + "history_only_reason": "" if (restorable and exact) else history_reason, + "when_unix_ms": when_ms, + "source_generation_key": clean_text(generation_key, 200), + "old_shpool_id": clean_text(old_shpool_id, 200), + # The picker refuses to act on a record whose evidence disagrees with + # itself. That guard is older than this list and outlives it. + "conflict_fields": [ + clean_text(field, 80) + for field in (conflict_fields or ()) + if clean_text(field, 80) + ], + } + + +def recovery_rows( + *, + manifest_sessions: Iterable[Any] = (), + closed_rows: Iterable[Any] = (), + pending_entries: Iterable[Any] = (), + live_sessions: Iterable[Any] = (), + aliases: Mapping[str, Any] | None = None, + automatic_titles: Mapping[str, Any] | None = None, + numbers: Mapping[str, Any] | None = None, + still_readable: Callable[[Any, str], bool] | None = None, + limit: int = MAX_ROWS, +) -> list[dict[str, Any]]: + """The one ordered list both recovery surfaces show. + + Newest first. One row per conversation, whichever store knew about it; + when more than one does, the newest event wins and the pending record + still contributes the keys its acknowledgment needs. + + ``still_readable`` answers the one question that decides whether a + conversation can come back at all, and every store is asked it the same + way: the ledger, the crash manifest and the pending queue used to disagree + about a transcript that is gone -- one dropped the row, the others offered + a restore that walks into a missing file. + """ + aliases = aliases if isinstance(aliases, Mapping) else {} + automatic_titles = ( + automatic_titles if isinstance(automatic_titles, Mapping) else {} + ) + numbers = _unambiguous_numbers(numbers if isinstance(numbers, Mapping) else {}) + live = _live_keys(live_sessions) + readable_cache: dict[tuple[Any, str], bool] = {} + + def readable(provider: Any, exact: str) -> bool: + # Nothing to read is not the same as unreadable: a shell never had a + # conversation, and a row with no uuid has nothing to look for. + if still_readable is None or provider == "shell" or not exact: + return True + key = (provider, exact) + if key not in readable_cache: + readable_cache[key] = bool(still_readable(provider, exact)) + return readable_cache[key] + + candidates: list[dict[str, Any]] = [] + + # A crash took these: the session is gone and nobody chose that. + for row in manifest_sessions or (): + if not isinstance(row, Mapping): + continue + provider = row.get("provider") + exact = valid_uuid(row.get("uuid")) or "" + intact = readable(provider, exact) + candidates.append( + _candidate( + source="lost", + provider=provider, + uuid=exact, + cwd=row.get("cwd"), + when=row.get("crashed_at_unix_ms"), + recorded_title=row.get("title"), + title_source=row.get("title_source"), + restorable=bool(exact) and intact, + history_reason=( + SHELL_HISTORY_REASON + if provider == "shell" or not exact + else UNREADABLE_HISTORY_REASON + ), + aliases=aliases, + automatic_titles=automatic_titles, + numbers=numbers, + ) + ) + + # Closed on purpose. Ending a terminal never ends the conversation, so + # these belong here exactly like the lost ones -- hiding them was the + # broken half of the promise, and the pending store hides them all. + for row in closed_rows or (): + if not isinstance(row, Mapping): + continue + provider = row.get("provider") + exact = valid_uuid(row.get("uuid")) or "" + restorable = ( + bool(row.get("restorable")) + and provider != "shell" + and readable(provider, exact) + ) + candidates.append( + _candidate( + source="closed", + provider=provider, + uuid=exact, + cwd=row.get("cwd"), + when=row.get("closed_at_unix_ms"), + recorded_title=row.get("title"), + title_source=row.get("title_source"), + restorable=restorable, + history_reason=( + SHELL_HISTORY_REASON + if provider == "shell" + else UNREADABLE_HISTORY_REASON + ), + # The ledger's own identity for a session that has no + # conversation. Without it a closed shell whose directory was + # never recorded is dropped by the merge below -- listed by + # neither surface, which is the bug this list exists to end. + old_shpool_id=row.get("shpool_id"), + aliases=aliases, + automatic_titles=automatic_titles, + numbers=numbers, + ) + ) + + # A daemon generation ended under these. They carry the only keys the + # acknowledgment can be made with, so they are merged in rather than + # chosen between. + for row in pending_entries or (): + if not isinstance(row, Mapping): + continue + provider = row.get("provider") + exact = valid_uuid(row.get("uuid")) or "" + conflicts = row.get("conflict_fields") or () + intact = readable(provider, exact) + candidates.append( + _candidate( + source="lost", + provider=provider, + uuid=exact, + cwd=row.get("cwd"), + when=row.get("started_at_unix_ms"), + recorded_title=row.get("title"), + title_source=row.get("title_source"), + restorable=bool(row.get("actionable")) and intact, + # Why it cannot come back, in the words that are true of THIS + # record. Every non-actionable pending row used to be called a + # plain shell, including a Claude conversation whose launch + # records disagree -- which is both false and the one case + # that needs them to look. + history_reason=( + SHELL_HISTORY_REASON + if provider == "shell" or not exact + else UNREADABLE_HISTORY_REASON + if not intact + else CONFLICT_HISTORY_REASON + if conflicts + else INCOMPLETE_HISTORY_REASON + ), + generation_key=row.get("source_generation_key"), + old_shpool_id=row.get("old_shpool_id"), + conflict_fields=row.get("conflict_fields") or (), + aliases=aliases, + automatic_titles=automatic_titles, + numbers=numbers, + ) + ) + + merged: dict[tuple[str, str], dict[str, Any]] = {} + ordered: list[dict[str, Any]] = [] + for row in candidates: + if row["uuid"] and (row["provider"], row["uuid"].casefold()) in live: + # Open right now. Not recoverable work, and never offered as such. + continue + if not row["uuid"]: + # A shell has no conversation to merge on; it is its own row, and + # only its own record can identify it. + if not row["old_shpool_id"] and not row["cwd"]: + continue + ordered.append(row) + continue + key = (row["provider"], row["uuid"].casefold()) + held = merged.get(key) + if held is None: + merged[key] = row + ordered.append(row) + continue + # The newest event describes the session best, but the acknowledgment + # keys exist on exactly one record and must survive the merge. + if row["when_unix_ms"] > held["when_unix_ms"]: + for field in ("source", "when_unix_ms", "cwd", "restorable", + "history_only_reason"): + held[field] = row[field] + if row["named"]: + held["name"] = row["name"] + held["named"] = True + held["display_name"] = row["display_name"] + elif row["named"] and not held["named"]: + held["name"] = row["name"] + held["named"] = True + held["display_name"] = row["display_name"] + if row["source_generation_key"] and not held["source_generation_key"]: + held["source_generation_key"] = row["source_generation_key"] + held["old_shpool_id"] = row["old_shpool_id"] + if row["conflict_fields"] and not held["conflict_fields"]: + held["conflict_fields"] = row["conflict_fields"] + + # A short id names one conversation for a machine reader; no screen prints + # it. Eight hex characters separate any real estate, and when two rows do + # share a prefix both grow until they actually differ -- growing once to + # twelve left two conversations sharing "aaaaaaaaaaaa" and calling it + # settled. + width = 8 + while width < 32: + by_short: dict[str, list[dict[str, Any]]] = {} + for row in ordered: + if row["short_id"]: + by_short.setdefault(row["short_id"], []).append(row) + sharing = [rows for rows in by_short.values() if len(rows) > 1] + if not sharing: + break + width += 4 + for rows in sharing: + for row in rows: + row["short_id"] = row["uuid"].replace("-", "")[:width] + + ordered.sort( + key=lambda item: ( + -item["when_unix_ms"], + item["number"] if isinstance(item["number"], int) else 1 << 30, + item["uuid"], + ) + ) + bound = limit if isinstance(limit, int) and not isinstance(limit, bool) else MAX_ROWS + bound = max(0, bound) + if len(ordered) > bound: + # The cap bounds the reading, and reading is all a history-only row is + # for. A conversation that can still come back is the work itself, so + # the cap never takes one: an aggregate limit applied over the merged + # list made the module's own promise false at its edge, and the row it + # dropped was reachable from neither surface. + restorable = [row for row in ordered if row["restorable"]] + history = [row for row in ordered if not row["restorable"]] + kept = {id(row) for row in restorable} + kept.update(id(row) for row in history[: max(0, bound - len(restorable))]) + ordered = [row for row in ordered if id(row) in kept] + + _assign_selectors(ordered) + return ordered + + +def _assign_selectors(rows: Sequence[dict[str, Any]]) -> None: + """What a person types to act on a row -- the same string on both screens. + + A number if the conversation still has one. Otherwise the name beside it, + which is what the screen shows and the only thing on that row a person + could type. A name is not always unique, though: everything nobody named + reads "unnamed", so two of those would answer to one word -- one surface + restoring the first and the other refusing. When a name is taken, the row + takes the time of its own event instead, which is fixed for as long as the + row exists and cannot be a row position. + + Taken means taken by ANY row on this list, not by another row with the + same name. A person may call a conversation "2", and session 2 may exist: + those were settled in separate passes, so both rows answered to "2" and + both surfaces sent it to the numbered one -- a word that names two rows, + which is the whole thing this list exists to stop. A word belongs to one + row on the whole list or it belongs to none of them. + + A row that cannot come back has no selector: its sentence is already under + it on the list, and there is nothing to act on. Its number still selects + it, because the screen shows one. + """ + taken: set[str] = set() + waiting: list[dict[str, Any]] = [] + for row in rows: + if isinstance(row["number"], int): + # The number is the identity the rest of the kit already uses for + # this conversation, so it is never given up to a name. + row["selector"] = str(row["number"]) + taken.add(row["selector"].casefold()) + continue + row["selector"] = "" + if row["restorable"]: + waiting.append(row) + + def offer(candidates: Sequence[tuple[str, dict[str, Any]]]) -> None: + """Hand out the words no other row is asking for or already holds.""" + claims: dict[str, int] = {} + for token, _ in candidates: + claims[token.casefold()] = claims.get(token.casefold(), 0) + 1 + for token, row in candidates: + folded = token.casefold() + if not token or folded in taken or claims[folded] != 1: + continue + row["selector"] = token + taken.add(folded) + + offer([(row["display_name"], row) for row in waiting]) + for pattern in ("%H:%M", "%H:%M:%S"): + waiting = [row for row in waiting if not row["selector"]] + if not waiting: + break + offer([(_event_stamp(row, pattern), row) for row in waiting]) + # Whatever is still empty had nothing left to tell it apart -- same name, + # same second. Those rows keep no selector, so both surfaces refuse rather + # than one of them guessing. + + +def _event_stamp(row: Mapping[str, Any], pattern: str) -> str: + when = row.get("when_unix_ms") + if not isinstance(when, int) or isinstance(when, bool) or when <= 0: + return "" + try: + moment = datetime.datetime.fromtimestamp(when / 1000) + except (OSError, OverflowError, ValueError): + return "" + return f"@{moment.strftime(pattern)}" diff --git a/lib/sessionkit_inventory/render.py b/lib/sessionkit_inventory/render.py new file mode 100644 index 0000000..91ef17b --- /dev/null +++ b/lib/sessionkit_inventory/render.py @@ -0,0 +1,1232 @@ +"""Terminal width, semantic colour, and the dashboard, detail, and lookup views. + +Not one word a person reads is written here. Every label, state word, group +heading, time phrase and refusal comes from `labels.py`, so this file decides +layout and colour only, and `sp list`, `sp detail` and the picker cannot drift +into three spellings of one idea. A literal shown to a person belongs in +`labels.py`; `tests/test_voice_labels.py` fails the day one appears below. +""" + +from __future__ import annotations + +import os +import shutil +import sys +import time +import unicodedata +from typing import Any, Callable, Iterable, Mapping + +from . import labels +from .common import ( + _positive_int, + clean_text, + stall_threshold_seconds as _shared_stall_threshold_seconds, +) +from .model import classify_top_level_sessions, session_is_unavailable + + +DEFAULT_STALL_SECONDS = labels.STALL_DEFAULT_SECONDS + +# One definition, in `labels`, beside the function that applies it. +MAX_PLAUSIBLE_AGE_SECONDS = labels.MAX_PLAUSIBLE_AGE_SECONDS + +# One palette for every text dashboard. The old picker and ``sp list`` call +# this module, so a provider or session colour cannot drift between them. +SESSION_PALETTE = { + "red": "\033[38;2;237;93;93m", + "blue": "\033[38;2;97;166;240m", + "green": "\033[38;2;63;221;115m", + "yellow": "\033[38;2;249;215;108m", + "purple": "\033[38;2;173;115;239m", + "orange": "\033[38;2;242;144;81m", + "pink": "\033[38;2;240;113;177m", + "cyan": "\033[38;2;64;216;209m", + "lime": "\033[38;2;170;230;70m", + "magenta": "\033[38;2;255;95;255m", + "silver": "\033[38;2;205;210;220m", + "sand": "\033[38;2;214;178;130m", + "sky": "\033[38;2;150;205;255m", + "sea": "\033[38;2;95;235;170m", +} + +PROVIDER_BADGES = { + labels.PROVIDER_CLAUDE: "CLD", + labels.PROVIDER_CODEX: "CDX", + labels.PROVIDER_SHELL: "SHL", + labels.PROVIDER_UNKNOWN: "UNK", +} + +PROVIDER_BADGE_COLORS = { + labels.PROVIDER_CLAUDE: SESSION_PALETTE["yellow"], + labels.PROVIDER_CODEX: SESSION_PALETTE["cyan"], + labels.PROVIDER_SHELL: SESSION_PALETTE["green"], + # Unknown used to reuse Claude yellow, visually claiming evidence the row + # did not contain. Silver is deliberately provider-neutral. + labels.PROVIDER_UNKNOWN: SESSION_PALETTE["silver"], +} + +ACCOUNT_CELL_MAX = 12 +MODEL_CELL_MAX = 20 + +# The fewest cells a title may be squeezed into before the metadata columns +# start giving room back. Filling the model column cost every row real width, +# and on a narrow terminal that came straight out of the title: a row rendered +# as `…` names nothing and is worth less than the model beside it. +MIN_TITLE_CELLS = 12 + +# The dashboard's last resort is a recognisable title fragment beside the +# complete state/count/time group. This lower floor is used only after the +# metadata columns have already yielded; it keeps the same row structure at +# 60 columns instead of changing the whole page at the next width. +RESPONSIVE_MIN_TITLE_CELLS = 4 + +# The floors the metadata columns keep once the state-and-time pair starts +# taking room back from them. Eight cells still shows `Opus 5`, `no model`, or +# a recognisable `GPT-5.6…`; three still shows a shortened alias. Below these a +# column stops being worth the space it costs. +MIN_MODEL_CELLS = 8 +MIN_ACCOUNT_CELLS = 3 + +# A placeholder is visible copy, but it is not evidence that its column needs +# seven cells. Real values decide the width. When every value is a placeholder, +# three cells retain an identifiable ``pe…`` without letting absence become the +# widest fact on the page. +PLACEHOLDER_CELL_FLOOR = 3 + + +def _placeholder_floor_width(value: str) -> int | None: + """Width an all-placeholder account/model column may claim.""" + + if value == labels.MISSING: + return PLACEHOLDER_CELL_FLOOR + return None + + +def _real_value_width(values: Iterable[str], *, default: int = 1) -> int: + """Size one aligned column from real values, not placeholder copy.""" + + real: list[int] = [] + placeholder_floors: list[int] = [] + for value in values: + floor = _placeholder_floor_width(value) + if floor is None: + real.append(_display_width(value)) + else: + placeholder_floors.append(floor) + if real: + return max(real) + if placeholder_floors: + return max(placeholder_floors) + return default + + +def _yield_to_essential( + account_width: int, + model_width: int, + shortfall: int, + *, + model_floor: int = MIN_MODEL_CELLS, + account_floor: int = MIN_ACCOUNT_CELLS, +) -> tuple[int, int, int]: + """Take room from the metadata columns to keep the state and the time. + + The order is what a person needs off a row when the window is small: whose + turn it is and when it last moved, then the name, then which model, then + which account. Returns the two widths and whatever shortfall is still + unmet, at which point the row genuinely cannot carry the pair and it comes + off every row on the page together. + + The floors are readable ones by default, and callers drop them to a single + cell for the last squeeze: on a window too narrow to hold even the state + word, a shortened model is worth less than the word that says whose turn + it is. + """ + if shortfall <= 0: + return account_width, model_width, 0 + taken = min(shortfall, max(0, model_width - model_floor)) + model_width -= taken + shortfall -= taken + taken = min(shortfall, max(0, account_width - account_floor)) + return account_width - taken, model_width, shortfall - taken + + +def _yield_to_title( + account_width: int, model_width: int, title_room: int +) -> tuple[int, int, int]: + """Give metadata room back to the title when a row is cramped. + + Order matters and is a judgement about what a person reads first: the model + gives up its room before the account does, and both stop at the floor where + a shortened value still reads. They used to give down to a single cell, + which rendered the model column as a bare `…`, a column that costs a cell + and says nothing is worse than a narrow one. + """ + needed = MIN_TITLE_CELLS - title_room + if needed <= 0: + return account_width, model_width, title_room + from_model = min(needed, max(0, model_width - MIN_MODEL_CELLS)) + model_width -= from_model + title_room += from_model + needed -= from_model + from_account = min(needed, max(0, account_width - MIN_ACCOUNT_CELLS)) + return account_width - from_account, model_width, title_room + from_account + + +def _budget_field_widths( + raw_account_width: int, raw_model_width: int, field_budget: int +) -> tuple[int, int]: + """Fit account and model cells into one terminal-width budget. + + Both text list surfaces use this allocation. Account gets at most one + third of a constrained budget; model gets the remainder, and both keep at + least one display cell so a missing or shortened value remains visible. + """ + field_budget = max(2, field_budget) + if raw_account_width + raw_model_width <= field_budget: + return raw_account_width, raw_model_width + account_width = min(raw_account_width, max(1, field_budget // 3)) + model_width = min(raw_model_width, max(1, field_budget - account_width)) + return account_width, model_width + + +def _provider_name(value: object) -> str: + return labels.provider_name(value) + + +def _term(value: str) -> str: + """The one word `sp list` prints for a collector's own status word. + + The full table: `sp list` and the picker say the same word for the + same state ("working", never the collector's "running"). + """ + return labels.state_word(value) + + +def _where(row: Mapping[str, Any]) -> str: + """Where the session can be opened, in the two words `sp list` uses.""" + return labels.where_word(row.get("availability")) + + +def _title_state(row: Mapping[str, Any]) -> str: + """Whether the provider is already showing this session's title.""" + return labels.title_state(row.get("provider_title_state")) + + +def _worktree_branch(row: Mapping[str, Any]) -> str: + """The branch a session is isolated on, as the collector recorded it.""" + worktree = row.get("worktree") + if not isinstance(worktree, Mapping): + return "" + return clean_text(worktree.get("branch"), 128) + + +def _timestamp(value: object) -> int | None: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + return None + return value + + +def _relative_time(timestamp_ms: int, now_ms: int) -> str: + return labels.relative_time(timestamp_ms, now_ms) + + +def _precise_local_time(timestamp_ms: int) -> str: + return labels.precise_local_time(timestamp_ms) + + +def _time_detail(timestamp_ms: object, now_ms: int) -> str: + exact = _timestamp(timestamp_ms) + if exact is None: + return "" + return labels.exact_and_relative(exact, now_ms) + + +# Both row facts come from `labels`, which is the only copy of either. They are +# named here so this module reads the same as before; they are not a second +# implementation, which is what the two surfaces had and disagreed over. +_last_active_seconds = labels.last_active_seconds +_last_active = labels.row_last_active +_model_cell = labels.model_cell + + +def _format_age(seconds: int | None) -> str: + if seconds is None: + return "" + return labels.duration(seconds) + + +def _short_path(path: str, *, home_factory: Callable[[], Any]) -> str: + home = str(home_factory()) + return ( + f"~{path[len(home) :]}" + if path == home or path.startswith(home + os.sep) + else path + ) + + +def _display_width(value: str) -> int: + """Conservative terminal-cell width without adding a runtime dependency.""" + return sum( + 0 + if unicodedata.combining(character) + else 2 + if unicodedata.east_asian_width(character) in {"W", "F"} + else 1 + for character in value + ) + + +def _pad(text: str, width: int) -> str: + """Pad to a number of terminal cells, not a number of characters. + + `str.ljust` counts characters. Every cell in these rows is truncated by + display width and was then padded by `ljust`, so a six-character CJK name + measured twelve cells wide, was accepted as fitting, and then had six more + spaces appended, the row over-ran its column by exactly the width the + wide characters added. One helper, used for every column. + """ + return text + " " * max(0, width - _display_width(text)) + + +def _truncate_cells(text: str, limit: int) -> str: + """Terminal-cell-aware truncation that leaves the text alone otherwise. + + Deliberately does NOT clean: `clean_text` flattens runs of spaces, which + is right for a value read out of a row and fatal for a line that has + already been padded into columns, it collapses the padding and the + columns with it. + """ + if _display_width(text) <= limit: + return text + if limit <= 1: + return labels.ELLIPSIS + kept: list[str] = [] + used = 0 + for character in text: + cells = _display_width(character) + if used + cells > limit - 1: + break + kept.append(character) + used += cells + return f"{''.join(kept)}{labels.ELLIPSIS}" + + +def _display_title(value: Any, limit: int = 48) -> str: + """Control-safe, terminal-cell-aware truncation for display only.""" + return _truncate_cells(clean_text(value, 10000), limit) + + +def _color_enabled() -> bool: + return ( + sys.stdout.isatty() + and not os.environ.get("NO_COLOR") + and not os.environ.get("SESSION_KIT_NO_COLOR") + ) + + +def stall_threshold_seconds() -> int: + """Silence after which a session stops being described as running. + + Deliberately far above any normal pause. Codex sessions report "running" + for their whole life, working or waiting, so a short threshold would label + every parked session and the warning would stop meaning anything. Override + with SESSION_KIT_STALL_SECONDS. + """ + return _shared_stall_threshold_seconds() + + +def render_inventory( + inventory: Mapping[str, Any], + rows_only: bool = False, + *, + color_enabled: Callable[[], bool], + now_ms: int | None = None, +) -> str: + """Render action-first/provider groups with sequential visible row numbers.""" + color = color_enabled() + rendered_at = int(time.time() * 1000) if now_ms is None else now_ms + bold = "\033[1m" if color else "" + dim = "\033[2m" if color else "" + cyan = "\033[36m" if color else "" + green = "\033[32m" if color else "" + yellow = "\033[33m" if color else "" + reset = "\033[0m" if color else "" + # Truecolor values equal to what Codex RENDERS for each sk-<color> theme + # accent (measured from captured frames, Codex contrast-adjusts the raw + # theme hex), so a session's name is pixel-identical here and in its own + # Codex status bar. Remeasure if the theme anchors ever change. + session_palette = SESSION_PALETTE if color else {} + + def tint(item: Mapping[str, Any], text: str) -> str: + code = session_palette.get(item.get("display_color") or "") + return f"{code}{text}{reset}" if code else text + + terminal_columns = shutil.get_terminal_size(fallback=(101, 24)).columns + columns = _positive_int( + os.environ.get("COLUMNS"), + max(2, min(240, terminal_columns)), + 2, + 240, + ) + width = max(1, columns - 1) + lines: list[str] = [] + if inventory.get("stale"): + lines.append( + f"{yellow} {labels.stale_warning(inventory.get('source'))}{reset}" + ) + collected_sessions = [ + item for item in inventory.get("sessions", ()) if isinstance(item, Mapping) + ] + sessions, machine_sessions, _ = classify_top_level_sessions(collected_sessions) + sessions = [item for item in sessions if not session_is_unavailable(item)] + machine_sessions = [ + item for item in machine_sessions if not session_is_unavailable(item) + ] + has_terminal_numbers = any( + isinstance(item, Mapping) + and isinstance(item.get("terminal_number"), int) + and not isinstance(item.get("terminal_number"), bool) + and item.get("terminal_number", 0) > 0 + for item in sessions + ) + ready_count = sum( + 1 for item in sessions if item.get("availability") == labels.AVAILABILITY_READY + ) + elsewhere_count = sum( + 1 + for item in sessions + if item.get("availability") == labels.AVAILABILITY_ATTACHED + ) + if sessions: + lines.append( + f" {labels.session_count(len(sessions), ready_count, elsewhere_count)}" + ) + if sessions: + lines.append("") + number_width = max( + 2, + max( + ( + len( + str( + item.get("terminal_number") + or item.get("row") + or labels.UNNUMBERED + ) + ) + for item in sessions + ), + default=2, + ), + ) + field_budget = max(2, width - (8 + number_width + 3 + 12 + 8 + 4)) + raw_account_width = min( + ACCOUNT_CELL_MAX, + _real_value_width( + ( + clean_text(item.get("account_alias"), 80) or labels.MISSING + for item in sessions + ) + ), + ) + raw_model_width = min( + MODEL_CELL_MAX, + _real_value_width((_model_cell(item) for item in sessions)), + ) + stall = stall_threshold_seconds() + # The state column is padded to one width across the whole list so the time + # phrase after it starts in the same place on every row. + state_width = max( + ( + _display_width(labels.session_state(item, stall_seconds=stall)) + for item in sessions + ), + default=0, + ) + account_width, model_width = _budget_field_widths( + raw_account_width, raw_model_width, field_budget + ) + subagent_detail_reserve = max( + 4, + max( + ( + _display_width( + labels.subagent_detail( + int( + item.get( + "active_subagent_count", + len(item.get("subagents", ())), + ) + ) + ) + ) + + 1 + for item in sessions + if int( + item.get("active_subagent_count", len(item.get("subagents", ()))) + ) + > 0 + ), + default=4, + ), + ) + # The pair every row exists to carry -- whose turn it is, and when it last + # did something -- is reserved before the title is sized, so a long session + # name costs its own tail and never the column. `sp list` had the same + # defect the picker did. + essential_reserve = max( + ( + _display_width(labels.session_state(item, stall_seconds=stall)) + + len(labels.SEPARATOR) + + ( + len(labels.SEPARATOR) + + _display_width( + labels.subagent_detail( + int( + item.get( + "active_subagent_count", len(item.get("subagents", ())) + ) + ) + ) + ) + if int( + item.get("active_subagent_count", len(item.get("subagents", ()))) + ) + > 0 + else 0 + ) + + len(labels.SEPARATOR) + + _display_width( + labels.row_last_active_compact(item, rendered_at) + if width < 100 + else labels.row_last_active(item, rendered_at) + ) + for item in sessions + ), + default=0, + ) + title_room = width - ( + 8 + + number_width + + 3 + + account_width + + model_width + + 12 + + max(subagent_detail_reserve, essential_reserve) + ) + account_width, model_width, title_room = _yield_to_title( + account_width, model_width, title_room + ) + # And when even the floor cannot carry the state-and-time pair, the + # metadata columns give room back before the pair is dropped. + account_width, model_width, _ = _yield_to_essential( + account_width, model_width, MIN_TITLE_CELLS - title_room + ) + title_room = width - ( + 8 + + number_width + + 3 + + account_width + + model_width + + 12 + + max(subagent_detail_reserve, essential_reserve) + ) + # The title keeps its floor even when the reserved pair leaves less: below + # about twelve cells a name stops naming anything, and `sp list` truncates + # its details rather than rendering every row as a bare ellipsis. + title_width = max( + 1, + min( + max( + ( + _display_width(clean_text(item.get("title"), 10000)) + for item in sessions + ), + default=1, + ), + max(title_room, MIN_TITLE_CELLS), + ), + ) + for availability in labels.AVAILABILITY_ORDER: + selected = [ + item for item in sessions if item.get("availability") == availability + ] + if not selected: + continue + lines.append(f" {bold}{labels.group_heading(availability)}{reset}") + for item in selected: + provider = clean_text( + item.get("display_provider") or item.get("provider"), 20 + ).casefold() + if provider not in PROVIDER_BADGES: + provider = labels.PROVIDER_UNKNOWN + status = labels.session_state(item, stall_seconds=stall) + agent_count = int( + item.get("active_subagent_count", len(item.get("subagents", ()))) + ) + agent_detail = labels.subagent_detail(agent_count) if agent_count else "" + age_detail = ( + labels.row_last_active_compact(item, rendered_at) + if width < 100 + else _last_active(item, rendered_at) + ) + status_parts = [ + _pad(status, state_width), + *([agent_detail] if agent_detail else []), + age_detail, + ] + branch = _worktree_branch(item) + if branch: + status_parts.append(labels.worktree_detail(branch)) + selector = item.get("terminal_number") + if ( + isinstance(selector, bool) + or not isinstance(selector, int) + or selector <= 0 + ): + selector = ( + labels.UNNUMBERED if has_terminal_numbers else item.get("row") + ) + selector_text = str(selector) + account = _display_title( + clean_text(item.get("account_alias"), 80) or labels.MISSING, + account_width, + ) + model = _display_title(_model_cell(item), model_width) + badge = PROVIDER_BADGES[provider] + title = _display_title(item.get("title"), title_width) + # Padding is measured on the plain text and appended after the + # coloured text: `_display_width` counts an escape sequence's bytes. + title_padding = " " * max(0, title_width - _display_width(title)) + detail_color = ( + yellow + if item.get("needs_you") + or status + in { + labels.QUESTION, + labels.WAITING_ON_YOU, + labels.SETUP_INCOMPLETE, + } + else dim + ) + if width < 60: + narrow_parts = [status, *([agent_detail] if agent_detail else [])] + narrow_detail = labels.SEPARATOR.join(narrow_parts) + narrow_prefix = f" {selector_text:>{number_width}} " + narrow_title_room = max( + 1, + width + - _display_width(narrow_prefix) + - len(labels.SEPARATOR) + - _display_width(narrow_detail), + ) + narrow_title = _display_title(item.get("title"), narrow_title_room) + lines.append( + f"{narrow_prefix}{tint(item, narrow_title)} | " + f"{detail_color}{narrow_detail}{reset}" + ) + continue + fixed = ( + 8 + number_width + 3 + account_width + model_width + 12 + title_width + ) + # Truncated, never re-cleaned: the state cell is padded so the time + # phrase after it starts at one column on every row, and cleaning + # would eat that padding. + details = _truncate_cells( + labels.SEPARATOR.join(status_parts), max(1, width - fixed) + ) + # A person's turn is highlighted whatever put it there. This used to + # name `provider exited` explicitly; that word no longer reaches the + # screen (it translates to `needs you`), and testing the screen + # word instead keeps an exited provider highlighted rather than + # silently dimming it. + lines.append( + f" {green}{selector_text:>{number_width}}{reset} " + f"{tint(item, title)}{title_padding} | {cyan}{badge}{reset} | " + f"{_pad(account, account_width)} | {_pad(model, model_width)} | " + f"{detail_color}{details}{reset}" + ) + collapsed_count = len(machine_sessions) + if collapsed_count: + count = collapsed_count + lines.append(f" {count} {labels.plural(count, 'machine session')}") + outside = list(inventory.get("outside_agents", ())) + if outside: + lines.append(f" {bold}{labels.GROUP_OUTSIDE_THE_KIT}{reset}") + for item in outside: + agent_count = int( + item.get("active_subagent_count", len(item.get("subagents", ()))) + ) + prefix = f" {labels.UNNUMBERED} " + title = _display_title( + item.get("title"), max(1, width - _display_width(prefix)) + ) + lines.append(f"{prefix}{tint(item, title)}") + detail_parts = [ + _provider_name(item.get("provider")), + _term(clean_text(item.get("agent_status"), 64) or labels.MISSING), + ] + if agent_count: + detail_parts.append(labels.subagent_detail(agent_count)) + detail_prefix = " " + details = _display_title( + labels.SEPARATOR.join(detail_parts), + max(1, width - _display_width(detail_prefix)), + ) + lines.append(f"{detail_prefix}{dim}{details}{reset}") + if not sessions and not machine_sessions and not outside: + lines.append(f" {labels.SESSIONS_EMPTY}") + if not rows_only: + lines.append("") + lines.extend(f" {dim}{hint}{reset}" for hint in labels.LIST_HINTS) + return "\n".join(lines) + + +def render_picker_page( + inventory: Mapping[str, Any], + *, + page: int, + page_size: int, + style_enabled: bool, + compact: bool, + jump_number: int = 0, + columns: int | None = None, + now_ms: int | None = None, +) -> str: + """Render one page of the key-driven picker from its projected snapshot. + + ``sp list`` and the picker intentionally have different framing, but the + terminal layout, value rules, state words, ages, and colours now live in + this one module. The Bash picker supplies only navigation state. + """ + + bold = "\033[1m" + green = "\033[32m" + cyan = "\033[36m" + yellow = "\033[33m" + reset = "\033[0m" + + def styled(text: str, *codes: str) -> str: + if not style_enabled or not text: + return text + return f"{''.join(codes)}{text}{reset}" + + def key(row: Mapping[str, Any]) -> str: + provider = clean_text( + row.get("display_provider") or row.get("provider"), 40 + ).casefold() + return provider if provider in PROVIDER_BADGES else labels.PROVIDER_UNKNOWN + + def badge(row: Mapping[str, Any]) -> str: + return PROVIDER_BADGES[key(row)] + + def field(row: Mapping[str, Any], name: str, limit: int) -> str: + value = clean_text(row.get(name), limit) + return value or labels.MISSING + + def shortened(value: object, room: int) -> str: + return _display_title(value, max(1, room)) + + def aligned_shortened(value: str, room: int) -> str: + """Truncate without cleaning, so column padding survives.""" + return _truncate_cells(value, room) + + rows = [row for row in inventory.get("sessions", ()) if isinstance(row, Mapping)] + picker = inventory.get("_picker") + picker = picker if isinstance(picker, Mapping) else {} + machine_count = picker.get("machine_count", 0) + machine_needs = picker.get("machine_needs_you", 0) + machine_expanded = picker.get("machine_expanded") is True + machine_expandable = picker.get("machine_expandable_count", machine_count) + source_total = picker.get("source_total") + if isinstance(source_total, bool) or not isinstance(source_total, int): + source_total = len(rows) + query = clean_text(picker.get("query"), 4096) + ready = sum(row.get("availability") == labels.AVAILABILITY_READY for row in rows) + attached = sum( + row.get("availability") == labels.AVAILABILITY_ATTACHED for row in rows + ) + page_size = max(1, page_size) + pages = max(1, (len(rows) + page_size - 1) // page_size) + page = min(max(1, page), pages) + first = (page - 1) * page_size + selected = rows[first : first + page_size] + terminal_columns = columns or shutil.get_terminal_size(fallback=(100, 24)).columns + width = max(1, min(239, terminal_columns - 1)) + rendered_at = int(time.time() * 1000) if now_ms is None else now_ms + stall = stall_threshold_seconds() + lines: list[str] = [] + + if query: + match_word = labels.plural(len(rows), "match", "es") + session_word = labels.plural(source_total, labels.SESSION) + summary = ( + f"{len(rows)} {match_word} of {source_total} {session_word} · " + f"{ready} {labels.WHERE_READY} · " + f"{attached} {labels.WHERE_OPEN_ELSEWHERE}" + ) + lines.append(f" {styled(shortened(summary, width - 2), bold)}") + shown_query = ( + "one exact conversation" + if __import__("re").fullmatch( + r"(?:claude:|codex:)?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", + query.strip(), + ) + else query + ) + search = shortened(shown_query, width - len(" Search: ")) + lines.append(f" {styled('Search:', bold)} {search}") + elif rows: + session_word = labels.plural(len(rows), labels.SESSION) + summary = ( + f"{len(rows)} {session_word} · {ready} {labels.WHERE_READY} · " + f"{attached} {labels.WHERE_OPEN_ELSEWHERE}" + ) + lines.append(f" {styled(shortened(summary, width - 2), bold)}") + elif not isinstance(machine_count, int) or machine_count <= 0: + lines.append(f" {labels.SESSIONS_EMPTY}") + + if inventory.get("stale"): + lines.append( + " " + + styled( + shortened( + "Showing a cached list. Actions are off until it refreshes.", + width - 2, + ), + bold, + yellow, + ) + ) + if query and not selected: + lines.append(f" {labels.NO_MATCHES}") + + prepared: list[dict[str, Any]] = [] + for row in selected: + number = row.get("terminal_number") + if isinstance(number, bool) or not isinstance(number, int) or number <= 0: + number = labels.UNNUMBERED + state = labels.session_state(row, stall_seconds=stall) + projected_now = _timestamp(row.get("_picker_time_as_of_unix_ms")) + as_of = rendered_at if projected_now is None else projected_now + # One time, one phrasing, on every row. Rows used to choose between + # three shapes, `needs you 3 hr`, `12 min ago`, `opened 3 hr + # ago`, depending on what each row happened to know, so no two rows + # measured the same thing and nothing lined up. + age_detail = _last_active(row, as_of) + compact_age = labels.row_last_active_compact(row, as_of) + name_state = clean_text(row.get("automatic_name_state"), 20).casefold() + agents = int(row.get("active_subagent_count", len(row.get("subagents") or ()))) + agent_detail = "" + if agents: + agent_detail = labels.subagent_detail(agents) + details = [state, *([agent_detail] if agent_detail else []), age_detail] + if name_state in {labels.TITLE_STATE_PENDING, "failed"}: + details.append(f"name {name_state}") + branch = _worktree_branch(row) + if branch: + details.append(labels.worktree_detail(branch)) + # Narrowing drops the extras before it drops the time: state and "when + # did this last do something" are the two facts the row exists for. + compact_details = [ + state, + *([agent_detail] if agent_detail else []), + compact_age, + ] + prepared.append( + { + "row": row, + "number": number, + "title": clean_text(row.get("display_title") or row.get("title"), 120), + "provider": badge(row), + "account": shortened(field(row, "account_alias", 80), ACCOUNT_CELL_MAX), + "model": shortened(_model_cell(row), MODEL_CELL_MAX), + "state": state, + "subagents": agent_detail, + "compact_age": compact_age, + "details": details, + "compact": compact_details, + # The pair the row exists to carry: whose turn it is, and when + # it last did something. Narrowing takes the extras first and + # this last -- and the title yields before this does. + "essential": compact_details, + "status": [state, *([agent_detail] if agent_detail else [])], + "warning": state + in { + labels.QUESTION, + labels.WAITING_ON_YOU, + labels.SETUP_INCOMPLETE, + } + or name_state == "failed", + } + ) + + if prepared: + number_width = max(3, max(len(str(item["number"])) for item in prepared)) + provider_width = max(_display_width(item["provider"]) for item in prepared) + raw_account_width = _real_value_width((item["account"] for item in prepared)) + raw_model_width = _real_value_width((item["model"] for item in prepared)) + prefix_width = 4 + number_width + 2 + # The same account/model allocator as ``sp list``. Preserve room for + # an eight-cell title and four cells of state before dividing what is + # left between the two metadata columns. + field_budget = max( + 2, + width - prefix_width - provider_width - 12 - 8 - 4, + ) + account_width, model_width = _budget_field_widths( + raw_account_width, raw_model_width, field_budget + ) + account_width, model_width, available_width = _yield_to_title( + account_width, + model_width, + width - prefix_width - provider_width - account_width - model_width - 12, + ) + available_width = max(1, available_width) + ideal_title = max(_display_width(item["title"]) for item in prepared) + # Measured on the state word alone, so the time phrase after it begins + # at one column down the whole page. + state_width = max(_display_width(item["state"]) for item in prepared) + + def details_text(parts: list[str]) -> str: + if not parts: + return "" + primary = parts[0] + if len(parts) > 1: + primary = _pad(primary, state_width) + return labels.SEPARATOR.join((primary, *parts[1:])) + + def widest(kind: str) -> int: + return max( + (_display_width(details_text(item[kind])) for item in prepared), + default=0, + ) + + # The state word AND the one time, which is the pair the row exists to + # carry. The title yields to protect it. + # + # This was the review's blocking finding, and it deleted the time from + # every row at the width the operator actually works at. `label_width` + # was sized against the SHORTEST detail form, the state word alone, + # so the title grew until only that fit; the page-wide chooser then + # found the real details would not fit the room the title had just + # taken and fell all the way back to state-only, which carries no time. + # It was a one-character cliff: at 120 columns a 40-character session + # name kept the column and a 41-character one deleted it from the whole + # page. Sizing the title against the pair instead makes a long name cost + # its own tail, which is what a name should cost. + essential = widest("essential") + title_ceiling = available_width - 3 - essential + label_width = max(MIN_TITLE_CELLS, min(ideal_title, title_ceiling)) + + def room_for(label: int) -> int: + return max( + 1, + width + - prefix_width + - label + - provider_width + - account_width + - model_width + - 12, + ) + + # With the title already at its floor, the metadata columns give up + # room next. A shortened model still reads; a missing time does not. + account_width, model_width, _ = _yield_to_essential( + account_width, model_width, essential - room_for(label_width) + ) + # Last squeeze: a window so narrow that even the state word would be + # cut takes the metadata columns down to a single cell first. `wor…` + # tells a person nothing; `working` at least says whose turn it is. + if room_for(label_width) < essential: + account_width, model_width, _ = _yield_to_essential( + account_width, + model_width, + essential - room_for(label_width), + model_floor=1, + account_floor=1, + ) + # If the metadata is already at its one-cell floor, take the final few + # cells from the title before changing to a different row shape. Four + # cells retain a recognisable title fragment in the 60-column estate. + remaining = essential - room_for(label_width) + if remaining > 0: + responsive_floor = ( + RESPONSIVE_MIN_TITLE_CELLS if width >= 59 else MIN_TITLE_CELLS + ) + label_width = max(responsive_floor, label_width - remaining) + detail_room = room_for(label_width) + status_only = widest("essential") > detail_room + + if status_only: + # At the status-width floor, account/model/provider columns yield + # as a unit. This preserves the full state and every nonzero + # provider count. From 60 columns up, an identifiable compact time + # follows them; at the narrowest tier waiting-on-you wins first. + status_with_age = widest("essential") + status_kind = "essential" + status_room = width - prefix_width - 3 - 4 + if status_with_age > status_room: + status_kind = "status" + status_room = max(1, width - prefix_width - 3 - 4) + chosen_width = widest(status_kind) + label_width = max( + 1, + min( + ideal_title, + MIN_TITLE_CELLS, + width - prefix_width - 3 - chosen_width, + ), + ) + detail_room = max(1, width - prefix_width - label_width - 3) + + # Which form of the details every row shows, decided ONCE for the page. + # It used to be decided per row against the same room, so at one width + # a row saying `last active just now` kept its time while the row above + # it saying `last active 3h 9m ago`, one cell longer, lost it. That + # is the ragged column the operator was reading: not a column at all, + # just whatever each row happened to fit. + # `essential` is the LAST form, not `status`. A window too narrow for + # the whole pair gets it truncated rather than deleted, which is what + # `sp list` has always done, and the two surfaces gave different answers + # at the same width while this ladder ended in a state word alone. + forms = ( + ["compact", "essential"] + if compact + else [ + "details", + "compact", + "essential", + ] + ) + detail_form = forms[-1] + for candidate in forms: + if widest(candidate) <= detail_room: + detail_form = candidate + break + + previous_group: str | None = None + for item in prepared: + row = item["row"] + group = clean_text( + row.get("_picker_group_label"), 80 + ) or labels.group_heading(clean_text(row.get("availability"), 20)) + if not compact and group != previous_group: + lines.append(f" {styled(group, bold)}") + previous_group = group + title = shortened(item["title"], label_width) + # Measured before colouring: `_display_width` counts escape bytes. + padding = " " * max(0, label_width - _display_width(title)) + color = SESSION_PALETTE.get(clean_text(row.get("display_color"), 20)) + title = styled(title, color) if color else title + title += padding + if status_only: + detail_line = aligned_shortened( + details_text(item[status_kind]), detail_room + ) + if item["warning"]: + detail_line = styled(detail_line, bold, yellow) + number_text = f"{item['number']:>{number_width}}" + marker = " ▸ " if item["number"] == jump_number else " " + lines.append( + f"{marker}{styled(number_text, bold, green)} {title} | {detail_line}" + ) + continue + detail_line = aligned_shortened( + details_text(item[detail_form]), detail_room + ) + if item["warning"]: + detail_line = styled(detail_line, bold, yellow) + number_text = f"{item['number']:>{number_width}}" + marker = " ▸ " if item["number"] == jump_number else " " + provider_key = key(row) + provider = styled( + item["provider"], bold, PROVIDER_BADGE_COLORS[provider_key] + ) + account = _pad( + aligned_shortened(item["account"], account_width), account_width + ) + model = _pad(aligned_shortened(item["model"], model_width), model_width) + lines.append( + f"{marker}{styled(number_text, bold, green)} {title} | " + f"{provider} | {account} | {model} | {detail_line}" + ) + + if ( + page == 1 + and isinstance(machine_count, int) + and not isinstance(machine_count, bool) + and machine_count > 0 + ): + machine_line = ( + f"{machine_count} {labels.plural(machine_count, 'machine session')}" + ) + if isinstance(machine_needs, int) and machine_needs > 0: + machine_line += f" · {machine_needs} {labels.NEEDS_YOU}" + if ( + isinstance(machine_expandable, int) + and not isinstance(machine_expandable, bool) + and machine_expandable > 0 + ): + machine_line += " · x " + ("hide" if machine_expanded else "show") + lines.append(f" {styled(shortened(machine_line, width - 2), bold)}") + + if pages > 1: + directions = [] + if page > 1: + directions.append("prev") + if page < pages: + directions.append("next") + direction_text = " | ".join(styled(value, bold, cyan) for value in directions) + lines.append(f" {styled(f'Page {page}/{pages}', bold)} | {direction_text}") + return "\n".join(lines) + + +def render_detail( + inventory: Mapping[str, Any], + selector: str, + *, + home_factory: Callable[[], Any], + activity: Mapping[str, Any] | None = None, + now_ms: int | None = None, +) -> str: + """One session in full, for a person. + + `lookup` returns the row itself and is a machine mode: it carries the + shpool id, the conversation UUID, PIDs and start ticks. This is what `sp + detail` prints instead: everything a person can act on, and no identifier + they could paste anywhere. + """ + row = lookup(inventory, selector) + if row is None: + return labels.NO_MATCHING_SESSION + identity = row.get("identity") + identity = identity if isinstance(identity, Mapping) else {} + subagents = row.get("subagents") + subagents = subagents if isinstance(subagents, list) else [] + aged_children = row.get("aged_children") + aged_children = aged_children if isinstance(aged_children, list) else [] + activity = activity if isinstance(activity, Mapping) else {} + projected_now = _timestamp(now_ms) + as_of = projected_now if projected_now is not None else int(time.time() * 1000) + status = labels.session_state(row, stall_seconds=stall_threshold_seconds()) + is_waiting = status in { + labels.QUESTION, + labels.WAITING_ON_YOU, + labels.IDLE, + } + number = row.get("terminal_number") + child_fields: list[tuple[str, str]] = [] + for child in aged_children: + if not isinstance(child, Mapping): + continue + age = child.get("age_seconds") + if isinstance(age, bool) or not isinstance(age, int) or age < 60 * 60: + continue + title = clean_text(child.get("title"), 120) + kind = clean_text(child.get("kind"), 20).casefold() + if not title or kind not in { + labels.PROVIDER_SHELL, + labels.CHILD_KIND_WORKER, + }: + continue + child_fields.append( + ( + labels.DETAIL_CHILD_SHELL + if kind == labels.PROVIDER_SHELL + else labels.DETAIL_CHILD_WORKER, + f"{title}{labels.SEPARATOR}{labels.duration(age)}", + ) + ) + fields: list[tuple[str, str]] = [ + ( + labels.DETAIL_SESSION, + str(number) + if isinstance(number, int) and not isinstance(number, bool) and number > 0 + else labels.NOT_NUMBERED, + ), + ( + labels.DETAIL_TITLE, + clean_text(row.get("display_title") or row.get("title"), 120), + ), + ( + labels.DETAIL_PROVIDER, + _provider_name(row.get("display_provider") or row.get("provider")), + ), + (labels.DETAIL_ACCOUNT_ALIAS, clean_text(row.get("account_alias"), 20)), + (labels.DETAIL_ACCOUNT_EMAIL, clean_text(row.get("account_email"), 254)), + (labels.DETAIL_ACCOUNT_PLAN, clean_text(row.get("account_plan"), 80)), + (labels.DETAIL_MODEL, _model_cell(row)), + (labels.DETAIL_STATE, status), + (labels.DETAIL_WHERE, _where(row)), + ( + labels.DETAIL_LAST_RESPONSE, + _time_detail(activity.get("last_response_at_unix_ms"), as_of), + ), + ( + labels.DETAIL_WAITING_SINCE, + _time_detail( + activity.get("waiting_since_unix_ms") if is_waiting else None, + as_of, + ), + ), + (labels.DETAIL_OPENED, _time_detail(row.get("started_at_unix_ms"), as_of)), + # The list rows carry one time each; the process age lives here, where + # nothing else is competing for the line. + (labels.DETAIL_PROCESS_AGE, _format_age(row.get("process_age_seconds"))), + ( + labels.DETAIL_PROJECT, + _short_path(clean_text(row.get("cwd"), 4096), home_factory=home_factory), + ), + (labels.DETAIL_WORKTREE, _worktree_branch(row)), + ( + labels.DETAIL_COLOR, + clean_text(row.get("display_color") or row.get("color"), 40), + ), + ( + labels.DETAIL_CONVERSATION, + labels.CONVERSATION_EXACT + if identity.get("confidence") == labels.CONFIDENCE_EXACT + else labels.CONVERSATION_INEXACT, + ), + ( + labels.DETAIL_SUBAGENTS, + str(len(subagents)) if subagents else labels.NONE, + ), + *child_fields, + (labels.DETAIL_TITLE_STATE, _title_state(row)), + ] + lines = [f" {labels.DETAIL_HEADING}"] + width = max(len(label) for label, _ in fields) + for label, value in fields: + if value: + lines.append(f" {label.ljust(width)} {value}") + return "\n".join(lines) + "\n" + + +def lookup(inventory: Mapping[str, Any], selector: str) -> dict[str, Any] | None: + matches: list[dict[str, Any]] = [] + if selector.isdigit(): + number = int(selector) + sessions = list(inventory.get("sessions", ())) + if any( + isinstance(item.get("terminal_number"), int) + and not isinstance(item.get("terminal_number"), bool) + and item.get("terminal_number", 0) > 0 + for item in sessions + if isinstance(item, Mapping) + ): + matches = [ + item for item in sessions if item.get("terminal_number") == number + ] + else: + matches = [item for item in sessions if item.get("row") == number] + else: + matches = [ + item + for item in inventory.get("sessions", ()) + if (item.get("shpool_id_raw") or item.get("shpool_id")) == selector + ] + return matches[0] if len(matches) == 1 else None diff --git a/lib/sessionkit_inventory/self_name.py b/lib/sessionkit_inventory/self_name.py new file mode 100644 index 0000000..50c0117 --- /dev/null +++ b/lib/sessionkit_inventory/self_name.py @@ -0,0 +1,444 @@ +"""Automatic naming: prompt-derived titles and the self-name caller proof. + +Two entry points share this module. ``auto_title_from_hook`` turns a session's +first prompt into a title; ``self_name_automatic_title`` lets a managed root +name itself, but only after ``prove_self_name_caller`` has proved the caller is +the exact conversation it claims to be. + +That proof is a patch point the suite relies on, so the caller proof arrives +injected rather than resolved here: a self-naming path that could not be +intercepted in a test would be a poor thing to leave behind. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +from typing import Any, Callable, Mapping + +from .common import ( + PROVIDERS, + CollectionError, + automatic_naming_enabled, + proc_root as gated_proc_root, + valid_uuid, +) +from .processes import _process_ancestor_chain + + +# A conversation launched through the first-window color pre-bake opens as a +# resume, and Claude Code never auto-titles a resumed conversation (its +# once-only guard initializes from the loaded message count, which the +# resume-synthesized continuation pair makes non-zero). These sessions carry a +# unique signature: a synthetic isMeta user record holding the resume +# continuation text, written at load before the human's first prompt. For +# exactly those sessions the kit derives a title from the first real prompt +# and writes it through the same native records /rename persists. Sessions +# outside that signature are never touched, so Claude's own (better) auto +# titles are never masked. +RESUME_CONTINUATION_TEXT = "Continue from where you left off." + + +MAX_AUTO_TITLE_TRANSCRIPT_BYTES = 8 * 1024 * 1024 + + +_TITLE_TRAILING_STOPWORDS = frozenset( + "a an and any are at be but by each every for from how if in is it of on " + "or so that the then there this to was were what when which who why will " + "with".split() +) + +_MACHINE_PROMPT_PREFIXES = ( + "<cross-session-message", + "[session-kit operator message ", + "Session Kit initialized this managed worker.", + "You are a Session Kit delivery runner.", + "RUNTIME FOR THIS WAKE: You are a fixed Session Kit delivery bot.", +) + + +def derive_prompt_title(prompt: Any, *, stopwords: frozenset[str]) -> str | None: + """Derive a short display title from a first user prompt. + + Pure heuristic, no model call: first sentence, at most seven words, + trailing function words trimmed, sentence-cased, capped at 64 characters. + """ + if not isinstance(prompt, str): + return None + text = re.sub(r"\s+", " ", prompt).strip() + if ( + not text + or text.startswith("/") + or any(text.startswith(prefix) for prefix in _MACHINE_PROMPT_PREFIXES) + ): + return None + text = re.split(r"(?<=[.!?])\s", text, maxsplit=1)[0] + words = text.split(" ")[:7] + trimmed = list(words) + while trimmed and trimmed[-1].lower().strip("?,.:;!\"'") in stopwords: + trimmed.pop() + if not trimmed: + trimmed = words + title = " ".join(trimmed).rstrip(" ,;:.!?\"'") + if len(title) < 4: + return None + title = title[:64].rstrip() + return title[0].upper() + title[1:] + + +def _first_text_block(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + for block in content: + if isinstance(block, Mapping) and block.get("type") == "text": + text = block.get("text") + if isinstance(text, str): + return text + return "" + + +def auto_title_from_hook( + raw: Any, + *, + environ: Mapping[str, str] | None = None, + derive_title: Callable[..., str | None], + max_transcript_bytes: int, + resume_continuation_text: str, + propagate_title: Callable[..., dict[str, Any]], + name_owner: Callable[..., str], + claim_name: Callable[..., str], +) -> dict[str, Any]: + """Title a pre-baked Claude conversation from its first real prompt. + + Input is the UserPromptSubmit hook payload (JSON text). Eligibility is + strict and every miss fails open with title=null: the transcript must + carry the resume-continuation signature, no real conversation beyond the + triggering prompt, and no title of any kind (ai, /rename, agent-name, + kit name intent) may already exist. + + The name is claimed once, at the conversation's first prompt: a thread + whose name is already owned, by a person, or by the pass that ran at + that first prompt, is left exactly as it is. The hook fires twice for a + brand-new session (UserPromptSubmit, then Stop), and that claim is what + makes the second firing a no-op rather than a second rename. + """ + out: dict[str, Any] = {"title": None, "pushes": [], "warnings": []} + + def skip(reason: str) -> dict[str, Any]: + out["reason"] = reason + return out + + try: + payload = json.loads(raw if isinstance(raw, str) else "") + except ValueError: + return skip("hook payload is not valid JSON") + if not isinstance(payload, Mapping): + return skip("hook payload is not an object") + # UserPromptSubmit fires before the first turn is flushed to disk (the + # pre-bake signature is not yet visible then); Stop fires right after the + # first answer with everything durable. Supporting both means the title + # lands at the end of the first exchange. + if payload.get("hook_event_name") not in ("UserPromptSubmit", "Stop"): + return skip("not a UserPromptSubmit or Stop event") + uuid = valid_uuid(str(payload.get("session_id") or "")) + if not uuid: + return skip("missing or invalid session_id") + + env = environ if environ is not None else os.environ + owner = name_owner("claude", uuid, environ=env) + if owner == "human": + return skip("a human name owns this session") + if owner == "automatic": + return skip("an automatic name already owns this session") + home = Path(env.get("HOME") or os.fspath(Path.home())) + transcript_raw = payload.get("transcript_path") + if not isinstance(transcript_raw, str) or not transcript_raw: + return skip("missing transcript_path") + transcript = Path(transcript_raw) + projects = (home / ".claude" / "projects").resolve() + try: + resolved = transcript.resolve(strict=True) + except OSError: + return skip("transcript unavailable") + if ( + transcript.is_symlink() + or resolved.name != f"{uuid}.jsonl" + or resolved.parent.parent != projects + ): + return skip("transcript path is not this session's own transcript") + if (home / ".claude" / "sessions" / f"{uuid}.nameintent").exists(): + return skip("a kit name intent already exists") + try: + if resolved.stat().st_size > max_transcript_bytes: + return skip("transcript exceeds the bounded size") + lines = resolved.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return skip("transcript unreadable") + + signature = False + real_users = 0 + first_prompt: str | None = None + for line in lines: + try: + record = json.loads(line) + except ValueError: + continue + if not isinstance(record, Mapping): + continue + kind = record.get("type") + if kind in ("ai-title", "agent-name", "custom-title"): + return skip("the session already has a title") + if kind != "user": + continue + if record.get("isSidechain"): + continue + content = ( + record.get("message", {}).get("content") + if isinstance(record.get("message"), Mapping) + else None + ) + text = _first_text_block(content) + if record.get("isMeta"): + if text.startswith(resume_continuation_text): + signature = True + continue + # Tool results are stored as user-typed records too; only records + # carrying prompt text count as human prompts. + if isinstance(content, str) or text: + real_users += 1 + if first_prompt is None: + first_prompt = content if isinstance(content, str) else text + if not signature: + return skip("no pre-bake resume signature") + if real_users > 1: + return skip("conversation already has prior prompts") + # The conversation's own first prompt names the topic; the live payload + # prompt is the fallback for the pre-flush window. + title = derive_title(first_prompt) or derive_title(payload.get("prompt")) + if not title: + return skip("prompt does not yield a title") + + entry = json.dumps( + {"type": "ai-title", "aiTitle": title, "sessionId": uuid}, + separators=(",", ":"), + ) + try: + with open(resolved, "a", encoding="utf-8") as handle: + handle.write(entry + "\n") + handle.flush() + os.fsync(handle.fileno()) + out["pushes"].append("claude-transcript-ai-title") + except OSError as exc: + return skip(f"transcript not appended: {exc}") + # Claim before pushing: the record is what stops a second pass, and a + # push that half-succeeds must not leave the thread open to renaming. + claimed = claim_name("claude", uuid, environ=env) + if claimed: + out["warnings"].append(claimed) + pushed = propagate_title("claude", uuid, title, environ=env) + out["title"] = title + out["pushes"].extend(pushed.get("provider_title_pushes", [])) + out["warnings"].extend(pushed.get("provider_title_warnings", [])) + return out + + +def prove_self_name_caller( + inventory: Mapping[str, Any], + process_table: Mapping[int, Mapping[str, Any]], + environ: Mapping[str, str], + current_pid: int, +) -> dict[str, Any]: + """Prove one exact managed root conversation and its current shell generation.""" + if inventory.get("source") != "live" or inventory.get("stale") is not False: + raise CollectionError("automatic naming requires a fresh live inventory") + shpool_id = environ.get("SHPOOL_SESSION_NAME", "") + if not shpool_id: + raise CollectionError("automatic naming is unavailable outside a managed shell") + matches = [ + item + for item in inventory.get("sessions", ()) + if isinstance(item, Mapping) and item.get("shpool_id_raw") == shpool_id + ] + if len(matches) != 1: + raise CollectionError("automatic name caller session is ambiguous or stale") + item = matches[0] + provider = item.get("provider") + identity = item.get("identity") + shell = item.get("shpool_shell") + if ( + provider not in PROVIDERS + or item.get("setup_incomplete") is not False + or not isinstance(identity, Mapping) + or identity.get("confidence") != "exact" + or not isinstance(shell, Mapping) + ): + raise CollectionError("automatic name caller setup is incomplete") + uuid = valid_uuid(identity.get("uuid")) + provider_pid = identity.get("pid") + provider_start = identity.get("process_start_ticks") + shell_pid = shell.get("pid") + shell_start = shell.get("process_start_ticks") + if not uuid or any( + isinstance(value, bool) or not isinstance(value, int) or value <= 0 + for value in (provider_pid, provider_start, shell_pid, shell_start) + ): + raise CollectionError("automatic name caller lacks an exact generation") + chain = _process_ancestor_chain(process_table, current_pid) + if provider_pid not in chain or shell_pid not in chain: + raise CollectionError( + "automatic name caller is outside the exact provider root" + ) + if chain.index(provider_pid) >= chain.index(shell_pid): + raise CollectionError("automatic name caller ancestry is not a managed root") + if process_table[provider_pid].get("start_ticks") != provider_start: + raise CollectionError("automatic name provider generation changed") + if process_table[shell_pid].get("start_ticks") != shell_start: + raise CollectionError("automatic name shell generation changed") + for pid in chain[: chain.index(provider_pid) + 1]: + argv = process_table[pid].get("cmdline") or () + if "--parent-session-id" in argv or "--agent-name" in argv: + raise CollectionError("automatic naming is refused for a subagent") + if provider == "codex": + caller_uuid = valid_uuid(environ.get("CODEX_THREAD_ID")) + if any( + isinstance(child, Mapping) + and child.get("provider") == "codex" + and valid_uuid(child.get("uuid")) == caller_uuid + for child in item.get("subagents", ()) + ): + raise CollectionError("automatic naming is refused for a subagent") + if caller_uuid != uuid: + raise CollectionError("Codex caller is not the managed root conversation") + else: + claude_uuid = environ.get("CLAUDE_SESSION_ID") + if claude_uuid and valid_uuid(claude_uuid) != uuid: + raise CollectionError("Claude caller is not the managed root conversation") + return { + "provider": provider, + "uuid": uuid, + "shpool_id": shpool_id, + "provider_pid": provider_pid, + "provider_start_ticks": provider_start, + "shell_pid": shell_pid, + "shell_start_ticks": shell_start, + } + + +def self_name_automatic_title( + config: Mapping[str, Any], + title: str, + *, + inventory: Mapping[str, Any] | None = None, + process_table: Mapping[int, Mapping[str, Any]] | None = None, + environ: Mapping[str, str] | None = None, + current_pid: int | None = None, + default_max_proc_nodes: int, + record_retry: Callable[..., None], + canonical_colors: Callable[[Mapping[str, Any]], dict[str, str]], + mutate_self_name: Callable[..., Any], + normalize_title: Callable[..., str], + process_table_reader: Callable[[Path, int], dict[int, dict[str, Any]]], + propagate_color: Callable[..., dict[str, Any]], + propagate_title: Callable[..., dict[str, Any]], + prove_caller: Callable[..., dict[str, Any]], + record_title_failure: Callable[..., Any], + session_color: Callable[..., str | None], + snapshot_inventory: Callable[..., dict[str, Any]], +) -> dict[str, Any]: + """Provider-neutral exact self-name entry point used by root agents.""" + caller_environ = environ if environ is not None else os.environ + if not automatic_naming_enabled(caller_environ): + raise CollectionError("automatic naming is disabled") + proc_root = gated_proc_root(caller_environ) + supplied_evidence = inventory is not None or process_table is not None + live = inventory or snapshot_inventory(write_state=False, config=dict(config)) + table = process_table or process_table_reader( + proc_root, int(config.get("max_proc_nodes", default_max_proc_nodes)) + ) + pid = current_pid if current_pid is not None else os.getpid() + evidence = prove_caller(live, table, caller_environ, pid) + + def revalidate() -> None: + # Runs INSIDE the name-store write locks. A fresh collection here is + # forbidden: collecting can itself take config.lock through a second + # descriptor, and a process cannot pass its own flock on a new handle: + # the self-name that jammed every collector on the machine for + # twenty minutes on 2026-08-17 was exactly this call re-collecting + # under the lock. The caller proof is repeated against a fresh + # process table (lock-free, and the part that can actually change: + # pid death and reuse); the inventory evidence stays the snapshot + # taken moments ago, which the held lock now protects from writers. + if supplied_evidence: + new_table = table + else: + new_table = process_table_reader( + proc_root, + int(config.get("max_proc_nodes", default_max_proc_nodes)), + ) + repeated = prove_caller(live, new_table, caller_environ, pid) + if repeated != evidence: + raise CollectionError("automatic name caller changed before write") + + try: + normalized = normalize_title(title) + except CollectionError as reason: + attempts = record_title_failure( + config, + str(evidence["provider"]), + str(evidence["uuid"]), + revalidate=revalidate, + ) + suffix = "name failed" if attempts >= 2 else "name pending" + raise CollectionError( + f"automatic title rejected ({reason}); {suffix}" + ) from reason + # A self-name owns both display tiers. Write them in one transaction so a + # repeated rename cannot trip over the alias created by the previous one. + result = mutate_self_name( + config, + str(evidence["provider"]), + str(evidence["uuid"]), + normalized, + revalidate=revalidate, + ) + result["caller"] = evidence + # A self-name is an explicit rename, not background bookkeeping: it also + # becomes the alias, the top display tier. In the automatic tier alone it + # lost to the provider's own ai-title, so the picker kept showing a stale + # native name after an agent explicitly renamed itself. `sp name reset` + # still demotes it like any alias. + native_push = propagate_title( + str(evidence["provider"]), + str(evidence["uuid"]), + normalized, + environ=caller_environ, + ) + result.update(native_push) + if native_push.get("provider_title_warnings"): + record_retry( + config, + str(evidence["provider"]), + str(evidence["uuid"]), + normalized, + ) + result["automatic_name_state"] = "pending" + else: + result["automatic_name_state"] = "ready" + effective_color = session_color( + str(evidence["provider"]), + str(evidence["uuid"]), + canonical_colors(config), + ) + if effective_color: + result.update( + propagate_color( + str(evidence["provider"]), + str(evidence["uuid"]), + effective_color, + environ=caller_environ, + ) + ) + return result diff --git a/lib/sessionkit_inventory/session_model.py b/lib/sessionkit_inventory/session_model.py new file mode 100644 index 0000000..73f1705 --- /dev/null +++ b/lib/sessionkit_inventory/session_model.py @@ -0,0 +1,553 @@ +"""Which model a session is running RIGHT NOW, and its name for a person. + +The launch argument is not the answer. `--model` says what a session was +started with; a person who types `/model` inside the session changes what it +actually runs and never touches the command line again. A picker that reads +argv therefore shows the wrong model for the rest of that session's life, and +says it with total confidence. + +The conversation's own record is the answer. Claude writes `message.model` on +every assistant record it appends; Codex writes `turn_context.model` at the +start of every turn and a `thread_settings_applied` event when the setting +changes. Both are appended by the provider itself, both move when the person +moves the model, and the LAST one in the file is what the session is on. + +Reading that cheaply is the whole problem. Transcripts here run to 34 MB, and +a tail is not enough: one live transcript on this machine has 45,019 +`agent-color` records after its last assistant record, pushing the model +4.2 MB back from the end. So the read is done once and then never repeated: +a small cache per conversation remembers the offset already scanned, and every +later refresh reads only the bytes the provider has appended since. Steady +state is one stat plus a few kilobytes; a first sighting is one bounded +backward scan. + +Nothing here is required for a row to render. Every failure path returns an +empty string, and the caller says so in the cell rather than guessing. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import stat +import time +from typing import Any, Iterable, Mapping + +from .common import clean_text + + +SCHEMA_VERSION = 1 +CACHE_DIRECTORY = "model" + +# The first backward window, per provider, chosen from measurement rather than +# taste, and the two providers are nothing alike. +# +# Claude appends `message.model` on every assistant record, so the newest one is +# usually near the end: 11 of 16 live transcripts here carry it within 48 KB. +# +# Codex writes `turn_context` ONCE at the start of a turn and then hundreds of +# kilobytes of events behind it, so the newest model record is always far back. +# Measured on the 12 newest live rollouts: **12 of 12** sit beyond 64 KB, from +# 249 KB to 1.43 MB. Starting Codex at 64 KB meant every first sighting paid +# 64 KB + 512 KB + 4 MB of escalating reads to find something a single 2 MB +# read finds outright. +FIRST_SCAN_BYTES = {"claude": 64 * 1024, "codex": 2 * 1024 * 1024} +DEFAULT_FIRST_SCAN_BYTES = 64 * 1024 +# The ceiling on one backward scan. The worst live case measured was 4.25 MB; +# this is four times that, and a file whose model sits further back reads as +# unknown rather than as a slow picker. +MAX_SCAN_BYTES = 16 * 1024 * 1024 + +# How long a "this machine has no record of that conversation" answer is +# trusted before looking again. Short enough that a session whose transcript +# has just appeared is picked up while a person is still reading the screen. +MISS_RETRY_SECONDS = 15.0 + +# The byte a candidate line must contain before it is worth parsing. Both +# providers spell the key the same way, and the escaped form a tool argument +# would produce (`\"model\"`) does not match. +MODEL_MARKER = b'"model"' + +CODEX_TURN_MARKER = b'"turn_context"' +CODEX_SETTINGS_MARKER = b'"thread_settings_applied"' + +SOURCE_TRANSCRIPT = "transcript" +SOURCE_LAUNCH = "launch argument" + + +# --------------------------------------------------------------- human names + +CLAUDE_FAMILY_NAMES = { + "opus": "Opus", + "sonnet": "Sonnet", + "haiku": "Haiku", + "fable": "Fable", +} + +# The non-numeric words a Codex identifier may carry, and how the vendor writes +# each of them. A token that is not here means this rule does not understand the +# identifier, and an identifier it does not understand keeps its raw form. +CODEX_KNOWN_TOKENS = { + "codex": "Codex", + "sol": "Sol", + "max": "Max", + "mini": "Mini", + "nano": "Nano", + "preview": "Preview", + "turbo": "Turbo", + "pro": "Pro", +} + + +def _is_release_date(token: str) -> bool: + """`20250929` in `claude-sonnet-4-5-20250929`, a build stamp, not a name.""" + return len(token) == 8 and token.isdigit() + + +def _claude_name(model_id: str) -> str: + """`claude-sonnet-4-5-20250929` becomes `Sonnet 4.5`. + + Every token must be accounted for. This used to skip whatever it did not + recognise, so `claude-opus-experimental-5` rendered as `Opus 5`, a + different model's name, indistinguishable on screen from the real one. + A name is only produced when the identifier is fully understood; anything + else keeps its raw identifier, which is ugly and true. + """ + body = model_id[len("claude-") :] if model_id.startswith("claude-") else model_id + family = "" + version: list[str] = [] + for token in body.split("-"): + if not token or _is_release_date(token): + continue + folded = token.casefold() + if not family and folded in CLAUDE_FAMILY_NAMES: + family = CLAUDE_FAMILY_NAMES[folded] + elif token.replace(".", "").isdigit(): + version.append(token) + else: + return "" + if not family: + return "" + return f"{family} {'.'.join(version)}".strip() + + +def _codex_name(model_id: str) -> str: + """`gpt-5-codex` becomes `GPT-5-Codex`. + + Derived rather than looked up. Codex caches the vendor's own display names + in `models_cache.json`, but that file is 346 KB on this machine and this + rule reproduces its `display_name` exactly for the models actually in use, + so a third of a megabyte is not read on every refresh to learn nothing. + + Every token must be one this rule recognises. Title-casing an unfamiliar + identifier produced a confident-looking name for a model nobody had heard + of, which reads exactly like an authoritative one. + """ + parts: list[str] = [] + for token in model_id.split("-"): + if not token: + continue + folded = token.casefold() + if folded == "gpt" or ( + folded.startswith("gpt") and token[3:].replace(".", "").isdigit() + ): + parts.append("GPT" + token[3:]) + elif folded in {"o1", "o3", "o4"}: + parts.append(token.upper()) + elif token.replace(".", "").isdigit(): + parts.append(token) + elif folded in CODEX_KNOWN_TOKENS: + parts.append(CODEX_KNOWN_TOKENS[folded]) + else: + return "" + return "-".join(parts) + + +def human_model_name(provider: object, model_id: object) -> str: + """The model's product name, or the raw identifier when it has no rule.""" + text = clean_text(model_id, 80) + if not text: + return "" + family = clean_text(provider, 20).casefold() + if family == "claude" or text.casefold().startswith("claude-"): + return _claude_name(text) or text + if family == "codex" or text.casefold().startswith(("gpt", "o1", "o3", "o4")): + return _codex_name(text) or text + return text + + +# ------------------------------------------------------------- record readers + + +def _claude_model_from_line(line: bytes) -> str: + try: + record = json.loads(line) + except (UnicodeDecodeError, ValueError): + return "" + if not isinstance(record, Mapping): + return "" + message = record.get("message") + if not isinstance(message, Mapping): + return "" + model = clean_text(message.get("model"), 80) + # Claude writes assistant records it generated itself -- an API failure + # notice, a cancelled turn -- with the literal model `<synthetic>`. Read + # naively, one of those at the end of a transcript renames the session's + # model to `<synthetic>` on the picker. Measured on a live transcript here. + if model.startswith("<"): + return "" + return model + + +def _codex_model_from_line(line: bytes) -> str: + try: + record = json.loads(line) + except (UnicodeDecodeError, ValueError): + return "" + if not isinstance(record, Mapping): + return "" + payload = record.get("payload") + if not isinstance(payload, Mapping): + return "" + if record.get("type") == "turn_context": + return clean_text(payload.get("model"), 80) + if payload.get("type") == "thread_settings_applied": + settings = payload.get("thread_settings") + if isinstance(settings, Mapping): + return clean_text(settings.get("model"), 80) + return "" + + +def _model_from_line(provider: str, line: bytes) -> str: + if provider == "claude": + return _claude_model_from_line(line) + if provider == "codex": + return _codex_model_from_line(line) + return "" + + +def _interesting(provider: str, line: bytes) -> bool: + if MODEL_MARKER not in line: + return False + if provider == "codex": + return CODEX_TURN_MARKER in line or CODEX_SETTINGS_MARKER in line + return True + + +def newest_model(provider: str, block: bytes) -> str: + """The last model recorded in one whole-line block, or ''.""" + for line in reversed(block.split(b"\n")): + if not line or not _interesting(provider, line): + continue + found = _model_from_line(provider, line) + if found: + return found + return "" + + +def _read_aligned( + descriptor: int, start: int, size: int, *, aligned: bool +) -> tuple[bytes, int]: + """Whole lines from ``start`` to ``size``, and the offset they end on. + + A block that begins mid-record would be parsed as garbage, so a start the + caller cannot vouch for drops its first partial line. ``aligned`` says the + caller already knows this offset is a line boundary, a resumed read starts + exactly where the last one stopped, and dropping a line there would skip a + whole record the provider had just appended. + + The returned offset is the boundary after the last complete line, and it is + absolute: it is what the next resumed read starts from. + """ + length = max(0, size - start) + if length <= 0: + return b"", start + try: + raw = os.pread(descriptor, length, start) + except OSError: + return b"", start + if not raw: + return b"", start + if start and not aligned: + boundary = raw.find(b"\n") + if boundary < 0: + return b"", start + start += boundary + 1 + raw = raw[boundary + 1 :] + end = raw.rfind(b"\n") + if end < 0: + return b"", start + return raw[: end + 1], start + end + 1 + + +def scan_backward(descriptor: int, provider: str, size: int) -> tuple[str, int]: + """The newest model in a bounded window at the end of the file. + + Returns the model and the offset scanned to, so the caller can record how + much of the file it has already accounted for. + """ + window = FIRST_SCAN_BYTES.get(provider, DEFAULT_FIRST_SCAN_BYTES) + scanned_to = 0 + while True: + start = max(0, size - window) + block, end_offset = _read_aligned(descriptor, start, size, aligned=start == 0) + if block: + scanned_to = end_offset + found = newest_model(provider, block) + if found: + return found, scanned_to + if start == 0 or window >= MAX_SCAN_BYTES: + # `scanned_to` stays 0 when the file held no complete line yet -- + # a first assistant record caught mid-write. Claiming the file size + # instead parked the resume point in the middle of that record, and + # once it completed the reader started reading from its middle and + # could never parse it: the conversation said `no reply yet` + # forever, but only with a cache. Nothing scanned, nothing claimed. + return "", scanned_to + window = min(window * 8, MAX_SCAN_BYTES) + + +# -------------------------------------------------------------------- cache + + +def prune_cache( + cache_dir: object, + live_keys: Iterable[object] | None, + *, + now: float | None = None, +) -> int: + """Drop cache records for conversations that are gone. + + Everything else under the state directory has a reaper; this did not, and + it writes one small file per conversation the kit has ever read a model for + including a `missed_at` marker for every session whose transcript could + not be found. Small each, unbounded together, over the life of an install. + + A record is kept while its conversation is live, and for a week after it + stops appearing: a closed conversation can be restored, and re-reading a + 34 MB transcript from scratch is the cost of forgetting too eagerly. + """ + root = Path(str(cache_dir)) / CACHE_DIRECTORY if cache_dir else None + if root is None or not root.is_dir(): + return 0 + keep = set() + for provider in ("claude", "codex"): + for key in live_keys or (): + exact = clean_text(key, 64) + if exact: + keep.add(cache_path(Path(str(cache_dir)), provider, exact).name) + moment = time.time() if now is None else now + retain_seconds = 7 * 86400 + removed = 0 + try: + entries = list(root.iterdir()) + except OSError: + return 0 + for path in entries: + if path.name in keep or not path.name.endswith(".json"): + continue + try: + info = path.lstat() + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid(): + continue + if moment - info.st_mtime < retain_seconds: + continue + path.unlink() + except OSError: + continue + removed += 1 + return removed + + +def cache_path(cache_dir: Path, provider: str, key: str) -> Path: + digest = hashlib.blake2b( + f"{provider}\0{key}".encode("utf-8", "replace"), digest_size=16 + ).hexdigest() + return Path(cache_dir) / CACHE_DIRECTORY / f"{digest}.json" + + +def _read_cache(path: Path) -> dict[str, Any] | None: + try: + with open(path, encoding="utf-8") as handle: + record = json.load(handle) + except (OSError, ValueError): + return None + if not isinstance(record, Mapping): + return None + if record.get("schema_version") != SCHEMA_VERSION: + return None + missed_at = record.get("missed_at") + if isinstance(missed_at, (int, float)) and not isinstance(missed_at, bool): + return {"missed_at": float(missed_at)} + for name in ("inode", "device", "scanned_to", "ctime_ns"): + value = record.get(name) + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + return None + model = record.get("model") + transcript = record.get("transcript") + if not isinstance(model, str) or not isinstance(transcript, str): + return None + if not transcript.startswith("/"): + return None + return { + "inode": record["inode"], + "device": record["device"], + "ctime_ns": record["ctime_ns"], + "scanned_to": record["scanned_to"], + "model": clean_text(model, 80), + "transcript": transcript, + } + + +def _write_cache(path: Path, record: Mapping[str, Any]) -> None: + """Best effort. A cache that cannot be written costs speed, never truth.""" + if "missed_at" in record: + payload: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "missed_at": float(record["missed_at"]), + } + else: + payload = { + "schema_version": SCHEMA_VERSION, + "inode": int(record["inode"]), + "device": int(record["device"]), + "ctime_ns": int(record["ctime_ns"]), + "scanned_to": int(record["scanned_to"]), + "model": clean_text(record.get("model"), 80), + "transcript": str(record["transcript"]), + } + try: + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + temporary = path.with_name(f"{path.name}.tmp.{os.getpid()}") + descriptor = os.open( + temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600 + ) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(payload, handle, sort_keys=True) + handle.write("\n") + os.replace(temporary, path) + except OSError: + try: + os.unlink(temporary) # type: ignore[possibly-undefined] + except (OSError, NameError, UnboundLocalError): + pass + + +def _open(path: object) -> tuple[int, Any] | None: + if not path: + return None + try: + descriptor = os.open(Path(str(path)), os.O_RDONLY | os.O_NOFOLLOW) + except OSError: + return None + try: + return descriptor, os.fstat(descriptor) + except OSError: + os.close(descriptor) + return None + + +def current_model( + provider: object, + *, + key: str, + locate: Any, + cache_dir: object = None, +) -> tuple[str, bool]: + """The model this conversation runs now, and whether its record was found. + + ``locate`` is called only when the cache cannot answer where the record + is. That matters more than it looks: finding a Codex rollout means walking + a sessions tree of several hundred files, and finding a Claude transcript + means globbing every profile root, so doing it per session per refresh is + the expensive part of this, not the reading. + + ``cache_dir`` is what makes the reading cheap: with one, a conversation is + scanned backward once and afterwards only the bytes the provider has + appended since are read. Without one, every call is a bounded backward + scan, which is correct and slower. + """ + family = clean_text(provider, 20).casefold() + if family not in {"claude", "codex"}: + return "", False + cache_file = None + record = None + if cache_dir and key: + cache_file = cache_path(Path(str(cache_dir)), family, key) + record = _read_cache(cache_file) + if record is not None and "missed_at" in record: + # A conversation with no record on this disk. Looking for one means + # globbing every Claude profile root or walking a Codex sessions tree + # of several hundred files -- measured at 12 ms, on every refresh, for + # a row that is going to say the same thing each time. Remember the + # miss briefly instead; a session whose transcript appears gets picked + # up on the next look, seconds later. + if 0 <= time.time() - record["missed_at"] < MISS_RETRY_SECONDS: + return "", False + record = None + opened = None + resumable = False + if record is not None: + opened = _open(record["transcript"]) + if opened is not None: + info = opened[1] + # The inode-change time is part of the identity because an inode + # is NOT: a transcript deleted and recreated at the same path is + # routinely handed the same inode number, and (inode, device) + # alone accepted the new file as the old one and answered from the + # cache -- a model name for a file that no longer exists, printed + # as fact. Measured on this filesystem, not imagined. + resumable = ( + record["inode"] == info.st_ino + and record["device"] == info.st_dev + and record["ctime_ns"] == info.st_ctime_ns + and record["scanned_to"] <= info.st_size + ) + if not resumable: + os.close(opened[0]) + opened = None + transcript = record["transcript"] if resumable and record else None + if opened is None: + transcript = locate() if callable(locate) else locate + opened = _open(transcript) + if opened is None: + if cache_file is not None: + _write_cache(cache_file, {"missed_at": time.time()}) + return "", False + descriptor, info = opened + try: + size = info.st_size + if size <= 0: + return "", True + if resumable and record is not None: + if record["scanned_to"] == size: + return record["model"], True + block, end_offset = _read_aligned( + descriptor, record["scanned_to"], size, aligned=True + ) + model = record["model"] + scanned_to = record["scanned_to"] + if block: + scanned_to = end_offset + found = newest_model(family, block) + if found: + model = found + else: + model, scanned_to = scan_backward(descriptor, family, size) + if cache_file is not None and transcript: + _write_cache( + cache_file, + { + "inode": info.st_ino, + "device": info.st_dev, + "ctime_ns": info.st_ctime_ns, + "scanned_to": scanned_to, + "model": model, + "transcript": os.fspath(transcript), + }, + ) + return model, True + finally: + os.close(descriptor) diff --git a/lib/sessionkit_inventory/snapshot.py b/lib/sessionkit_inventory/snapshot.py new file mode 100644 index 0000000..6baec7b --- /dev/null +++ b/lib/sessionkit_inventory/snapshot.py @@ -0,0 +1,470 @@ +"""Collection and private-state orchestration: one exact refresh, or a fallback. + +This module owns the order of a refresh rather than any of its steps. It takes a +live collection, and if that collection is complete it publishes the inventory, +the terminal-number registry and the recovery manifest under one lock, in the +order those files have to be written for a crash between any two of them to +leave usable state behind. If the collection is incomplete it serves the last +known good cache instead, marked stale and carrying the reason. + +Nothing here reads a process table, a rollout, or a provider database. Every +step arrives as a call-time argument, the collector, the boot identity, the +state reader and publisher, the terminal helpers, the recovery transaction, the +lifecycle passes, so this file can be read as the sequence itself, and so a +facade patch on any single step still reaches the refresh under test. + +The two modes are not symmetrical and the asymmetry is the point. ``write_state`` +takes the lock, re-reads the cache inside it, and allocates terminal numbers. +``--no-write`` does none of that: it must not create a state directory or a lock +file merely to inspect existing state, because deployment validation runs it +against installations it is not allowed to modify. +""" + +from __future__ import annotations + +import inspect +from pathlib import Path +from typing import Any, Callable, Iterable, Mapping, Protocol + +from .common import CollectionError, _utc_now, clean_text + + +class ProviderUntitledQuarantine(Protocol): + def __call__( + self, + config: Mapping[str, Any], + inventory: Mapping[str, Any], + *, + retire_generations: Iterable[tuple[str, str]] = (), + ) -> list[dict[str, str]]: ... + + +def _cold_inventory(error: str, *, schema_version: int) -> dict[str, Any]: + return { + "schema_version": schema_version, + "generated_at": _utc_now(), + "source": "cold", + "stale": True, + "warnings": [clean_text(error, 400)], + "daemon_generation": None, + "sessions": [], + "outside_agents": [], + } + + +def _accepts_keyword(callback: Callable[..., Any], name: str) -> bool: + """Whether an injected callback accepts one post-v1 optional keyword.""" + try: + parameters = inspect.signature(callback).parameters.values() + except (TypeError, ValueError): + return True + return any( + parameter.kind == inspect.Parameter.VAR_KEYWORD + or ( + parameter.name == name + and parameter.kind + in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) + ) + for parameter in parameters + ) + + +def snapshot( + *, + write_state: bool = True, + config: dict[str, Any] | None = None, + schema_version: int, + load_config: Callable[[], dict[str, Any]], + state_paths: Callable[[Mapping[str, Any]], dict[str, Path]], + state_lock: Callable[..., Any], + read_state_json: Callable[[Path], Any], + collect_live: Callable[[Mapping[str, Any]], dict[str, Any]], + boot_id_factory: Callable[[], str | None], + persist_last_exact: Callable[..., Any], + apply_provider_exit_states: Callable[..., Any], + prune_inactive_state: Callable[..., Any], + read_terminal_registry: Callable[..., dict[str, Any]], + read_terminal_retirements: Callable[[Path, str], dict[int, float]], + apply_terminal_numbers: Callable[..., dict[str, Any]], + terminal_retirement_payload: Callable[[Mapping[int, float], str], dict[str, Any]], + atomic_write_json: Callable[[Path, Any], None], + quarantine_orphaned_provider_untitled_markers: ProviderUntitledQuarantine, + update_recovery_state: Callable[[Mapping[str, Path], Mapping[str, Any]], None], + enqueue_lost_conversations: Callable[..., list[str]], + apply_session_origins: Callable[..., None], + capture_bounce_receipts: Callable[[Path], frozenset[str]], + capture_bounce_cleanup_generations: Callable[[Path], frozenset[tuple[str, str]]], + capture_lifecycle_generations: Callable[[Path], frozenset[tuple[str, str]]], + capture_origin_generations: Callable[[Path], frozenset[tuple[str, str]]], + capture_provider_untitled_generations: Callable[[Path], frozenset[tuple[str, str]]], + capture_session_color_generations: Callable[[Path], frozenset[tuple[str, str]]], + prune_origins: Callable[..., int], + publish_session_colors: Callable[..., int], + cold_inventory: Callable[[str], dict[str, Any]], + allocate_collection_start: Callable[[Mapping[str, Path]], tuple[int, str | None]] + | None = None, + preflight_collection_documents: Callable[ + [Mapping[str, Path], tuple[str, ...], int | None], tuple[list[str], list[str]] + ] + | None = None, + write_collection_json: Callable[..., None] | None = None, + prove_pending_losses: Callable[[Any, tuple[str, ...]], None] | None = None, + # Optional: a caller assembled before the model cache existed still works, + # and simply does not reap it. + prune_model_cache: Callable[..., Any] | None = None, + apply_session_idle_states: Callable[..., Any] | None = None, +) -> dict[str, Any]: + # Keep the callback-injection seam used by tests and facades, while older + # callers that predate publication ordering get the production behavior + # instead of failing on newly required keyword arguments. + if ( + allocate_collection_start is None + or preflight_collection_documents is None + or write_collection_json is None + ): + from . import state_io + + allocate_collection_start = ( + allocate_collection_start or state_io.allocate_collection_start + ) + preflight_collection_documents = ( + preflight_collection_documents or state_io.preflight_collection_documents + ) + write_collection_json = write_collection_json or state_io.write_collection_json + if prove_pending_losses is None: + from .recovery import prove_pending_losses as prove_pending_document + + def prove_pending_losses(value: Any, expected: tuple[str, ...]) -> None: + prove_pending_document( + value, + expected, + schema_version=schema_version, + ) + + settings = config or load_config() + paths = state_paths(settings) + collection_start: int | None = None + collection_diagnostic: str | None = None + publish_state = write_state + + # The inventory cache is a comparison input and display fallback; nothing + # overwrites recovery evidence based on it, so a corrupt cache reads as + # absent here and the refresh proceeds to the allocation refusal instead + # of crashing. Every other state read stays strict: an unreadable pending + # queue must raise before anything could replace its bytes. + def _cached_or_none() -> Any: + try: + return read_state_json(paths["inventory"]) + except (OSError, ValueError): + return None + + if write_state: + with state_lock(paths["root"], paths["lock"]): + cached = _cached_or_none() + try: + collection_start, collection_diagnostic = allocate_collection_start( + paths + ) + except CollectionError as exc: + publish_state = False + collection_diagnostic = str(exc) + else: + # --no-write is also useful in deployment validation. Do not create a + # state directory or lock file merely to inspect existing state. + cached = _cached_or_none() + # A receipt name is a bounce generation. Capture the generations before + # process collection so a later positive window sighting can settle only + # receipts that are not newer than that sighting. Read-only snapshots + # never settle state and need no generation capture. + bounce_receipts = ( + capture_bounce_receipts(paths["root"]) if publish_state else frozenset() + ) + bounce_cleanup_generations = ( + capture_bounce_cleanup_generations(paths["root"]) + if publish_state + else frozenset() + ) + lifecycle_generations = ( + capture_lifecycle_generations(paths["root"]) if publish_state else frozenset() + ) + origin_generations = ( + capture_origin_generations(paths["root"]) if publish_state else frozenset() + ) + provider_untitled_generations = ( + capture_provider_untitled_generations(paths["root"]) + if publish_state + else frozenset() + ) + session_color_generations = ( + capture_session_color_generations(paths["root"]) + if publish_state + else frozenset() + ) + try: + live = collect_live(settings) + except CollectionError as exc: + live = None + failure = str(exc) + else: + assert live is not None + failure = "; ".join(live.get("warnings", ())) + complete = bool(live and live.pop("_complete", True)) + if complete and live is not None: + result: dict[str, Any] = live + if collection_start is not None: + result["collection_start"] = {"sequence": collection_start} + try: + boot_id = boot_id_factory() + if not boot_id: + raise CollectionError("current boot identity is unavailable") + if publish_state: + with state_lock(paths["root"], paths["lock"]): + locked_cached = read_state_json(paths["inventory"]) + document_keys = ( + "inventory", + "terminal_numbers", + "terminal_numbers_retired", + "terminal_numbers_epoch", + "manifest", + "pending", + ) + refused, diagnostics = preflight_collection_documents( + paths, document_keys, collection_start + ) + if refused: + message = ( + f"refused stale collection {collection_start}; newer " + f"published state stands: {', '.join(sorted(refused))}" + ) + retained = ( + dict(locked_cached) + if isinstance(locked_cached, Mapping) + else dict(result) + ) + retained_warnings = list(retained.get("warnings", ())) + retained_warnings.append(message) + retained["warnings"] = retained_warnings + return retained + if collection_diagnostic: + diagnostics.insert(0, collection_diagnostic) + if diagnostics: + warnings = list(result.get("warnings", ())) + warnings.extend(diagnostics) + result["warnings"] = warnings + if apply_session_idle_states is not None: + # Transcript movement is differential evidence. Compare + # against the exact inventory still protected by this + # publication lock, not the optimistic pre-collection + # read which another refresh may have superseded. + apply_session_idle_states( + result, + ( + locked_cached + if isinstance(locked_cached, Mapping) + else None + ), + state_dir=paths["root"], + ) + persist_last_exact( + result, + (locked_cached if isinstance(locked_cached, Mapping) else None), + state_dir=paths["root"], + boot_id=boot_id, + ) + apply_provider_exit_states( + result, + (locked_cached if isinstance(locked_cached, Mapping) else None), + state_dir=paths["root"], + boot_id=boot_id, + ) + # Who asked for each session, before anything decides what + # to show. An unstamped session belongs to the person. + apply_session_origins( + result, + state_dir=paths["root"], + settle_bounce_receipts=bounce_receipts, + ) + registry = read_terminal_registry( + paths["terminal_numbers"], + boot_id, + paths["terminal_numbers_epoch"], + ) + retired_numbers = read_terminal_retirements( + paths["terminal_numbers_retired"], boot_id + ) + updated_registry = apply_terminal_numbers( + result, + registry, + boot_id=boot_id, + allocate=True, + retired=retired_numbers, + ) + write_collection_json( + paths, + paths["terminal_numbers"], + updated_registry, + collection_start=collection_start, + ) + write_collection_json( + paths, + paths["terminal_numbers_retired"], + terminal_retirement_payload(retired_numbers, boot_id), + collection_start=collection_start, + ) + write_collection_json( + paths, + paths["terminal_numbers_epoch"], + { + "schema_version": schema_version, + "boot_id": boot_id, + }, + collection_start=collection_start, + ) + quarantined_markers = quarantine_orphaned_provider_untitled_markers( + settings, + result, + retire_generations=provider_untitled_generations, + ) + if quarantined_markers: + result["provider_untitled_quarantine"] = quarantined_markers + # A session that went away with its provider still running + # is the ordinary way a conversation is lost, and until now + # only a whole-generation restart could queue one. Publish + # that evidence before inventory or the recovery manifest + # discard the last row which can rediscover the transition. + # If the pending write fails, the old authorities remain + # intact and the next refresh retries the same loss. Every + # write in this transaction carries the same allocated + # collection sequence, so ordering the pending write first + # changes nothing the sequence records prove. + enqueue_keywords: dict[str, Any] = {"boot_id": boot_id} + if _accepts_keyword(enqueue_lost_conversations, "collection_start"): + enqueue_keywords["collection_start"] = collection_start + queued_losses = enqueue_lost_conversations( + paths, + result, + (locked_cached if isinstance(locked_cached, Mapping) else None), + **enqueue_keywords, + ) + if queued_losses: + try: + pending_at_advance = read_state_json(paths["pending"]) + except (OSError, ValueError) as exc: + expected = ", ".join(sorted(queued_losses)) + raise CollectionError( + "pending recovery evidence changed before inventory/" + f"manifest advance; expected {expected}; found unreadable " + f"{paths['pending'].name}: {exc}" + ) from exc + prove_pending_losses(pending_at_advance, tuple(queued_losses)) + write_collection_json( + paths, + paths["inventory"], + result, + collection_start=collection_start, + ) + recovery_keywords = ( + {"collection_start": collection_start} + if _accepts_keyword(update_recovery_state, "collection_start") + else {} + ) + update_recovery_state(paths, result, **recovery_keywords) + # One colour per session, published where the session's + # own prompt can read it without asking anything. + publish_session_colors( + result, + state_dir=paths["root"], + retire_generations=session_color_generations, + ) + prune_origins( + paths["root"], + [ + item["shpool_id_raw"] + for item in result.get("sessions", ()) + if isinstance(item, Mapping) + and isinstance(item.get("shpool_id_raw"), str) + ], + retire_generations=origin_generations, + retire_bounce_generations=bounce_cleanup_generations, + ) + prune_inactive_state( + paths["root"], + [ + item["shpool_id_raw"] + for item in result.get("sessions", ()) + if isinstance(item, Mapping) + and isinstance(item.get("shpool_id_raw"), str) + ], + retire_generations=lifecycle_generations, + ) + # The model cache is state like any other and gets the same + # reaper. It writes one small record per conversation the + # kit has read a model for, and without this it grows for + # the life of the install. + if prune_model_cache is not None: + prune_model_cache( + paths["root"], + [ + (item.get("identity") or {}).get("uuid") + for item in result.get("sessions", ()) + if isinstance(item, Mapping) + and isinstance(item.get("identity"), Mapping) + ], + ) + else: + if apply_session_idle_states is not None: + apply_session_idle_states( + result, + (cached if isinstance(cached, Mapping) else None), + state_dir=paths["root"], + ) + if collection_diagnostic: + warnings = list(result.get("warnings", ())) + warnings.append(collection_diagnostic) + result["warnings"] = warnings + apply_provider_exit_states( + result, + cached if isinstance(cached, Mapping) else None, + state_dir=paths["root"], + boot_id=boot_id, + ) + # Reading only. This branch promised not to create a state + # directory merely to inspect state, so it certainly may not + # delete state -- and it holds no lock while every `sp` guard + # snapshot comes through here. Rows are still classified; the + # settled bounce markers are left to a collection that is + # allowed to write, and until then the row stays visible. + apply_session_origins( + result, state_dir=paths["root"], clear_settled_bounces=False + ) + registry = read_terminal_registry( + paths["terminal_numbers"], + boot_id, + paths["terminal_numbers_epoch"], + ) + apply_terminal_numbers( + result, registry, boot_id=boot_id, allocate=False + ) + except CollectionError as exc: + failure = str(exc) + warnings = list(result.get("warnings", ())) + warnings.append(f"terminal numbering unavailable: {failure}") + result["warnings"] = warnings + complete = False + if complete: + return result + if isinstance(cached, Mapping) and cached.get("schema_version") == schema_version: + result = dict(cached) + result["source"] = "cache" + result["stale"] = True + warnings = list(result.get("warnings", ())) + warnings.append(f"live refresh failed; showing last-known-good: {failure}") + result["warnings"] = warnings + return result + if live is not None: + live["source"] = "cold" + live["stale"] = True + return live + return cold_inventory( + f"inventory unavailable and no last-known-good cache exists: {failure}" + ) diff --git a/lib/sessionkit_inventory/state_io.py b/lib/sessionkit_inventory/state_io.py index 3115dbc..0e98041 100644 --- a/lib/sessionkit_inventory/state_io.py +++ b/lib/sessionkit_inventory/state_io.py @@ -2,14 +2,835 @@ from __future__ import annotations +import contextlib +import fcntl +import hashlib import json import os from pathlib import Path +import re import stat import tempfile -from typing import Any +import threading +from typing import Any, Callable, Iterable, Mapping -from .common import CollectionError +from .common import PROVIDERS, CollectionError, valid_uuid + + +_PUBLISHING_LOCK_NAME = "inventory-v2.lock" +_LEGACY_PUBLISHING_LOCK_NAME = "inventory.lock" +_LEGACY_LOCK_FENCE = b"session-kit publishing lock generation 2\n" +_LEGACY_ROLLBACK_MARKER_NAME = "inventory-rollback-v2" +_LEGACY_ROLLBACK_MARKER = b"session-kit legacy rollback selected\n" +_COLLECTION_DOCUMENT_KEYS = ( + "inventory", + "terminal_numbers", + "terminal_numbers_retired", + "terminal_numbers_epoch", + "manifest", + "pending", +) +_HELD_PUBLISHING_LOCKS = threading.local() + + +def _publishing_lock_key(root: Path) -> str: + return os.path.realpath(root) + + +def _held_publishing_locks() -> set[str]: + held = getattr(_HELD_PUBLISHING_LOCKS, "roots", None) + if held is None: + held = set() + _HELD_PUBLISHING_LOCKS.roots = held + return held + + +def _require_pending_publication_lock(path: Path) -> None: + if _publishing_lock_key(path.parent) not in _held_publishing_locks(): + raise CollectionError( + f"{path.name} publication requires this thread to hold " + f"{_PUBLISHING_LOCK_NAME}" + ) + + +def _state_paths(config: Mapping[str, Any]) -> dict[str, Path]: + root = Path(config["state_dir"]) + return { + "root": root, + "inventory": root / "inventory.json", + "manifest": root / "recovery-manifest.json", + "pending": root / "recovery-pending.json", + "aliases": root / "aliases.json", + "aliases_archive": root / "aliases.json.migrated-v1", + "aliases_source_backup": root / "aliases.json.pre-migration-v1", + "terminal_numbers": root / "terminal-numbers.json", + "terminal_numbers_epoch": root / "terminal-numbers.initialized.json", + "terminal_numbers_retired": root / "terminal-numbers-retired.json", + "color_reservations": root / "color-reservations.json", + "provider_title_retries": root / "provider-title-retries.json", + "provider_untitled_quarantine": root / "provider-untitled-quarantine", + "collection_sequence": root / "collection-sequence.json", + "collection_sequence_floor": root / "collection-sequence-floor.json", + "collection_markers": root / "collection-markers", + "config_lock": root / "config.lock", + "legacy_lock": root / _LEGACY_PUBLISHING_LOCK_NAME, + "legacy_rollback_marker": root / _LEGACY_ROLLBACK_MARKER_NAME, + "lock": root / _PUBLISHING_LOCK_NAME, + } + + +class StateLock: + def __init__(self, root: Path, lock_path: Path): + self.root = root + self.lock_path = lock_path + self.fd: int | None = None + + def _validate_root(self) -> None: + self.root.mkdir(mode=0o700, parents=True, exist_ok=True) + try: + root_stat = self.root.lstat() + except OSError as exc: + raise CollectionError( + f"cannot inspect state directory {self.root}" + ) from exc + if ( + not stat.S_ISDIR(root_stat.st_mode) + or stat.S_IMODE(root_stat.st_mode) != 0o700 + or root_stat.st_uid != os.geteuid() + ): + raise CollectionError( + "state directory must be a mode-0700 current-owner real directory: " + f"{self.root}" + ) + + def _open_and_lock(self, path: Path) -> int: + flags = os.O_RDWR | os.O_CREAT + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(path, flags, 0o600) + opened = os.fstat(descriptor) + before = path.lstat() + if ( + not stat.S_ISREG(opened.st_mode) + or stat.S_IMODE(opened.st_mode) != 0o600 + or opened.st_uid != os.geteuid() + or (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino) + ): + raise CollectionError( + "state lock must be a mode-0600 current-owner regular file: " + f"{path}" + ) + fcntl.flock(descriptor, fcntl.LOCK_EX) + after = path.lstat() + if (opened.st_dev, opened.st_ino) != (after.st_dev, after.st_ino): + raise CollectionError( + f"state lock changed while locking: {path}" + ) + except BaseException: + if "descriptor" in locals(): + with contextlib.suppress(OSError): + fcntl.flock(descriptor, fcntl.LOCK_UN) + os.close(descriptor) + raise + return descriptor + + def _legacy_lock_is_fenced(self, path: Path) -> bool: + flags = os.O_RDONLY + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(path, flags) + except FileNotFoundError: + return False + except OSError as exc: + raise CollectionError(f"cannot inspect legacy state lock: {path}") from exc + try: + metadata = os.fstat(descriptor) + pathname = path.lstat() + content = os.read(descriptor, len(_LEGACY_LOCK_FENCE) + 1) + except OSError as exc: + raise CollectionError(f"cannot inspect legacy state lock: {path}") from exc + finally: + os.close(descriptor) + if ( + stat.S_ISREG(metadata.st_mode) + and stat.S_IMODE(metadata.st_mode) == 0o400 + and metadata.st_uid == os.geteuid() + and metadata.st_nlink == 1 + and (metadata.st_dev, metadata.st_ino) + == (pathname.st_dev, pathname.st_ino) + and content == _LEGACY_LOCK_FENCE + ): + return True + if stat.S_ISREG(metadata.st_mode) and stat.S_IMODE(metadata.st_mode) == 0o600: + return False + raise CollectionError(f"legacy state lock is unsafe: {path}") + + def _publish_legacy_lock_fence(self, path: Path) -> None: + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=self.root) + try: + os.fchmod(descriptor, 0o400) + os.write(descriptor, _LEGACY_LOCK_FENCE) + os.fsync(descriptor) + os.close(descriptor) + descriptor = -1 + os.replace(temporary, path) + directory = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if descriptor >= 0: + os.close(descriptor) + with contextlib.suppress(FileNotFoundError): + os.unlink(temporary) + + def _legacy_rollback_is_active(self, path: Path) -> bool: + flags = os.O_RDONLY + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(path, flags) + except FileNotFoundError: + return False + except OSError as exc: + raise CollectionError( + f"cannot inspect legacy rollback publication hold: {path}" + ) from exc + try: + metadata = os.fstat(descriptor) + pathname = path.lstat() + content = os.read(descriptor, len(_LEGACY_ROLLBACK_MARKER) + 1) + except OSError as exc: + raise CollectionError( + f"cannot inspect legacy rollback publication hold: {path}" + ) from exc + finally: + os.close(descriptor) + if ( + stat.S_ISREG(metadata.st_mode) + and stat.S_IMODE(metadata.st_mode) == 0o400 + and metadata.st_uid == os.geteuid() + and metadata.st_nlink == 1 + and (metadata.st_dev, metadata.st_ino) + == (pathname.st_dev, pathname.st_ino) + and content == _LEGACY_ROLLBACK_MARKER + ): + return True + raise CollectionError(f"legacy rollback publication hold is unsafe: {path}") + + def _launched_release_is_current(self) -> bool: + release_raw = os.environ.get("SESSION_KIT_RELEASE_DIR") + root_raw = os.environ.get("SESSION_KIT_ROOT") + if not root_raw: + home_raw = os.environ.get("HOME") + if home_raw: + root_raw = os.fspath(Path(home_raw) / ".local/lib/session-kit") + if not release_raw or not root_raw: + return False + release = Path(release_raw) + root = Path(root_raw) + current = root / "current" + releases = root / "releases" + if not release.is_absolute() or not root.is_absolute(): + return False + try: + resolved_release = release.resolve(strict=True) + return ( + resolved_release.parent == releases.resolve(strict=True) + and resolved_release == current.resolve(strict=True) + ) + except OSError: + return False + + def _respect_legacy_rollback(self) -> None: + marker = self.root / _LEGACY_ROLLBACK_MARKER_NAME + if not self._legacy_rollback_is_active(marker): + return + if not self._launched_release_is_current(): + raise CollectionError( + "legacy rollback selected; this older generation-two publisher " + "is read-only" + ) + # A newly launched, currently selected generation-two release is the + # forward-update boundary. It may retire the rollback hold while it + # owns the versioned lock, then recreate the legacy refusal fence. + self._legacy_rollback_is_active(marker) + try: + marker.unlink() + directory = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + except OSError as exc: + raise CollectionError( + "cannot retire legacy rollback publication hold" + ) from exc + + def _open_versioned_publishing_lock(self) -> int: + legacy = self.root / _LEGACY_PUBLISHING_LOCK_NAME + while True: + if self._legacy_lock_is_fenced(legacy): + descriptor = self._open_and_lock(self.lock_path) + try: + self._respect_legacy_rollback() + except BaseException: + with contextlib.suppress(OSError): + fcntl.flock(descriptor, fcntl.LOCK_UN) + os.close(descriptor) + raise + return descriptor + try: + legacy_descriptor = self._open_and_lock(legacy) + except (CollectionError, PermissionError): + # Another new process may have replaced the legacy inode while + # this process waited on it -- or fenced it between this + # process's fence check and its O_RDWR open, which the 0400 + # fence answers with EACCES. The exact fence is the only safe + # reason to retry through the new lock generation. + if self._legacy_lock_is_fenced(legacy): + continue + raise + versioned_descriptor: int | None = None + try: + versioned_descriptor = self._open_and_lock(self.lock_path) + self._respect_legacy_rollback() + self._publish_legacy_lock_fence(legacy) + return versioned_descriptor + except BaseException: + if versioned_descriptor is not None: + with contextlib.suppress(OSError): + fcntl.flock(versioned_descriptor, fcntl.LOCK_UN) + os.close(versioned_descriptor) + raise + finally: + with contextlib.suppress(OSError): + fcntl.flock(legacy_descriptor, fcntl.LOCK_UN) + os.close(legacy_descriptor) + + def __enter__(self) -> "StateLock": + self._validate_root() + descriptor = ( + self._open_versioned_publishing_lock() + if self.lock_path == self.root / _PUBLISHING_LOCK_NAME + else self._open_and_lock(self.lock_path) + ) + self.fd = descriptor + if self.lock_path == self.root / _PUBLISHING_LOCK_NAME: + _held_publishing_locks().add(_publishing_lock_key(self.root)) + return self + + def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: + if self.fd is not None: + try: + fcntl.flock(self.fd, fcntl.LOCK_UN) + os.close(self.fd) + self.fd = None + finally: + if self.lock_path == self.root / _PUBLISHING_LOCK_NAME: + _held_publishing_locks().discard( + _publishing_lock_key(self.root) + ) + + +@contextlib.contextmanager +def publication_lock(paths: Mapping[str, Path]): + """Acquire the publishing lock unless this thread already owns it.""" + key = _publishing_lock_key(paths["root"]) + if key in _held_publishing_locks(): + yield + return + with StateLock(paths["root"], paths["lock"]): + yield + + +def atomic_write_json(path: Path, payload: Any) -> None: + """Write JSON with mode 0600, fsync, and same-directory atomic replace.""" + _atomic_write_bytes(path, _atomic_json_bytes(payload)) + + +def _write_all(descriptor: int, payload: bytes) -> None: + view = memoryview(payload) + while view: + written = os.write(descriptor, view) + if written <= 0: + raise OSError("short write while publishing state") + view = view[written:] + + +def _read_back_descriptor(descriptor: int, expected_size: int) -> bytes: + reader = os.dup(descriptor) + try: + os.lseek(reader, 0, os.SEEK_SET) + chunks: list[bytes] = [] + remaining = expected_size + 1 + while remaining: + block = os.read(reader, min(remaining, 65536)) + if not block: + break + chunks.append(block) + remaining -= len(block) + return b"".join(chunks) + finally: + os.close(reader) + + +def _prove_published_descriptor(descriptor: int, path: Path) -> None: + written = os.fstat(descriptor) + try: + pathname = path.lstat() + except OSError as exc: + raise CollectionError( + f"state file path no longer names the file that was published: {path}" + ) from exc + if ( + not stat.S_ISREG(pathname.st_mode) + or (written.st_dev, written.st_ino) != (pathname.st_dev, pathname.st_ino) + ): + raise CollectionError( + f"state file path no longer names the file that was published: {path}" + ) + + +def _atomic_write_bytes(path: Path, payload: bytes) -> None: + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + os.fchmod(descriptor, 0o600) + _write_all(descriptor, payload) + os.fsync(descriptor) + if _read_back_descriptor(descriptor, len(payload)) != payload: + raise CollectionError( + f"state file changed while it was being written: {path}" + ) + os.replace(temporary, path) + directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + _prove_published_descriptor(descriptor, path) + finally: + os.close(descriptor) + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + +def _collection_marker_path(paths: Mapping[str, Path], path: Path) -> Path: + if path.parent != paths["root"] or not path.name or "/" in path.name: + raise CollectionError(f"collection document is outside the state root: {path}") + return paths["collection_markers"] / f"{path.name}.json" + + +def _read_collection_sequence_document(path: Path) -> int | None: + try: + value = read_private_json(path, max_bytes=8192, allow_missing=True) + except (CollectionError, OSError, ValueError): + return None + sequence = value.get("last_collection_start") if isinstance(value, Mapping) else None + if isinstance(sequence, bool) or not isinstance(sequence, int) or sequence <= 0: + return None + return sequence + + +def _lost_collection_order_exists(paths: Mapping[str, Path]) -> bool: + """Whether published state proves this is not a first allocation. + + This check must not create or repair anything. With both allocation + records gone, even a corrupt document or marker is evidence that a prior + collector may still hold a larger sequence. + """ + for key in _COLLECTION_DOCUMENT_KEYS: + path = paths[key] + if os.path.lexists(path): + return True + + marker_dir = paths["collection_markers"] + try: + marker_info = marker_dir.lstat() + except FileNotFoundError: + return False + except OSError as exc: + raise CollectionError("cannot inspect collection markers") from exc + if not stat.S_ISDIR(marker_info.st_mode) or stat.S_ISLNK(marker_info.st_mode): + return True + try: + next(marker_dir.iterdir()) + except StopIteration: + return False + except OSError as exc: + raise CollectionError("cannot inspect collection markers") from exc + return True + + +def read_collection_marker(paths: Mapping[str, Path], path: Path) -> int | None: + """Return a document marker only when it still names the exact bytes.""" + marker_path = _collection_marker_path(paths, path) + try: + marker = read_private_json(marker_path, max_bytes=8192, allow_missing=True) + content = _read_bounded_owner_file( + path, + label="published collection document", + max_bytes=16 * 1024 * 1024, + exact_mode=0o600, + allow_missing=True, + ) + except (CollectionError, OSError, ValueError): + return None + sequence = marker.get("collection_start") if isinstance(marker, Mapping) else None + digest = marker.get("content_sha256") if isinstance(marker, Mapping) else None + if ( + content is None + or isinstance(sequence, bool) + or not isinstance(sequence, int) + or sequence <= 0 + or not isinstance(digest, str) + or not re.fullmatch(r"[0-9a-f]{64}", digest) + or hashlib.sha256(content).hexdigest() != digest + ): + return None + return sequence + + +def _read_collection_witness(paths: Mapping[str, Path], path: Path) -> int | None: + """Return the durable order witness even while its document is replacing. + + A writer publishes the witness before it replaces the document. During + that short interval the digest intentionally names the incoming bytes, not + the incumbent bytes. Freshness checks must still honor the sequence or an + older collector could use the digest mismatch as permission to publish. + """ + try: + marker = read_private_json( + _collection_marker_path(paths, path), + max_bytes=8192, + allow_missing=True, + ) + except (CollectionError, OSError, ValueError): + return None + sequence = marker.get("collection_start") if isinstance(marker, Mapping) else None + digest = marker.get("content_sha256") if isinstance(marker, Mapping) else None + if ( + isinstance(sequence, bool) + or not isinstance(sequence, int) + or sequence <= 0 + or not isinstance(digest, str) + or not re.fullmatch(r"[0-9a-f]{64}", digest) + ): + return None + return sequence + + +def allocate_collection_start(paths: Mapping[str, Path]) -> tuple[int, str | None]: + """Allocate one durable collection-start order while inventory.lock is held.""" + counter_path = paths["collection_sequence"] + prior = _read_collection_sequence_document(counter_path) + floor_path = paths.get( + "collection_sequence_floor", + paths["root"] / "collection-sequence-floor.json", + ) + durable_floor = _read_collection_sequence_document(floor_path) + counter_exists = os.path.lexists(counter_path) + floor_exists = os.path.lexists(floor_path) + if prior is None and durable_floor is None and ( + counter_exists + or floor_exists + or _lost_collection_order_exists(paths) + ): + raise CollectionError( + "both durable collection sequence records are unreadable; " + "collection publication is read-only" + ) + known = max(prior or 0, durable_floor or 0) + marker_dir = paths["collection_markers"] + ensure_private_directory(marker_dir) + try: + marker_paths = list(marker_dir.iterdir()) + except OSError as exc: + raise CollectionError("cannot inspect collection markers") from exc + for marker_path in marker_paths: + if marker_path.name.startswith(".") or not marker_path.name.endswith(".json"): + continue + document_name = marker_path.name.removesuffix(".json") + candidate = read_collection_marker(paths, paths["root"] / document_name) + if candidate is not None: + known = max(known, candidate) + sequence = known + 1 + diagnostic = None + if counter_exists and prior is None: + diagnostic = ( + "collection sequence was unreadable; rebuilt it from the durable " + "allocation floor and published markers" + ) + # The floor is the allocation record. Publish and fsync it before the + # ordinary counter and before returning the sequence to the collector, so + # a later counter reset cannot undercut an allocation still in flight. + atomic_write_json( + floor_path, + {"schema_version": 1, "last_collection_start": sequence}, + ) + atomic_write_json( + counter_path, + {"schema_version": 1, "last_collection_start": sequence}, + ) + return sequence, diagnostic + + +def preflight_collection_documents( + paths: Mapping[str, Path], + document_keys: Iterable[str], + sequence: int | None, +) -> tuple[list[str], list[str]]: + """Compare one incoming reading with every incumbent before any mutation.""" + refused: list[str] = [] + diagnostics: list[str] = [] + incoming_sequence = ( + sequence + if not isinstance(sequence, bool) + and isinstance(sequence, int) + and sequence > 0 + else None + ) + for key in document_keys: + path = paths[key] + marker = read_collection_marker(paths, path) + witness = _read_collection_witness(paths, path) + exists = path.exists() or path.is_symlink() + if witness is not None and ( + incoming_sequence is None or witness > incoming_sequence + ): + refused.append(path.name) + elif exists and marker is None and witness is None: + diagnostics.append( + f"{path.name} has no readable collection marker; replacing pre-upgrade or corrupt state" + ) + return refused, diagnostics + + +def write_collection_json( + paths: Mapping[str, Path], + path: Path, + payload: Any, + *, + collection_start: int | None, +) -> None: + """Publish a freshness witness, then atomically replace its document.""" + incumbent = _read_collection_witness(paths, path) + if ( + isinstance(collection_start, bool) + or not isinstance(collection_start, int) + or collection_start <= 0 + ): + if incumbent is not None: + raise CollectionError( + f"refused unknown collection replacing marked {path.name}" + ) + raise CollectionError(f"collection start marker is unavailable for {path.name}") + if incumbent is not None and incumbent > collection_start: + raise CollectionError( + f"refused collection {collection_start} replacing newer {path.name} marker {incumbent}" + ) + content = _atomic_json_bytes(payload) + ensure_private_directory(paths["collection_markers"]) + atomic_write_json( + _collection_marker_path(paths, path), + { + "schema_version": 1, + "collection_start": collection_start, + "content_sha256": hashlib.sha256(content).hexdigest(), + }, + ) + atomic_write_json(path, payload) + + +def stamp_collection_document( + paths: Mapping[str, Path], + path: Path, + *, + collection_start: int, +) -> None: + """Stamp exact existing bytes, including migration-preserved formatting.""" + content = _read_bounded_owner_file( + path, + label="published collection document", + max_bytes=16 * 1024 * 1024, + exact_mode=0o600, + ) + assert content is not None + ensure_private_directory(paths["collection_markers"]) + atomic_write_json( + _collection_marker_path(paths, path), + { + "schema_version": 1, + "collection_start": collection_start, + "content_sha256": hashlib.sha256(content).hexdigest(), + }, + ) + + +def _read_state_json(path: Path, *, load_json_file: Callable[[Path], Any]) -> Any: + try: + return load_json_file(path) + except FileNotFoundError: + return None + + +def _atomic_json_bytes(payload: Any) -> bytes: + return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def _read_bounded_owner_file( + path: Path, + *, + label: str, + max_bytes: int, + exact_mode: int | None = None, + allow_missing: bool = False, +) -> bytes | None: + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except FileNotFoundError: + if allow_missing: + return None + raise CollectionError(f"{label} does not exist: {path}") + except OSError as exc: + raise CollectionError(f"cannot open {label}: {path}") from exc + try: + before = os.fstat(descriptor) + try: + pathname = path.lstat() + except OSError as exc: + raise CollectionError(f"cannot inspect {label}: {path}") from exc + mode = stat.S_IMODE(before.st_mode) + if ( + not stat.S_ISREG(before.st_mode) + or before.st_uid != os.geteuid() + or before.st_nlink != 1 + or before.st_size < 0 + or before.st_size > max_bytes + or (exact_mode is not None and mode != exact_mode) + or (exact_mode is None and mode & 0o022) + or (before.st_dev, before.st_ino) != (pathname.st_dev, pathname.st_ino) + ): + raise CollectionError(f"unsafe {label}: {path}") + chunks: list[bytes] = [] + remaining = before.st_size + while remaining: + block = os.read(descriptor, min(remaining, 65536)) + if not block: + raise CollectionError(f"short read from {label}: {path}") + chunks.append(block) + remaining -= len(block) + if os.read(descriptor, 1): + raise CollectionError(f"{label} grew while reading: {path}") + after = os.fstat(descriptor) + stable = ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns") + if any(getattr(before, field) != getattr(after, field) for field in stable): + raise CollectionError(f"{label} changed while reading: {path}") + return b"".join(chunks) + finally: + os.close(descriptor) + + +def _read_private_launch_file(directory_fd: int, name: str) -> bytes: + flags = os.O_RDONLY + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(name, flags, dir_fd=directory_fd) + try: + before = os.fstat(descriptor) + if ( + not stat.S_ISREG(before.st_mode) + or stat.S_IMODE(before.st_mode) != 0o600 + or before.st_uid != os.geteuid() + or before.st_nlink != 1 + or before.st_size <= 0 + or before.st_size > 8192 + ): + raise CollectionError("unsafe retained launch record") + chunks: list[bytes] = [] + remaining = before.st_size + while remaining: + block = os.read(descriptor, min(remaining, 4096)) + if not block: + raise CollectionError("short retained launch record") + chunks.append(block) + remaining -= len(block) + if os.read(descriptor, 1): + raise CollectionError("retained launch record grew while reading") + after = os.fstat(descriptor) + stable = ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns") + if any(getattr(before, field) != getattr(after, field) for field in stable): + raise CollectionError("retained launch record changed while reading") + return b"".join(chunks) + finally: + os.close(descriptor) + + +def _launch_fields(payload: bytes, count: int) -> list[str] | None: + try: + text = payload.decode("utf-8", "strict") + except UnicodeDecodeError: + return None + if not text.endswith("\n") or text.count("\n") != 1 or "\r" in text: + return None + fields = text[:-1].split("\t") + return fields if len(fields) == count else None + + +def _create_private_backup( + path: Path, + payload: bytes, + *, + read_bounded_owner_file: Callable[..., bytes | None], + max_bytes: int, +) -> None: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(path, flags, 0o600) + except FileExistsError: + existing = read_bounded_owner_file( + path, + label="alias migration backup", + max_bytes=max_bytes, + exact_mode=0o600, + ) + if existing != payload: + raise CollectionError( + "alias migration backup already exists with other bytes" + ) + return + try: + os.fchmod(descriptor, 0o600) + view = memoryview(payload) + while view: + written = os.write(descriptor, view) + if written <= 0: + raise CollectionError("could not write alias migration backup") + view = view[written:] + os.fsync(descriptor) + finally: + os.close(descriptor) + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) def ensure_private_directory(path: Path) -> None: @@ -75,39 +896,16 @@ def read_private_json( def atomic_write_private_json(path: Path, value: Any) -> None: """Durably replace one owner-only JSON object without following symlinks.""" ensure_private_directory(path.parent) - descriptor, temporary = tempfile.mkstemp( - prefix=f".{path.name}.", suffix=".tmp", dir=path.parent - ) - try: - os.fchmod(descriptor, 0o600) - payload = json.dumps( + payload = ( + json.dumps( value, ensure_ascii=False, indent=2, sort_keys=True, - ).encode("utf-8") + b"\n" - view = memoryview(payload) - while view: - written = os.write(descriptor, view) - if written <= 0: - raise OSError("short write while publishing state") - view = view[written:] - os.fsync(descriptor) - os.close(descriptor) - descriptor = -1 - os.replace(temporary, path) - directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) - try: - os.fsync(directory) - finally: - os.close(directory) - finally: - if descriptor >= 0: - os.close(descriptor) - try: - os.unlink(temporary) - except FileNotFoundError: - pass + ).encode("utf-8") + + b"\n" + ) + _atomic_write_bytes(path, payload) def create_private_json(path: Path, value: Any) -> bool: @@ -126,12 +924,15 @@ def create_private_json(path: Path, value: Any) -> bool: except FileExistsError: return False try: - payload = json.dumps( - value, - ensure_ascii=False, - indent=2, - sort_keys=True, - ).encode("utf-8") + b"\n" + payload = ( + json.dumps( + value, + ensure_ascii=False, + indent=2, + sort_keys=True, + ).encode("utf-8") + + b"\n" + ) view = memoryview(payload) while view: written = os.write(descriptor, view) @@ -147,3 +948,166 @@ def create_private_json(path: Path, value: Any) -> bool: finally: os.close(directory) return True + + +def _empty_terminal_registry(boot_id: str, *, schema_version: int) -> dict[str, Any]: + return { + "schema_version": schema_version, + "boot_id": boot_id, + "next_number": 1, + "bindings": {}, + } + + +def _validate_terminal_registry( + raw: Any, + boot_id: str, + *, + schema_version: int, + empty_registry: Callable[[str], dict[str, Any]], +) -> dict[str, Any]: + if not isinstance(raw, Mapping) or set(raw) != { + "schema_version", + "boot_id", + "next_number", + "bindings", + }: + raise CollectionError("terminal number registry has an invalid schema") + if raw.get("schema_version") != schema_version: + raise CollectionError("terminal number registry has an unsupported schema") + stored_boot = raw.get("boot_id") + next_number = raw.get("next_number") + bindings = raw.get("bindings") + if ( + not isinstance(stored_boot, str) + or not stored_boot + or isinstance(next_number, bool) + or not isinstance(next_number, int) + or next_number <= 0 + or not isinstance(bindings, Mapping) + ): + raise CollectionError("terminal number registry has invalid fields") + checked: dict[str, int] = {} + ai_by_number: dict[int, str] = {} + generation_by_number: dict[int, str] = {} + for key, value in bindings.items(): + if ( + not isinstance(key, str) + or isinstance(value, bool) + or not isinstance(value, int) + or value <= 0 + ): + raise CollectionError("terminal number registry has an invalid binding") + if key.startswith("ai:"): + _, separator, remainder = key.partition(":") + provider, separator, uuid = remainder.partition(":") + if ( + not separator + or provider not in PROVIDERS + or valid_uuid(uuid) != uuid + or (value in ai_by_number and ai_by_number[value] != key) + ): + raise CollectionError( + "terminal number registry has a conflicting AI binding" + ) + ai_by_number[value] = key + elif re.fullmatch(r"generation:[0-9a-f]{64}", key): + if value in generation_by_number and generation_by_number[value] != key: + raise CollectionError( + "terminal number registry has duplicate generation bindings" + ) + generation_by_number[value] = key + else: + raise CollectionError("terminal number registry has an invalid key") + checked[key] = value + if checked and next_number <= max(checked.values()): + raise CollectionError("terminal number registry would reuse a number") + if stored_boot != boot_id: + return empty_registry(boot_id) + return { + "schema_version": schema_version, + "boot_id": stored_boot, + "next_number": next_number, + "bindings": checked, + } + + +def _read_terminal_registry( + path: Path, + boot_id: str, + epoch_path: Path | None = None, + *, + schema_version: int, + max_bytes: int, + read_bounded_owner_file: Callable[..., bytes | None], + empty_registry: Callable[[str], dict[str, Any]], + validate_registry: Callable[[Any, str], dict[str, Any]], +) -> dict[str, Any]: + payload = read_bounded_owner_file( + path, + label="terminal number registry", + max_bytes=max_bytes, + exact_mode=0o600, + allow_missing=True, + ) + if payload is None: + if epoch_path is not None: + epoch_payload = read_bounded_owner_file( + epoch_path, + label="terminal number initialization receipt", + max_bytes=4096, + exact_mode=0o600, + allow_missing=True, + ) + if epoch_payload is not None: + try: + epoch = json.loads(epoch_payload.decode("utf-8", "strict")) + except (UnicodeDecodeError, ValueError) as exc: + raise CollectionError( + "terminal number initialization receipt is invalid" + ) from exc + if ( + not isinstance(epoch, Mapping) + or set(epoch) != {"schema_version", "boot_id"} + or epoch.get("schema_version") != schema_version + or not isinstance(epoch.get("boot_id"), str) + or not epoch["boot_id"] + ): + raise CollectionError( + "terminal number initialization receipt has an invalid schema" + ) + if epoch["boot_id"] == boot_id: + raise CollectionError( + "same-boot terminal number registry disappeared" + ) + return empty_registry(boot_id) + try: + raw = json.loads(payload.decode("utf-8", "strict")) + except (UnicodeDecodeError, ValueError) as exc: + raise CollectionError("terminal number registry is invalid JSON") from exc + return validate_registry(raw, boot_id) + + +def _terminal_generation_key( + inventory: Mapping[str, Any], item: Mapping[str, Any], boot_id: str +) -> str | None: + shell = item.get("shpool_shell") + values = ( + item.get("started_at_unix_ms"), + shell.get("pid") if isinstance(shell, Mapping) else None, + shell.get("process_start_ticks") if isinstance(shell, Mapping) else None, + ) + if any( + isinstance(value, bool) or not isinstance(value, int) or value <= 0 + for value in values + ): + return None + raw_id = item.get("shpool_id_raw") + if not isinstance(raw_id, str) or not raw_id: + return None + payload = json.dumps( + [boot_id, raw_id, values[0], values[1], values[2]], + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + return f"generation:{hashlib.sha256(payload).hexdigest()}" diff --git a/lib/sessionkit_inventory/subagent_sweep.py b/lib/sessionkit_inventory/subagent_sweep.py new file mode 100644 index 0000000..61194af --- /dev/null +++ b/lib/sessionkit_inventory/subagent_sweep.py @@ -0,0 +1,2268 @@ +#!/usr/bin/env python3 +"""Close completed sub-agent processes that their owners never closed. + +The same timer also runs an independent background-shell pass. It recognizes +only a real bash carrying Claude's anchored snapshot-script argv shape whose +exact direct parent is a live managed provider root. Its idle evidence is the +regular file currently bound to fd/1, snapshotted through one no-follow +descriptor with path, device, inode, size, mtime, and ctime. A shell with any +live descendant is never eligible. For a childless shell, fd/1 movement or a +change in that shell's own utime+stime resets the fifteen-minute clock. A +pipe, socket, tty, missing file, unreadable candidate-local proc entry, or a +shell-owned foreground terminal is a refusal. Delivery pins and stops the +shell through a pidfd, verifies the stopped state, re-proves zero descendants, +identity, own CPU, terminal state, and fd/1, then sends the closing signal and +attempts to resume a surviving shell. A target that exits or is reaped before +that resume is still logged as receiving the closing signal. Shell records are +tagged ``kind=background-shell`` in the shared decision log. + +Completion means closure (operator ruling X20, 2026-08-14). A provider +sub-agent -- a Claude worker process carrying ``--parent-session-id`` -- keeps +running after its task ends so its owner can continue it; when the owner never +does, it sits for days holding memory (two 25-hour workers survived the +2026-08-13 out-of-memory incident's cleanup and had to be killed by hand). + +Self-reported state is worthless here: both hand-killed zombies reported +"running" while a full day idle. CPU is worthless too: the Node provider CLI +keeps ticking its event loop after the worker has stopped producing anything; +an estate measurement found 42 ticks in 60 seconds from a Claude session idle +for a full day. The evidence this sweep trusts is the worker's own output +transcript. Legacy Claude workers bind that file to ``--parent-session-id`` +plus ``--agent-id``; current named workers are full sessions and bind it to +the exact ``sessionId`` in Claude's PID/start-bound session record. Agent-mode +workers that have no PID record bind instead to every standalone transcript +whose head or tail records declare the exact name and team in their ``--agent-id``. +Both layouts live beneath the same profile roots the collector searches. Every +exact copy's path, device, inode, size, nanosecond mtime, and nanosecond ctime +is evidence. If that full copy set does not move for +``SESSION_KIT_SUBAGENT_IDLE_MINUTES`` (default 15, 0 disables the sweep), the +worker is finished or abandoned; both deserve closing. CPU movement never +resets this clock. + +Fifteen minutes is an operator ruling (2026-08-15): "close it 15 minutes +after it's finished." It replaces a six-hour window, and the margin that +window bought is now gone -- KNOWINGLY. A worker in one long silent compute, +with no transcript write for the whole window, is indistinguishable from a +worker that has stopped. It may be swept even while computing. That is the +accepted cost: a swept worker is not lost, its transcript persists, and +continuing it respawns a process. Conversely, a stopped worker whose provider +still writes output will stay alive until those writes stop; keeping it is the +safe error. Raise +``SESSION_KIT_SUBAGENT_IDLE_MINUTES`` if a real worker is ever cut off. + +A transcript that cannot be found, opened, or identified as a regular file is +not evidence of idleness. The worker is left alone and the refusal is appended +to the pass log, so damaged output evidence is visible rather than silently +granting immortality. This refusal applies before TERM. Once a real TERM has +landed, escalation remains committed and the next pass sends KILL even if the +transcript moves or becomes unreadable. + +The window is only as sharp as the pass that applies it: idle is measured +between passes, so a sweep every five minutes delivers the fifteen-minute +promise within about twenty. That is why the sweep has its own five-minute +timer instead of riding the hourly reaper, where fifteen minutes would have +meant up to seventy-five. + +This module still requires a REAL Linux /proc, but CPU counters are no longer +the reason. The reaper's Darwin proc-shaped tree is regenerated from a single +process-table snapshot: mtimes inside that tree say when the snapshot files +were written, not when a worker produced output, and its PID generation cannot +be re-read live at signal time. No proc-entry mtime may substitute for missing +transcript evidence. The output snapshot comes directly from the transcript; +the reaper keeps the invocation gated off Darwin because the settled exact-PID +delivery proof still requires live procfs (re-derived X20-F1). + +A candidate must BE a provider worker, not merely mention the flag: the +executable (argv[0], or argv[1] behind a node wrapper) must be named +``claude`` or live under a ``claude`` path component -- an exact component, +never a substring, so ``/tmp/notclaude`` and a ``claude-tmp`` working +directory both fail (lane finding X20-F2) -- and ``--parent-session-id`` and +``--agent-id`` must both be exact argv elements followed by a value. Only +processes owned by this uid are ever candidates. + +Closing is two-step across passes: TERM first, KILL on the next pass if the +same process (pid + start ticks) is still alive. Immediately before any +signal the identity is re-read, and delivery pins the process with a pidfd +where the kernel offers one, so a pid recycled between scan and signal can +never be struck (lane finding X20-F3). A TERM that was not delivered is not +recorded as sent, so escalation to KILL always follows a real TERM (lane +finding X20-F5). Once a real TERM is sent, escalation is committed: a TERM +handler writing output on its way down must not reset its own idle clock; +post-TERM output movement is logged, never obeyed. One sweep at a time: the +pass flocks the state directory for its whole read-decide-write span, so +overlapping runs cannot collapse the TERM-to-KILL grace pass (lane finding +X20-F7) and a skipped pass leaves no lock file behind. + +Every real action or output-evidence refusal appends one JSON line to +``subagent-sweep.log`` in the state directory. ``--dry-run`` prints what +would happen and touches nothing +on disk -- no signals, no state, no log (lane finding X20-F4); note a cold +dry run reports nothing, because only real passes arm the idle clock. + +EVERY switch that stops this pass, in the order it is read. The first four +are checked by ``bin/shpool_reaper`` before this module is even started; the +rest are checked here. ``session-kit doctor`` names 1, 6, 7 and 8 directly on +its ``subagent-sweep`` line; 2 is reported as "does not run on macOS"; 3 and 5 +reach only a hand-run pass, never the timer, and 4 is caught indirectly, +because all three leave the sweep with no completed pass to show and the +staleness rule reports that: + + 1. ``~/.no_shpool_reaper`` (``SESSION_KIT_REAPER_SENTINEL``) -- the estate's + oldest brake. It stops every closing pass, this one included; giving the + sweep its own entry point once walked it out from behind this file, and + that is the regression the check inside ``sweep_subagents()`` prevents by + construction (lane A F1). + 2. macOS -- the sweep never runs there at all, because the reaper's Darwin + process snapshot cannot support live PID re-verification (X20-F1). + 3. ``SESSION_KIT_REAPER_DRY_RUN=1`` -- reports, touches nothing. + 4. removing or renaming this module -- the reaper skips the sweep. + 5. ``SESSION_KIT_SUBAGENT_SWEEP`` set to ``0``/``off``/``no``/``false``. + A VARIABLE, so it stops a hand-run pass and never the timer. + 6. ``<state>/subagent-sweep-off`` -- a file, so it reaches the systemd timer, + which inherits none of their shell environment. This is the one to use. + 7. a window of ``0``, by any of the three routes below. + 8. a window that cannot be read, by any of those routes: unreadable is a + REFUSAL, not a guess (lane A F3). + +and the window itself, highest precedence first: +``SESSION_KIT_SUBAGENT_IDLE_MINUTES``, ``SESSION_KIT_SUBAGENT_IDLE_HOURS``, +``<state>/subagent-sweep-minutes``, then the default. Only the file reaches +the timer. + +A pass that does nothing says so on stderr, and records ``last_pass`` in its +state file so that a sweep which has silently stopped running is visible to +``session-kit doctor`` (lane B F9). + +stdlib only; /proc only; no provider APIs. +""" + +from __future__ import annotations + +import argparse +import errno +import fcntl +import json +import os +from pathlib import Path, PurePath +import re +import signal +import stat +import sys +import time +from typing import Callable, cast + +from .common import valid_uuid +from .transcripts import claude_roots + +# An operator-editable window that actually reaches the timer, unlike an +# exported variable (lane A F4). One number, in minutes, beside the off switch. +IDLE_WINDOW_FILE = "subagent-sweep-minutes" +DEFAULT_IDLE_MINUTES = 15.0 +# Kept as a derived value: the old environment variable still works, and a +# name that says hours cannot express fifteen minutes without a fraction. +DEFAULT_IDLE_HOURS = DEFAULT_IDLE_MINUTES / 60.0 +STATE_VERSION = 3 + +_AGENT_FLAG = "--parent-session-id" +_ID_FLAG = "--agent-id" +_NAME_FLAG = "--agent-name" +_TEAM_FLAG = "--team-name" +_TYPE_FLAG = "--agent-type" +_SHELL_SNAPSHOT_PATH_RE = re.compile( + r"^/.*/(?:\.claude|accounts/claude/[^/]+)/shell-snapshots/" + r"snapshot-bash-[0-9]+-[A-Za-z0-9]+\.sh$" +) +_SHELL_SOURCE_PREFIX_RE = re.compile( + r"^source (?P<path>/.*/(?:\.claude|accounts/claude/[^/]+)/shell-snapshots/" + r"snapshot-bash-[0-9]+-[A-Za-z0-9]+\.sh)(?:\s|$)" +) +_MAX_SESSION_RECORD_BYTES = 1024 * 1024 +_MAX_TRANSCRIPT_PROBE_BYTES = 256 * 1024 +# The provider names its version directories `2.1.233`, and that directory IS +# the executable in the current packaging. Anchored and deliberately narrow: a +# component that merely contains digits is not a version. +_VERSION_RE = re.compile(r"\d+(\.\d+)+([-+][0-9A-Za-z.\-]+)?") + +_HAS_PIDFD = hasattr(os, "pidfd_open") and hasattr(signal, "pidfd_send_signal") +SignalSender = Callable[[int, int], None] + + +def _finite_non_negative(raw: str) -> float | None: + """A usable duration, or None for anything that is not one.""" + try: + value = float(raw) + except ValueError: + return None + # NaN fails its own equality test; infinity would disable the sweep by + # accident rather than by the documented 0. + if value < 0 or value != value or value == float("inf"): + return None + return value + + +def _idle_window_file(state_dir: Path) -> str: + """The window an operator left in the state directory, if any. + + The systemd user manager does not inherit their shell environment and the + kit never bridges it, so an exported variable never reaches the timer that + does the closing (lane A F4). A file beside the off switch does, because + the pass reads it itself. + """ + try: + path = state_dir / IDLE_WINDOW_FILE + if path.is_symlink() or not path.is_file(): + return "" + # Bytes, then a REPLACING decode. `read_text` decodes strictly, so a + # single non-UTF-8 byte in this hand-editable file raised + # UnicodeDecodeError -- a ValueError, which `except OSError` does not + # catch -- out of the whole pass. The replacement character then fails + # to parse as a number, which disables the sweep and says why. Both + # outcomes close nothing; only one of them explains itself. + return path.read_bytes()[:64].decode("utf-8", "replace").strip() + except OSError: + return "" + + +def _env_idle_hours( + environ: dict[str, str], state_dir: Path | None = None +) -> float: + """The idle window, in hours, from whichever source is set. + + Order: the minutes variable, the hours variable, the state-directory file, + then the default. Minutes is the name the ruling is written in, so it wins; + the hours name keeps working unchanged, including 0 to disable. + + A value that cannot be read is a REFUSAL, not a default. It used to fall + back to fifteen minutes -- the narrowest window in the system -- so a typo + in the very variable the docs tell them to raise made the closer more + aggressive, and a malformed minutes value silently revived a sweep they had + turned off with hours=0 (lane A F3). Anything unparseable now disables the + sweep and says so on stderr. Wrong-and-quiet is the failure this pass can + least afford. + """ + for name in ("SESSION_KIT_SUBAGENT_IDLE_MINUTES", "SESSION_KIT_SUBAGENT_IDLE_HOURS"): + if name not in environ: + continue + raw = (environ.get(name) or "").strip() + if not raw: + _refuse(f"{name} is set but empty") + return 0.0 + value = _finite_non_negative(raw) + if value is None: + _refuse(f"{name}={raw!r} is not a number of {'minutes' if 'MINUTES' in name else 'hours'}") + return 0.0 + return value / 60.0 if "MINUTES" in name else value + if state_dir is not None: + raw = _idle_window_file(state_dir) + if raw: + value = _finite_non_negative(raw) + if value is None: + _refuse(f"{IDLE_WINDOW_FILE} contains {raw!r}, which is not a number of minutes") + return 0.0 + return value / 60.0 + return DEFAULT_IDLE_HOURS + + +def _refuse(reason: str) -> None: + """Say why the sweep is standing down. Never silent.""" + print( + f"session inventory: sub-agent sweep disabled -- {reason}", + file=sys.stderr, + ) + + +def _skipped(reason: str) -> None: + """Say why a pass did nothing. A silent pass is indistinguishable from a + pass that ran and found nothing, and those are not the same fact.""" + print( + f"session inventory: sub-agent sweep skipped -- {reason}; " + "nothing was closed on this pass", + file=sys.stderr, + ) + + +def _read_cmdline(proc: Path, pid: int) -> list[str]: + try: + raw = (proc / str(pid) / "cmdline").read_bytes() + except OSError: + return [] + return [part for part in raw.decode("utf-8", "replace").split("\0") if part] + + +def _stat_numbers(proc: Path, pid: int) -> tuple[str, int, int, int, int] | None: + """(state, ppid, own_cpu, reaped_child_cpu, start_ticks), or None.""" + try: + raw = (proc / str(pid) / "stat").read_text(encoding="utf-8", errors="replace") + except OSError: + return None + # comm may contain spaces/parens; fields are fixed after the LAST ')'. + tail = raw.rsplit(")", 1) + if len(tail) != 2: + return None + fields = tail[1].split() + # tail fields, 0-indexed: state=0 ppid=1 ... utime=11 stime=12 cutime=13 + # cstime=14 ... starttime=19 + if len(fields) < 20: + return None + try: + ppid = int(fields[1]) + own = int(fields[11]) + int(fields[12]) + reaped = int(fields[13]) + int(fields[14]) + start_ticks = int(fields[19]) + except ValueError: + return None + return fields[0], ppid, own, reaped, start_ticks + + +def _shell_stat( + proc: Path, pid: int +) -> tuple[str, int, int, int, int, int, int] | None: + """The process/terminal identity needed by the background-shell pass. + + Returns ``(state, ppid, pgid, session, tty, tpgid, start_ticks)``. This is + deliberately separate from :func:`_stat_numbers`: the worker pass keeps + reading exactly the fields it always has, while this independent pass + refuses unless every terminal field it relies on is parseable. + """ + try: + raw = (proc / str(pid) / "stat").read_text( + encoding="utf-8", errors="replace" + ) + except OSError: + return None + tail = raw.rsplit(")", 1) + if len(tail) != 2: + return None + fields = tail[1].split() + # tail fields: state=0, ppid=1, pgrp=2, session=3, tty_nr=4, + # tpgid=5, ... starttime=19. + if len(fields) < 20: + return None + try: + return ( + fields[0], + int(fields[1]), + int(fields[2]), + int(fields[3]), + int(fields[4]), + int(fields[5]), + int(fields[19]), + ) + except ValueError: + return None + + +def _background_shell_stat( + proc: Path, pid: int +) -> tuple[tuple[str, int, int, int, int, int, int, int] | None, str]: + """Read one proc stat without confusing disappearance with unreadability.""" + path = proc / str(pid) / "stat" + try: + raw = path.read_text(encoding="utf-8", errors="replace") + except FileNotFoundError: + return None, "" + except OSError as error: + return None, f"cannot read process stat {pid}: {error}" + tail = raw.rsplit(")", 1) + if len(tail) != 2: + return None, f"cannot parse process stat {pid}" + fields = tail[1].split() + if len(fields) < 20: + return None, f"cannot parse process stat {pid}" + try: + return ( + ( + fields[0], + int(fields[1]), + int(fields[2]), + int(fields[3]), + int(fields[4]), + int(fields[5]), + int(fields[19]), + int(fields[11]) + int(fields[12]), + ), + "", + ) + except ValueError: + return None, f"cannot parse process stat {pid}" + + +def _flag_value(cmdline: list[str], flag: str) -> str: + """The value following an exact-element flag; the ``=`` form is refused.""" + for index, part in enumerate(cmdline[:-1]): + if part == flag: + value = cmdline[index + 1] + if value and not value.startswith("-"): + return value + return "" + + +def _is_provider_binary(executable: PurePath) -> bool: + """The provider's OWN binary, by the provider's own packaging. + + This used to accept any executable with a path component named ``claude`` + (``"claude" in executable.parts``). That reaches a process the operator + started: a tool of theirs under any directory called ``claude``, run with the + two flags, was selected and TERMed after fifteen minutes -- reproduced by a + review lane, and reproduced again here before this was changed. Destroy + class 1 on the branch whose whole subject is an automatic closer. + + The fix is not "require the name ``claude``". MEASURED on the live process + table 2026-08-15: all fourteen genuine workers run as + ``/home/<user>/.local/share/claude/versions/2.1.233`` -- the version + DIRECTORY is the executable, a Bun single-file build, basename ``2.1.233``. + Requiring the name would have refused every real worker on the box and + quietly retired the sweep. So the bind is to the provider's LAYOUT: + + * an executable named exactly ``claude`` -- the launcher on PATH and the + older ``.../versions/<v>/claude`` layout; or + * ``<...>/claude/versions/<version>``, the current layout, with the + version component required to LOOK like a version. + + What this binds: the provider's packaging. What it does not: a file the + operator deliberately places at ``<anything>/claude/versions/<semver>`` and + runs with both provider flags is still eligible -- at which point it is + indistinguishable from the provider by anything short of a signature check. + That is the honest limit, and it is far away from "any directory called + claude". + """ + if executable.name == "claude": + return True + parent = executable.parent + return ( + parent.name == "versions" + and parent.parent.name == "claude" + and _VERSION_RE.fullmatch(executable.name) is not None + ) + + +def _is_worker(cmdline: list[str]) -> bool: + """A provider worker, never a bystander. + + ``grep claude ...`` fails (grep is not node, so argv[1] is never + consulted), ``/tmp/notclaude`` fails, a helper under ``claude-tmp`` fails, + and -- since the round-3 tightening -- so does any tool of the operator's + that merely lives under a directory named ``claude``. See + :func:`_is_provider_binary`. + """ + if not cmdline: + return False + if not _flag_value(cmdline, _AGENT_FLAG) or not _flag_value(cmdline, _ID_FLAG): + return False + executable = PurePath(cmdline[0]) + if executable.name in ("node", "nodejs"): + if len(cmdline) < 2: + return False + executable = PurePath(cmdline[1]) + return _is_provider_binary(executable) + + +def _provider_executable(cmdline: list[str]) -> PurePath | None: + """The provider executable named by argv, including the Node wrapper.""" + if not cmdline: + return None + executable = PurePath(cmdline[0]) + if executable.name in ("node", "nodejs"): + if len(cmdline) < 2: + return None + executable = PurePath(cmdline[1]) + return executable + + +def _is_provider_root(cmdline: list[str]) -> bool: + """A managed Claude/Codex root, never one of its machine children. + + Claude reuses the exact packaging identity already settled for workers, + with the worker/fork flags excluded. Codex roots use its native launcher; + non-interactive/server subcommands are machine children, not a person's + root provider. The shell fingerprint remains Claude-specific today, but + retaining the managed Codex root shape costs no breadth: every shell still + has to satisfy the independent exact fingerprint and terminal guards. + """ + executable = _provider_executable(cmdline) + if executable is None: + return False + if _is_provider_binary(executable): + return not any( + flag in cmdline + for flag in (_AGENT_FLAG, "--fork-session") + ) + if executable.name != "codex" or PurePath(cmdline[0]).name != "codex": + return False + return not any( + command in cmdline[1:] + for command in ("exec", "review", "app-server", "mcp-server") + ) + + +def _is_background_shell_cmdline(cmdline: list[str]) -> bool: + """The narrow harness argv fingerprint; ordinary bash never qualifies.""" + if not cmdline or PurePath(cmdline[0]).name != "bash": + return False + if any(_SHELL_SNAPSHOT_PATH_RE.fullmatch(argument) for argument in cmdline[1:]): + return True + for index, argument in enumerate(cmdline[:-1]): + if argument == "-c" and _SHELL_SOURCE_PREFIX_RE.match(cmdline[index + 1]): + return True + return False + + +def _exe_is_bash(proc: Path, pid: int) -> bool: + """Bind bash argv to the executable procfs says is actually running.""" + try: + target = os.readlink(proc / str(pid) / "exe") + except OSError: + return False + return PurePath(target).name == "bash" + + +def _plain_component(value: object) -> str: + """An exact path component supplied by the provider, or no component.""" + candidate = value if isinstance(value, str) else "" + if not candidate or candidate in {".", ".."} or Path(candidate).name != candidate: + return "" + return candidate + + +def _stable_session_record(path: Path) -> tuple[dict[str, object] | None, str]: + """Read one exact provider session record without link or rewrite races.""" + flags = ( + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_NONBLOCK", 0) + ) + try: + descriptor = os.open(path, flags) + except OSError as error: + return None, f"cannot read exact worker session record {path}: {error}" + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode): + return None, f"exact worker session record is not a regular file: {path}" + if before.st_size > _MAX_SESSION_RECORD_BYTES: + return None, f"exact worker session record is too large: {path}" + chunks: list[bytes] = [] + remaining = _MAX_SESSION_RECORD_BYTES + 1 + while remaining: + chunk = os.read(descriptor, min(remaining, 64 * 1024)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + if remaining == 0: + return None, f"exact worker session record is too large: {path}" + after = os.fstat(descriptor) + except OSError as error: + return None, f"cannot read exact worker session record {path}: {error}" + finally: + os.close(descriptor) + if ( + before.st_dev, + before.st_ino, + before.st_size, + before.st_mtime_ns, + before.st_ctime_ns, + ) != ( + after.st_dev, + after.st_ino, + after.st_size, + after.st_mtime_ns, + after.st_ctime_ns, + ): + return None, f"exact worker session record changed while being read: {path}" + try: + payload = json.loads(b"".join(chunks).decode("utf-8", "strict")) + except (UnicodeDecodeError, ValueError): + return None, f"exact worker session record is not valid JSON: {path}" + if not isinstance(payload, dict): + return None, f"exact worker session record is not an object: {path}" + return payload, "" + + +def _worker_session_identity_details( + agent: dict[str, object], environ: dict[str, str] +) -> tuple[str, str, bool]: + """Bind a current full-session worker through Claude's exact PID record. + + Claude publishes ``<profile>/sessions/<pid>.json`` with the full + ``sessionId`` and Linux ``procStart``. Requiring pid plus start ticks makes + a stale record for a recycled pid ineligible. Multiple matching records may + be profile copies only when they all advertise the same UUID. + """ + pid = agent.get("pid") + start_ticks = agent.get("start_ticks") + if ( + not isinstance(pid, int) + or isinstance(pid, bool) + or not isinstance(start_ticks, int) + or isinstance(start_ticks, bool) + ): + return "", "worker session identity is missing pid or start ticks", False + identities: set[str] = set() + saw_record = False + saw_stale = False + for root in claude_roots(environ): + candidate = root / "sessions" / f"{pid}.json" + try: + candidate.lstat() + except FileNotFoundError: + continue + except OSError as error: + return "", f"cannot inspect exact worker session record: {error}", True + saw_record = True + record, record_error = _stable_session_record(candidate) + if record_error: + return "", record_error, True + assert record is not None + record_pid = record.get("pid") + if ( + not isinstance(record_pid, int) + or isinstance(record_pid, bool) + or record_pid != pid + ): + return "", "exact worker session record pid does not match the worker", True + proc_start = record.get("procStart") + if not isinstance(proc_start, str) or not proc_start.isdecimal(): + return "", "exact worker session record is missing valid procStart", True + if proc_start != str(start_ticks): + saw_stale = True + continue + session_id = valid_uuid(record.get("sessionId")) + if session_id is None: + return "", "exact worker session record is missing valid sessionId", True + identities.add(session_id) + if len(identities) > 1: + return "", "worker session identity is ambiguous across exact session records", True + if identities: + return next(iter(identities)), "", True + if saw_stale: + return "", "no exact worker session record matches the worker procStart", True + if saw_record: + return "", "worker session identity could not be read", True + return ( + "", + "worker session identity is missing exact sessions/<pid>.json record", + False, + ) + + +def _worker_session_identity( + agent: dict[str, object], environ: dict[str, str] +) -> tuple[str, str]: + """Compatibility wrapper for the PID-record identity and refusal reason.""" + session_id, error, _record_exists = _worker_session_identity_details( + agent, environ + ) + return session_id, error + + +def _candidate_open_flags() -> int: + return ( + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_NONBLOCK", 0) + ) + + +def _stat_quintuple(info: os.stat_result) -> tuple[int, int, int, int, int]: + return ( + info.st_dev, + info.st_ino, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + ) + + +def _copy_evidence(candidate: Path, info: os.stat_result) -> dict[str, object]: + """Movement evidence from the descriptor that verified this exact copy.""" + return { + "path": os.fspath(candidate), + "dev": info.st_dev, + "ino": info.st_ino, + "size": info.st_size, + "mtime_ns": info.st_mtime_ns, + "ctime_ns": info.st_ctime_ns, + } + + +def _read_fd_bytes(descriptor: int, start: int, length: int) -> bytes: + os.lseek(descriptor, start, os.SEEK_SET) + chunks: list[bytes] = [] + remaining = length + while remaining: + chunk = os.read(descriptor, min(remaining, 64 * 1024)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def _window_membership_decision( + payload: bytes, + candidate: Path, + agent_name: str, + team_name: str, + *, + discard_first_fragment: bool, + complete_final_line: bool, +) -> tuple[bool | None, str]: + """The first identity-bearing (or root-session) record in one window.""" + if discard_first_fragment: + _fragment, separator, payload = payload.partition(b"\n") + if not separator: + return None, "" + lines = payload.split(b"\n") + if not complete_final_line: + lines.pop() + for line in lines: + if not line: + continue + try: + record = json.loads(line) + except (UnicodeDecodeError, ValueError): + continue + if not isinstance(record, dict): + continue + if {"agentName", "teamName"}.issubset(record): + member = ( + record.get("agentName") == agent_name + and record.get("teamName") == team_name + ) + if member and ( + valid_uuid(candidate.stem) is None + or record.get("sessionId") != candidate.stem + ): + return None, ( + "worker output transcript sessionId does not match " + f"its filename: {candidate}" + ) + return member, "" + # Root user/assistant records carry their own UUID but no agentName. + # Other metadata records may also omit agentName, so they do not decide. + if ( + record.get("type") in {"user", "assistant"} + and record.get("sessionId") == candidate.stem + and "agentName" not in record + ): + return False, "" + return None, "" + + +def _content_transcript_probe( + candidate: Path, + agent_name: str, + team_name: str, + worker_started_ns: int | None = None, +) -> tuple[bool, dict[str, object] | None, str]: + """Decide standalone membership and snapshot it through one descriptor.""" + try: + descriptor = os.open(candidate, _candidate_open_flags()) + except OSError as error: + return False, None, f"cannot read worker output transcript {candidate}: {error}" + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode): + return ( + False, + None, + f"worker output transcript is not a regular file: {candidate}", + ) + head_size = min(before.st_size, _MAX_TRANSCRIPT_PROBE_BYTES) + head = _read_fd_bytes(descriptor, 0, head_size) + decision, decision_error = _window_membership_decision( + head, + candidate, + agent_name, + team_name, + discard_first_fragment=False, + complete_final_line=before.st_size <= len(head), + ) + if decision is None and not decision_error and before.st_size > head_size: + tail_start = max(0, before.st_size - _MAX_TRANSCRIPT_PROBE_BYTES) + previous = _read_fd_bytes(descriptor, tail_start - 1, 1) + tail = _read_fd_bytes( + descriptor, tail_start, before.st_size - tail_start + ) + decision, decision_error = _window_membership_decision( + tail, + candidate, + agent_name, + team_name, + discard_first_fragment=previous != b"\n", + complete_final_line=True, + ) + after = os.fstat(descriptor) + except OSError as error: + return False, None, f"cannot read worker output transcript {candidate}: {error}" + finally: + os.close(descriptor) + if _stat_quintuple(before) != _stat_quintuple(after): + return ( + False, + None, + f"worker output transcript changed while being probed: {candidate}", + ) + if decision_error: + return False, None, decision_error + snapshot = _copy_evidence(candidate, after) + if decision is None: + if before.st_size <= head_size: + # The whole file fit inside the head window, so every complete + # record has been seen and none carried the agent identity + # fields. That is TWO real shapes wearing one face: the husk an + # aborted session left behind days ago, and a LIVE worker's + # transcript in its first moments, before the first user record + # brings the identity fields (review lanes rv-c10b-1/2 killed a + # live worker through exactly this confusion). Age tells them + # apart exactly: a worker's transcript cannot predate the worker + # process. Older than the worker (with an hour of clock slack) + # decides non-member; as new as the worker or newer REFUSES the + # whole answer, the no-close direction. + if worker_started_ns is None: + return ( + False, + None, + "worker start time is unreadable for transcript " + f"attribution: {candidate}", + ) + newest_ns = max(before.st_mtime_ns, before.st_ctime_ns) + if newest_ns >= worker_started_ns - _ATTRIBUTION_SLACK_NS: + return ( + False, + None, + "worker transcript is identityless but as recent as the " + f"worker itself: {candidate}", + ) + return False, snapshot, "" + if valid_uuid(candidate.stem) is not None: + return ( + False, + None, + f"worker transcript membership undecidable: {candidate}", + ) + return False, snapshot, "" + return decision, snapshot, "" + + +def _content_transcript_member( + candidate: Path, + agent_name: str, + team_name: str, + worker_started_ns: int | None = None, +) -> tuple[bool, str]: + """Compatibility wrapper for direct membership callers.""" + member, _snapshot, error = _content_transcript_probe( + candidate, agent_name, team_name, worker_started_ns + ) + return member, error + + +_ATTRIBUTION_SLACK_NS = 3600 * 1_000_000_000 + + +def _worker_started_wallclock_ns(proc: Path, start_ticks: object) -> int | None: + """The worker's start as wallclock nanoseconds, from boot time + ticks. + + Unreadable pieces return None; the caller must treat that as refusal + territory, never as permission to dismiss a candidate transcript.""" + if not isinstance(start_ticks, int) or isinstance(start_ticks, bool): + return None + try: + stat_text = (proc / "stat").read_text(encoding="ascii") + except (OSError, UnicodeDecodeError): + return None + btime = None + for line in stat_text.splitlines(): + fields = line.split() + if len(fields) == 2 and fields[0] == "btime" and fields[1].isdecimal(): + btime = int(fields[1]) + break + if btime is None: + return None + try: + hertz = os.sysconf("SC_CLK_TCK") + except (ValueError, OSError): + return None + if hertz <= 0: + return None + return btime * 1_000_000_000 + (start_ticks * 1_000_000_000) // hertz + + +def _content_bound_candidates( + projects: list[Path], + agent_id: str, + worker_started_ns: int | None = None, +) -> tuple[list[Path] | None, dict[str, dict[str, object]], str]: + """Every standalone transcript declaring NAME@TEAM from exact argv.""" + agent_name, separator, team_name = agent_id.rpartition("@") + if not separator or not agent_name or not team_name: + return None, {}, "worker --agent-id is missing exact name@team fields" + members: list[Path] = [] + probed: dict[str, dict[str, object]] = {} + seen: set[str] = set() + for project in projects: + try: + entries = sorted(os.scandir(project), key=lambda entry: entry.name) + except FileNotFoundError: + continue + except OSError as error: + return None, {}, f"cannot search {project}: {error}" + for entry in entries: + if not entry.name.endswith(".jsonl"): + continue + candidate = Path(entry.path) + path = os.fspath(candidate) + if path in seen: + continue + seen.add(path) + member, snapshot, probe_error = _content_transcript_probe( + candidate, agent_name, team_name, worker_started_ns + ) + if probe_error: + return None, {}, probe_error + if snapshot is not None: + probed[path] = snapshot + if member: + members.append(candidate) + return members, probed, "" + + +def _candidate_snapshot(candidate: Path) -> tuple[dict[str, object] | None, str]: + """Open, verify, and snapshot one exact candidate through one descriptor.""" + try: + descriptor = os.open(candidate, _candidate_open_flags()) + except FileNotFoundError: + return None, "" + except OSError as error: + return None, f"cannot read worker output transcript {candidate}: {error}" + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode): + return None, f"worker output transcript is not a regular file: {candidate}" + os.read(descriptor, 1) + after = os.fstat(descriptor) + except OSError as error: + return None, f"cannot read worker output transcript {candidate}: {error}" + finally: + os.close(descriptor) + if _stat_quintuple(before) != _stat_quintuple(after): + return None, f"worker output transcript changed while being read: {candidate}" + return _copy_evidence(candidate, after), "" + + +def _background_shell_tree_snapshot( + shell: dict[str, object], proc: Path, *, required_state: str | None = None +) -> tuple[dict[str, object] | None, str]: + """Walk only this shell's descendants and snapshot its own CPU. + + Linux exposes each task's direct children below that process. Walking + those files keeps an unreadable candidate local to that candidate instead + of making an unrelated numeric proc entry retire the whole shell pass. + """ + shell_pid = shell.get("pid") + shell_start = shell.get("start_ticks") + shell_session = shell.get("session_id") + if any( + not isinstance(value, int) or isinstance(value, bool) + for value in (shell_pid, shell_start, shell_session) + ): + return None, "background shell identity is missing pid, start, or session" + assert isinstance(shell_pid, int) + assert isinstance(shell_start, int) + assert isinstance(shell_session, int) + shell_identity, error = _background_shell_stat(proc, shell_pid) + if ( + shell_identity is None + or shell_identity[0] == "Z" + or (required_state is not None and shell_identity[0] != required_state) + or shell_identity[6] != shell_start + or shell_identity[3] != shell_session + ): + return None, error or "background shell identity changed during descendant scan" + + def direct_children(pid: int) -> tuple[list[int] | None, str]: + task = proc / str(pid) / "task" + try: + tids = sorted( + int(name) + for name in os.listdir(task) + if re.fullmatch(r"\d+", name) + ) + except FileNotFoundError: + return None, "" + except OSError as task_error: + return None, f"cannot enumerate process tasks for {pid}: {task_error}" + if not tids: + return None, f"process {pid} has no readable tasks" + children: set[int] = set() + for tid in tids: + path = task / str(tid) / "children" + try: + raw = path.read_text(encoding="ascii", errors="strict") + except FileNotFoundError: + # A non-leader thread may vanish while tasks are enumerated. + if not (task / str(tid)).exists(): + continue + return None, f"cannot read process children for {pid} task {tid}" + except (OSError, UnicodeError) as child_error: + return None, ( + f"cannot read process children for {pid} task {tid}: " + f"{child_error}" + ) + for word in raw.split(): + if not re.fullmatch(r"\d+", word): + return None, f"cannot parse process children for {pid} task {tid}" + children.add(int(word)) + return sorted(children), "" + + initial, error = direct_children(shell_pid) + if initial is None: + return None, error or "background shell vanished during descendant scan" + pending = list(initial) + seen: set[int] = set() + descendants: list[list[int]] = [] + while pending: + pid = pending.pop() + if pid in seen: + continue + seen.add(pid) + identity, error = _background_shell_stat(proc, pid) + if identity is None: + if error: + return None, f"cannot read descendant {pid}: {error}" + continue + if identity[0] == "Z": + continue + children, error = direct_children(pid) + if children is None: + if error: + return None, f"cannot walk descendant {pid}: {error}" + continue + pending.extend(children) + ( + _state, + _ppid, + _pgid, + session_id, + tty_nr, + _tpgid, + start_ticks, + _own_cpu, + ) = identity + if tty_nr != 0 and session_id != shell_session: + return None, ( + f"descendant {pid} owns a tty in a different process session" + ) + descendants.append([pid, start_ticks]) + descendants.sort(key=lambda item: (item[1], item[0])) + shell_after, error = _background_shell_stat(proc, shell_pid) + if ( + shell_after is None + or shell_after[1:] != shell_identity[1:] + or (required_state is not None and shell_after[0] != required_state) + ): + return None, error or "background shell changed during descendant scan" + return ( + { + "descendant_identities": descendants, + "shell_cpu_ticks": shell_identity[7], + "shell_identity": list(shell_identity[:7]), + }, + "", + ) + + +def _background_shell_foreground_refusal( + tree: dict[str, object], +) -> str: + """Refuse when this childless shell owns its terminal foreground.""" + identity = tree.get("shell_identity") + if ( + not isinstance(identity, list) + or len(identity) != 7 + or not isinstance(identity[2], int) + or isinstance(identity[2], bool) + or not isinstance(identity[4], int) + or isinstance(identity[4], bool) + or not isinstance(identity[5], int) + or isinstance(identity[5], bool) + ): + return "background shell tpgid is unreadable" + pgid = identity[2] + tty_nr = identity[4] + tpgid = identity[5] + if tty_nr != 0 and tpgid == pgid: + return f"terminal foreground pgid {tpgid} belongs to the background shell" + return "" + + +def _background_shell_own_snapshot( + shell: dict[str, object], proc: Path +) -> tuple[dict[str, object] | None, str]: + tree, error = _background_shell_tree_snapshot(shell, proc) + if tree is None: + return None, error + descendants = tree.get("descendant_identities") + if isinstance(descendants, list) and descendants: + youngest = descendants[-1] + return None, ( + f"youngest live descendant {youngest[0]} " + f"(start ticks {youngest[1]}) prevents background shell closure" + ) + foreground_error = _background_shell_foreground_refusal(tree) + if foreground_error: + return None, foreground_error + return {"shell_cpu_ticks": tree["shell_cpu_ticks"]}, "" + + +def _background_shell_output_snapshot( + shell: dict[str, object], proc: Path +) -> tuple[dict[str, object] | None, str]: + """Snapshot the shell's exact fd/1 regular file as its idle evidence.""" + pid = shell.get("pid") + if not isinstance(pid, int) or isinstance(pid, bool): + return None, "background shell identity is missing pid" + fd_path = proc / str(pid) / "fd" / "1" + try: + target = os.readlink(fd_path) + except OSError as error: + return None, f"cannot resolve background shell fd/1: {error}" + # Linux renders non-files as names such as pipe:[123], socket:[123], and + # /dev/pts/4. They have no stable regular-file tuple and are refusals. + if re.match(r"^(?:pipe|socket|anon_inode):\[", target): + return None, f"background shell fd/1 is not a regular file: {target}" + candidate = Path(target) + if not candidate.is_absolute(): + candidate = fd_path.parent / candidate + try: + fd_before = os.stat(fd_path) + except OSError as error: + return None, f"cannot inspect background shell fd/1: {error}" + try: + descriptor = os.open(candidate, _candidate_open_flags()) + except OSError as error: + return None, f"cannot read background shell output {candidate}: {error}" + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode) or not stat.S_ISREG(fd_before.st_mode): + return None, f"background shell fd/1 is not a regular file: {target}" + os.read(descriptor, 1) + after = os.fstat(descriptor) + fd_after = os.stat(fd_path) + target_after = os.readlink(fd_path) + except OSError as error: + return None, f"cannot read background shell output {candidate}: {error}" + finally: + os.close(descriptor) + if ( + _stat_quintuple(before) != _stat_quintuple(after) + or (fd_before.st_dev, fd_before.st_ino) + != (after.st_dev, after.st_ino) + or _stat_quintuple(fd_after) != _stat_quintuple(after) + or target_after != target + ): + return None, f"background shell output changed while being read: {candidate}" + return {"output_copies": [_copy_evidence(candidate, after)]}, "" + + +def _candidate_copy_set( + candidates: list[Path], + probed: dict[str, dict[str, object]] | None = None, +) -> tuple[list[dict[str, object]] | None, str]: + """Every readable exact copy, ordered by path for stable comparison.""" + copies: list[dict[str, object]] = [] + known = probed or {} + by_path = {os.fspath(candidate): candidate for candidate in candidates} + for path in sorted(by_path): + snapshot = known.get(path) + error = "" + if snapshot is None: + snapshot, error = _candidate_snapshot(by_path[path]) + if error: + return None, error + if snapshot is not None: + copies.append(snapshot) + return copies, "" + + +def _output_snapshot( + agent: dict[str, object], environ: dict[str, str] +) -> tuple[dict[str, object] | None, str]: + """The readable transcript this worker itself writes. + + Legacy workers bind by parent plus agent id. Current named workers are full + sessions and bind by the exact ``sessionId`` in their PID/start-bound + provider record. If no PID record exists, current agent-mode workers bind + to standalone files whose own content declares the exact argv NAME@TEAM. + All applicable layouts contribute to one full copy set. + + Every candidate is opened once with NOFOLLOW, verified as a readable regular + file, and snapshotted from that same descriptor. Any exact copy that exists + but cannot be read refuses the whole answer. Every readable copy publishes + path, device, inode, size, nanosecond mtime, and nanosecond ctime in + deterministic path order, so appearance, disappearance, replacement, or + movement resets the idle clock. + """ + parent = _plain_component(agent.get("parent")) + agent_id = _plain_component(agent.get("agent_id")) + if not parent or not agent_id: + return None, "worker output identity is not one exact path component" + projects_found: list[Path] = [] + search_errors: list[str] = [] + filename = f"agent-{agent_id}.jsonl" + for root in claude_roots(environ): + projects = root / "projects" + try: + entries = list(os.scandir(projects)) + except FileNotFoundError: + continue + except OSError as error: + search_errors.append(f"cannot search {projects}: {error}") + continue + for entry in entries: + try: + if not entry.is_dir(follow_symlinks=False): + continue + except OSError as error: + search_errors.append(f"cannot inspect {entry.path}: {error}") + continue + projects_found.append(Path(entry.path)) + if search_errors: + return None, search_errors[0] + candidates = [ + project / parent / "subagents" / filename for project in projects_found + ] + identity_error = agent.get("session_identity_error") + session_id = valid_uuid(agent.get("session_id")) + record_exists = agent.get("session_record_exists") + current_shape = agent.get("current_worker_shape") is True + required_registry_paths: set[str] = set() + probed: dict[str, dict[str, object]] = {} + + # A full answer always re-reads the PID/start-bound registry. This is what + # makes the signal-time answer fresh rather than a replay of process-scan + # identity, while preserving registry precedence when a record exists. + if current_shape: + refreshed_id, refreshed_error, refreshed_exists = ( + _worker_session_identity_details(agent, environ) + ) + record_exists = refreshed_exists + identity_error = refreshed_error + session_id = valid_uuid(refreshed_id) + if record_exists is False and current_shape: + worker_started_ns = agent.get("start_wallclock_ns") + if not isinstance(worker_started_ns, int) or isinstance( + worker_started_ns, bool + ): + worker_started_ns = None + content_candidates, probed, candidate_error = _content_bound_candidates( + projects_found, + agent_id, + worker_started_ns, + ) + if candidate_error: + return None, candidate_error + assert content_candidates is not None + candidates.extend(content_candidates) + + # A PID record can appear after process discovery. Retain the content + # members already found and union the stronger registry candidate. + refreshed_id, refreshed_error, refreshed_exists = ( + _worker_session_identity_details(agent, environ) + ) + if refreshed_exists: + if refreshed_error: + return None, refreshed_error + session_id = valid_uuid(refreshed_id) + identity_error = refreshed_error + if session_id is not None: + required_registry_paths = { + os.fspath(project / f"{session_id}.jsonl") + for project in projects_found + } + if not identity_error and session_id is not None: + candidates.extend(project / f"{session_id}.jsonl" for project in projects_found) + copies, copy_error = _candidate_copy_set(candidates, probed) + if copy_error: + return None, copy_error + assert copies is not None + if required_registry_paths and not any( + copy["path"] in required_registry_paths for copy in copies + ): + return None, "worker output transcript for exact sessionId was not found" + if copies: + return {"output_copies": copies}, "" + if identity_error: + return None, str(identity_error) + if session_id is None: + return None, "worker session identity is missing valid sessionId" + return ( + None, + "worker output transcript for exact sessionId was not found", + ) + + +def find_subagents( + proc: Path, environ: dict[str, str] | None = None +) -> list[dict[str, object]]: + """Every live provider sub-agent worker THIS uid owns.""" + values = dict(os.environ) if environ is None else environ + found: list[dict[str, object]] = [] + try: + entries = sorted( + int(name) for name in os.listdir(proc) if re.fullmatch(r"\d+", name) + ) + except OSError: + return found + own_uid = os.geteuid() + for pid in entries: + before = _stat_numbers(proc, pid) + if before is None: + continue + state, _ppid, _own, _reaped, start_ticks = before + if state == "Z": + continue + cmdline = _read_cmdline(proc, pid) + if not _is_worker(cmdline): + continue + try: + if (proc / str(pid)).stat().st_uid != own_uid: + continue + except OSError: + continue + agent = { + "pid": pid, + "start_ticks": start_ticks, + "agent_id": _flag_value(cmdline, _ID_FLAG), + "parent": _flag_value(cmdline, _AGENT_FLAG), + "current_worker_shape": bool(_flag_value(cmdline, _NAME_FLAG)) + and bool(_flag_value(cmdline, _TEAM_FLAG)) + and bool(_flag_value(cmdline, _TYPE_FLAG)), + "start_wallclock_ns": _worker_started_wallclock_ns( + proc, start_ticks + ), + } + session_id, session_identity_error, session_record_exists = ( + _worker_session_identity_details(agent, values) + ) + after = _stat_numbers(proc, pid) + after_cmdline = _read_cmdline(proc, pid) + # CPU counters and ordinary S/R state churn are deliberately irrelevant. + # Only the process generation and the identity-bearing argv must remain + # the same around the exact provider-session record read. + if ( + after is None + or after[0] == "Z" + or after[4] != start_ticks + or after_cmdline != cmdline + ): + continue + agent["session_id"] = session_id + agent["session_identity_error"] = session_identity_error + agent["session_record_exists"] = session_record_exists + found.append(agent) + return found + + +def find_background_shells(proc: Path) -> list[dict[str, object]]: + """Every exact harness shell directly owned by a live provider root.""" + found: list[dict[str, object]] = [] + try: + entries = sorted( + int(name) for name in os.listdir(proc) if re.fullmatch(r"\d+", name) + ) + except OSError: + return found + own_uid = os.geteuid() + for pid in entries: + cmdline = _read_cmdline(proc, pid) + if not _is_background_shell_cmdline(cmdline): + continue + before = _shell_stat(proc, pid) + if before is None or before[0] == "Z" or not _exe_is_bash(proc, pid): + continue + _state, ppid, pgid, session_id, tty_nr, tpgid, start_ticks = before + parent_before = _shell_stat(proc, ppid) + if parent_before is None or parent_before[0] == "Z": + continue + parent_cmdline = _read_cmdline(proc, ppid) + if not _is_provider_root(parent_cmdline): + continue + parent_tty = parent_before[4] + if tty_nr not in (0, parent_tty): + continue + try: + if ( + (proc / str(pid)).stat().st_uid != own_uid + or (proc / str(ppid)).stat().st_uid != own_uid + ): + continue + except OSError: + continue + # Bind both generations and every identity-bearing field around the + # scan. CPU and scheduling state are intentionally irrelevant. + after = _shell_stat(proc, pid) + parent_after = _shell_stat(proc, ppid) + if ( + after is None + or after[0] == "Z" + or after[1:] != before[1:] + or _read_cmdline(proc, pid) != cmdline + or not _exe_is_bash(proc, pid) + or parent_after is None + or parent_after[0] == "Z" + or parent_after[1:] != parent_before[1:] + or _read_cmdline(proc, ppid) != parent_cmdline + ): + continue + shell = { + "pid": pid, + "start_ticks": start_ticks, + "provider_pid": ppid, + "provider_start_ticks": parent_before[6], + "session_id": session_id, + } + found.append(cast(dict[str, object], shell)) + return found + + +def _still_the_process(proc: Path, pid: int, start_ticks: int) -> bool: + """The recorded process generation is live at this instant. + + A zombie retains its PID and start ticks only until its parent reaps it; + it has already exited and cannot survive or perform work. + """ + numbers = _stat_numbers(proc, pid) + return numbers is not None and numbers[0] != "Z" and numbers[4] == start_ticks + + +def _still_the_worker(proc: Path, pid: int, start_ticks: int) -> bool: + """The recorded identity, re-read at signal time: same start ticks AND + still worker-shaped. A recycled pid fails one or both.""" + if not _still_the_process(proc, pid, start_ticks): + return False + return _is_worker(_read_cmdline(proc, pid)) + + +def _background_shell_identity_snapshot( + proc: Path, + pid: int, + start_ticks: int, + shell: dict[str, object], +) -> tuple[dict[str, object] | None, str]: + """Re-derive the shell and parent identity without making a tty decision.""" + identity = _shell_stat(proc, pid) + provider_pid = shell.get("provider_pid") + provider_start = shell.get("provider_start_ticks") + if ( + identity is None + or identity[0] == "Z" + or identity[6] != start_ticks + or not isinstance(provider_pid, int) + or isinstance(provider_pid, bool) + or not isinstance(provider_start, int) + or isinstance(provider_start, bool) + or identity[1] != provider_pid + or identity[3] != shell.get("session_id") + or not _is_background_shell_cmdline(_read_cmdline(proc, pid)) + or not _exe_is_bash(proc, pid) + ): + return None, "background shell identity changed before delivery" + _state, _ppid, _pgid, _session, tty_nr, _tpgid, _start = identity + parent = _shell_stat(proc, provider_pid) + if ( + parent is None + or parent[0] == "Z" + or parent[6] != provider_start + or not _is_provider_root(_read_cmdline(proc, provider_pid)) + or tty_nr not in (0, parent[4]) + ): + return None, "background shell provider identity changed before delivery" + return { + "shell_identity": list(identity[1:]), + "provider_identity": list(parent[1:]), + }, "" + + +def _background_shell_evidence_snapshot( + shell: dict[str, object], proc: Path +) -> tuple[dict[str, object] | None, str]: + """The per-pass childless-shell own-CPU and output proof.""" + own, error = _background_shell_own_snapshot(shell, proc) + if own is None: + return None, error + output, error = _background_shell_output_snapshot(shell, proc) + if output is None: + return None, error + return {**own, **output}, "" + + +def _background_shell_own_cpu_moved( + previous: dict[str, object], current: dict[str, object] +) -> bool: + """Only strict equality of the shell's own CPU permits closure.""" + previous_cpu = previous.get("shell_cpu_ticks") + current_cpu = current.get("shell_cpu_ticks") + return ( + not isinstance(previous_cpu, int) + or isinstance(previous_cpu, bool) + or not isinstance(current_cpu, int) + or isinstance(current_cpu, bool) + or current_cpu != previous_cpu + ) + + +def _background_shell_final_proof( + proc: Path, + shell: dict[str, object], + armed: dict[str, object], +) -> tuple[dict[str, object] | None, str]: + """Full proof while the pinned shell is frozen in state T.""" + pid = int(cast(int, shell["pid"])) + start_ticks = int(cast(int, shell["start_ticks"])) + identity, error = _background_shell_identity_snapshot( + proc, pid, start_ticks, shell + ) + if identity is None: + return None, error + stopped = _shell_stat(proc, pid) + if stopped is None or stopped[0] != "T": + return None, "background shell did not remain stopped in state T" + tree, error = _background_shell_tree_snapshot(shell, proc, required_state="T") + if tree is None: + return None, error + descendants = tree.get("descendant_identities") + if isinstance(descendants, list) and descendants: + youngest = descendants[-1] + return None, ( + f"youngest live descendant {youngest[0]} " + f"(start ticks {youngest[1]}) prevents background shell closure" + ) + own = {"shell_cpu_ticks": tree["shell_cpu_ticks"]} + if _background_shell_own_cpu_moved(armed, own): + return None, "background shell own CPU moved before delivery" + foreground_error = _background_shell_foreground_refusal(tree) + if foreground_error: + return None, foreground_error + # fd/1 equality is deliberately the last proof before the signal syscall. + output, error = _background_shell_output_snapshot(shell, proc) + if output is None: + return None, error + if output.get("output_copies") != armed.get("output_copies"): + return None, "background shell fd/1 tuple moved before delivery" + return {**identity, **own, **output}, "" + + +def _background_shell_wait_stopped( + proc: Path, pid: int, start_ticks: int +) -> bool: + """Wait briefly for the asynchronous SIGSTOP state to appear in procfs.""" + for attempt in range(20): + identity = _shell_stat(proc, pid) + if identity is None or identity[6] != start_ticks: + return False + if identity[0] == "T": + return True + if attempt != 19: + time.sleep(0.005) + return False + + +def _deliver_background_shell( + proc: Path, + shell: dict[str, object], + armed: dict[str, object], + signum: int, + *, + send_signal: Callable[[int, int], None] | None = None, +) -> tuple[bool, dict[str, object] | None, str]: + """Pin, freeze, re-prove, close, and resume one exact shell. + + ``send_signal`` is the synthetic-proc test seam and represents pidfd signal + delivery. Production refuses when pidfds are unavailable and never falls + back to a PID-only shell kill. + """ + pid = int(cast(int, shell["pid"])) + if send_signal is None and not _HAS_PIDFD: + return False, None, "pidfd delivery is unavailable for background shell" + descriptor = os.pidfd_open(pid) if send_signal is None else None + stopped = False + delivered = False + proof: dict[str, object] | None = None + error = "" + target_exited_before_cont = False + target_reaped_before_cont = False + + def send(pidfd_signum: int) -> None: + if send_signal is not None: + send_signal(pid, pidfd_signum) + else: + assert descriptor is not None + signal.pidfd_send_signal(descriptor, pidfd_signum) + + try: + send(signal.SIGSTOP) + stopped = True + if not _background_shell_wait_stopped( + proc, pid, int(cast(int, shell["start_ticks"])) + ): + error = "background shell did not enter stopped state T" + else: + proof, error = _background_shell_final_proof(proc, shell, armed) + if proof is not None: + send(signum) + delivered = True + after_signal = _shell_stat(proc, pid) + if ( + after_signal is None + or after_signal[6] != int(cast(int, shell["start_ticks"])) + ): + target_exited_before_cont = True + target_reaped_before_cont = True + elif after_signal[0] in {"X", "Z"}: + target_exited_before_cont = True + finally: + try: + if stopped: + try: + send(signal.SIGCONT) + except OSError as resume_error: + if not delivered: + raise + if resume_error.errno == errno.ESRCH: + target_exited_before_cont = True + target_reaped_before_cont = True + else: + error = ( + "background shell closing signal was delivered; " + f"SIGCONT failed: {resume_error}" + ) + finally: + if descriptor is not None: + try: + os.close(descriptor) + except OSError as close_error: + if not delivered: + raise + error = ( + "background shell closing signal was delivered; " + f"pidfd close failed: {close_error}" + ) + if delivered and proof is not None: + if target_exited_before_cont: + proof["target_exited_before_cont"] = True + if target_reaped_before_cont: + proof["target_reaped_before_cont"] = True + return delivered, proof, error + + +def _deliver_exact_process( + proc: Path, + pid: int, + start_ticks: int, + signum: int, + *, + still_expected: Callable[[Path, int, int], bool] | None = None, + send_signal: Callable[[int, int], None] | None = None, + before_signal: Callable[[], bool] | None = None, +) -> bool: + """Signal exactly the recorded process. + + With pidfd support the process is pinned first, then re-verified through + /proc, then signalled through the fd -- the kernel guarantees the fd can + never address a pid-reuse successor. Without pidfd the identity re-read + narrows the race to the read-kill gap, the classic best available. + ``before_signal`` runs only after the process-generation guard and may veto + delivery when fresh external evidence no longer matches the armed answer. + Returns false only for that safe veto. Raises ProcessLookupError when the + recorded process is already gone or replaced. + """ + expected = still_expected or _still_the_process + if send_signal is not None: + if not expected(proc, pid, start_ticks): + raise ProcessLookupError(pid) + if before_signal is not None and not before_signal(): + return False + send_signal(pid, signum) + elif _HAS_PIDFD: + fd = os.pidfd_open(pid) + try: + if not expected(proc, pid, start_ticks): + raise ProcessLookupError(pid) + if before_signal is not None and not before_signal(): + return False + signal.pidfd_send_signal(fd, signum) + finally: + os.close(fd) + else: + if not expected(proc, pid, start_ticks): + raise ProcessLookupError(pid) + if before_signal is not None and not before_signal(): + return False + os.kill(pid, signum) + return True + + +def _deliver( + proc: Path, + pid: int, + start_ticks: int, + signum: int, + *, + before_signal: Callable[[], bool] | None = None, +) -> bool: + """Worker-shaped compatibility wrapper around exact-process delivery.""" + return _deliver_exact_process( + proc, + pid, + start_ticks, + signum, + still_expected=_still_the_worker, + before_signal=before_signal, + ) + + +def _boot_id(proc: Path) -> str: + """This boot, so a pid:start_ticks pair cannot span two of them. + + Start ticks are counted from boot, so the same pair identifies a DIFFERENT + process after a reboot. A persisted TERM decision could then land on a + stranger as an immediate KILL. Unreadable is fine and compares equal to + itself, which is what a fixture wants. + """ + try: + return (proc / "sys" / "kernel" / "random" / "boot_id").read_text( + encoding="utf-8" + ).strip()[:64] + except OSError: + return "" + + +def _load_state(path: Path) -> dict[str, object]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + if not isinstance(payload, dict) or payload.get("version") != STATE_VERSION: + return {} + return payload + + +def _save_state( + path: Path, + tracked: dict[str, dict[str, object]], + *, + boot_id: str, + last_pass: float, + window_seconds: float, +) -> None: + scratch = path.with_name(path.name + ".tmp") + scratch.write_text( + json.dumps( + { + "version": STATE_VERSION, + "tracked": tracked, + "boot_id": boot_id, + "last_pass": last_pass, + # The window the evidence below was gathered under. A shorter + # window is a NEW rule, and evidence collected under the old + # one cannot be used to close anything (see _locked_sweep). + "window_seconds": window_seconds, + }, + sort_keys=True, + ), + encoding="utf-8", + ) + os.replace(scratch, path) + + +def _log(log_path: Path, record: dict[str, object]) -> None: + with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, sort_keys=True) + "\n") + + +def sweep( + *, + proc: Path, + state_dir: Path, + environ: dict[str, str], + now: float, + kill: SignalSender | None = None, + dry_run: bool = False, +) -> list[dict[str, object]]: + """One pass. Returns the action records it produced. + + ``kill`` is a test seam: when provided it replaces only the delivery + syscall -- the identity re-read still runs against ``proc`` first, so + tests exercise the same refusal path production uses. + """ + idle_hours = _env_idle_hours(environ, state_dir) + switched_off = ( + idle_hours == 0 + or (environ.get("SESSION_KIT_SUBAGENT_SWEEP") or "").strip().casefold() + in {"0", "off", "no", "false"} + or (state_dir / "subagent-sweep-off").exists() + ) + if switched_off: + return [] + # One sweep at a time across the whole read-decide-write span. A second + # runner (a hand-run reaper beside the timer) skipping is correct; the + # dangerous alternative is it reading the first runner's fresh term_sent + # and firing KILL in the same second TERM landed (lane finding X20-F7). + # The lock is a flock on the state DIRECTORY itself, not on a lock file: + # a dry pass must create nothing on disk, ever (lane finding X20-F4, + # round 2), and the directory descriptor gives the same exclusion with + # zero filesystem writes. + # + # A pass that does nothing must SAY it did nothing. Silence is the exact + # symptom this whole change exists to remove, and a lock that is never + # released -- a stuck process, a debugger, a hand-run pass that hung -- + # used to retire the rule permanently with no evidence anywhere (lane B + # F9). Both skips are safe (nothing is closed) and both are now audible on + # stderr, which is the journal under the timer. The durable half of the + # trace is `last_pass` in the state file: `session-kit doctor` reads it and + # reports a sweep that has stopped completing passes, whatever the cause. + try: + lock_handle = os.open(state_dir, os.O_RDONLY) + except OSError as error: + _skipped(f"cannot open its state directory {state_dir}: {error}") + return [] + try: + try: + fcntl.flock(lock_handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + _skipped(f"another pass holds {state_dir}") + return [] + return _locked_sweep( + proc=proc, + state_dir=state_dir, + environ=environ, + now=now, + idle_hours=idle_hours, + kill=kill, + dry_run=dry_run, + ) + finally: + os.close(lock_handle) + + +def _sweep_background_shells( + *, + proc: Path, + tracked: dict[str, object], + next_tracked: dict[str, dict[str, object]], + boot_id: str, + now: float, + idle_seconds_required: float, + kill: SignalSender | None, + dry_run: bool, + log_path: Path, +) -> list[dict[str, object]]: + """Independent childless-shell pass with own-CPU and fd/1 evidence.""" + actions: list[dict[str, object]] = [] + for shell in find_background_shells(proc): + pid = int(cast(int, shell["pid"])) + start_ticks = int(cast(int, shell["start_ticks"])) + key = f"background-shell:{boot_id}:{pid}:{start_ticks}" + prior = tracked.get(key) + evidence, evidence_error = _background_shell_evidence_snapshot(shell, proc) + if evidence is None: + refusal = { + "at": now, + "kind": "background-shell", + "pid": pid, + "start_ticks": start_ticks, + "provider_pid": shell["provider_pid"], + "decision": "refused-output", + "reason": evidence_error, + } + if not dry_run: + _log(log_path, refusal) + continue + record: dict[str, object] = { + **evidence, + "last_active": now, + "term_sent": False, + } + if isinstance(prior, dict): + evidence_unchanged = ( + prior.get("output_copies") == evidence.get("output_copies") + and not _background_shell_own_cpu_moved(prior, evidence) + ) + if evidence_unchanged: + record["last_active"] = prior.get("last_active", now) + record["term_sent"] = bool(prior.get("term_sent")) + idle_seconds = now - float(cast(float, record["last_active"])) + overdue = idle_seconds >= idle_seconds_required + if bool(record["term_sent"]) or overdue: + escalate = bool(record["term_sent"]) + signum = signal.SIGKILL if escalate else signal.SIGTERM + action = { + "at": now, + "kind": "background-shell", + "pid": pid, + "start_ticks": start_ticks, + "provider_pid": shell["provider_pid"], + "idle_seconds": int(idle_seconds), + "signal": "SIGKILL" if escalate else "SIGTERM", + "moved_after_term": False, + "dry_run": dry_run, + } + if not dry_run: + try: + delivered, final_proof, delivery_error = ( + _deliver_background_shell( + proc, + shell, + record, + signum, + send_signal=kill if kill is not None else None, + ) + ) + except (ProcessLookupError, OSError) as error: + delivered = False + final_proof = None + delivery_error = f"background shell delivery failed: {error}" + if not delivered: + _log( + log_path, + { + "at": now, + "kind": "background-shell", + "pid": pid, + "start_ticks": start_ticks, + "provider_pid": shell["provider_pid"], + "decision": "refused-final-proof", + "reason": delivery_error, + }, + ) + # A refusal never inherits a TERM commitment. A later + # readable/stable pass must arm a fresh full window. + refreshed, _refresh_error = _background_shell_evidence_snapshot( + shell, proc + ) + if refreshed is not None: + next_tracked[key] = { + **refreshed, + "last_active": now, + "term_sent": False, + } + continue + if not escalate: + record["term_sent"] = True + if isinstance(final_proof, dict): + if final_proof.get("target_exited_before_cont") is True: + action["target_exited_before_cont"] = True + if final_proof.get("target_reaped_before_cont") is True: + action["target_reaped_before_cont"] = True + _log(log_path, action) + actions.append(action) + else: + actions.append(action) + next_tracked[key] = record + return actions + + +def _locked_sweep( + *, + proc: Path, + state_dir: Path, + environ: dict[str, str], + now: float, + idle_hours: float, + kill: SignalSender | None, + dry_run: bool, +) -> list[dict[str, object]]: + state_path = state_dir / "subagent-sweep.state.json" + log_path = state_dir / "subagent-sweep.log" + stored = _load_state(state_path) + tracked = stored.get("tracked") + tracked = tracked if isinstance(tracked, dict) else {} + boot_id = _boot_id(proc) + stored_boot = stored.get("boot_id") + last_pass = stored.get("last_pass") + last_pass = ( + float(last_pass) + if isinstance(last_pass, (int, float)) and not isinstance(last_pass, bool) + else None + ) + # NOTHING may be closed on evidence gathered before the rule that judges + # it was running. `last_active` is not "when the worker stopped" -- it is + # "when a pass last saw its output move", so it carries two assumptions: that the + # passes were closer together than the window, and that the window has not + # moved. Every way either breaks has the same answer -- discard the prior + # observation and watch every worker afresh for a full window under the + # CURRENT rule: + # + # * the window is SHORTER than the one this evidence was gathered under. + # This is install day (lane A F2): the state file on disk was armed by + # the six-hour pass, and the first fifteen-minute pass would otherwise + # find every worker instantly overdue on evidence collected when + # seventeen idle minutes meant nothing at all. It is also every later + # narrowing -- a future default, or the operator lowering + # `<state>/subagent-sweep-minutes` -- so the guarantee holds by the + # rule itself and not by an old state file happening to lack a field. + # + # A window that WIDENS keeps its evidence, because WIDENING CAN ONLY + # CLOSE LESS. That asymmetry is the whole rule in one line: an idle + # clock reads the same under either window, so what a narrowing + # invalidates is not the measurement, it is the CONSENT -- while those + # minutes accrued, nobody had asked for anything to be closed at the + # new window. Widening needs no restart, because under a wider rule a + # worker simply takes longer to become overdue; nothing is ever closed + # sooner than the current rule allows. + # * the window that gathered it is not recorded at all, so it cannot be + # compared. Unknown provenance is untrusted provenance. + # * the machine rebooted -- start ticks are counted from boot, so the + # same pid:start_ticks pair is a DIFFERENT process on the other side of + # one, and a persisted TERM could land on a stranger as a KILL. + # * passes were skipped, e.g. the flock held by the hourly reaper, so the + # gap exceeds the window and nobody watched during it. + # * the clock jumped (F8). Forward is caught by the gap rule; backward is + # caught here. + window_seconds = idle_hours * 3600 + stored_window = stored.get("window_seconds") + if isinstance(stored_window, bool) or not isinstance(stored_window, (int, float)): + stored_window = None + else: + stored_window = float(stored_window) + # A NaN compares false against everything, and a window of zero or less + # is not a window at all -- a pass only ever saves state when the sweep + # is ON, so a stored zero cannot have come from this code and cannot be + # what gathered the evidence. Any of them would slip past the comparison + # below and let the evidence be trusted, which is the direction that + # closes MORE. A window this code cannot compare is a window it does not + # know, so it takes the same answer as one that was never recorded. + if stored_window != stored_window or stored_window <= 0: + stored_window = None + rearmed = "" + if isinstance(stored_boot, str) and stored_boot != boot_id: + rearmed = "the machine rebooted" + elif last_pass is None: + rearmed = "no previous pass under this rule" + elif stored_window is None: + rearmed = "the previous pass did not record the window it was judging by" + elif window_seconds < stored_window: + rearmed = ( + f"the window was shortened from {stored_window:g}s to {window_seconds:g}s" + ) + elif now - last_pass > window_seconds: + rearmed = f"{int(now - last_pass)}s since the previous pass, longer than the window" + elif now < last_pass: + rearmed = "the clock moved backwards" + if rearmed and tracked: + print( + "session inventory: sub-agent sweep starting its clocks fresh -- " + f"{rearmed}; nothing is closed on this pass", + file=sys.stderr, + ) + if rearmed: + tracked = {} + live = find_subagents(proc, environ) + actions: list[dict[str, object]] = [] + next_tracked: dict[str, dict[str, object]] = {} + for agent in live: + pid = int(cast(int, agent["pid"])) + start_ticks = int(cast(int, agent["start_ticks"])) + # The boot is part of the identity, not just part of the document. + # Start ticks are counted FROM BOOT, so `pid:start_ticks` names a + # different process on the other side of one -- and a persisted + # `term_sent` inherited by that stranger is an immediate KILL of a + # brand-new worker (Codex 1/3, reproduced). The whole-document check + # above already discards a previous boot's decisions; putting the boot + # in the key too means a stale entry cannot even be LOOKED UP if that + # check is ever defeated -- an unreadable boot id, a hand-edited state + # file, a future edit to the re-arm chain. Two independent guards, + # because this one ends processes. + key = f"{boot_id}:{pid}:{start_ticks}" + prior = tracked.get(key) + committed = isinstance(prior, dict) and bool(prior.get("term_sent")) + output, output_error = _output_snapshot(agent, environ) + if output is None and not committed: + refusal = { + "at": now, + "pid": pid, + "start_ticks": start_ticks, + "agent_id": agent["agent_id"], + "parent": agent["parent"], + "decision": "refused-output", + "reason": output_error, + } + if not dry_run: + _log(log_path, refusal) + # Unreadable is never an idle answer. Do not retain an older idle + # clock: if output becomes readable later it must be watched for a + # fresh full window before TERM. + continue + record: dict[str, object] = { + **(output or {}), + "last_active": now, + "term_sent": False, + } + moved_after_term = False + if isinstance(prior, dict): + # TERM committed: the decision stands even if the handler writes + # on the way down -- otherwise a graceful-shutdown message resets + # its own clock every pass and the kill never lands. + record["term_sent"] = committed + same_output = ( + output is not None + and prior.get("output_copies") == output.get("output_copies") + ) + if same_output: + record["last_active"] = prior.get("last_active", now) + elif record["term_sent"]: + moved_after_term = output is not None + record["last_active"] = prior.get("last_active", now) + if output is None: + if "output_copies" in prior: + record["output_copies"] = prior["output_copies"] + idle_seconds = now - float(cast(float, record["last_active"])) + overdue = idle_seconds >= idle_hours * 3600 + if bool(record["term_sent"]) or overdue: + escalate = bool(record["term_sent"]) + signum = signal.SIGKILL if escalate else signal.SIGTERM + action = { + "at": now, + "pid": pid, + "start_ticks": start_ticks, + "agent_id": agent["agent_id"], + "parent": agent["parent"], + "idle_seconds": int(idle_seconds), + "signal": "SIGKILL" if escalate else "SIGTERM", + "moved_after_term": moved_after_term, + "dry_run": dry_run, + } + if not dry_run: + delivered = False + signal_output: dict[str, object] | None = None + signal_output_error = "" + signal_vetoed = False + + def evidence_still_armed() -> bool: + nonlocal signal_output, signal_output_error, signal_vetoed + signal_output, signal_output_error = _output_snapshot( + agent, environ + ) + signal_vetoed = not ( + signal_output is not None + and signal_output.get("output_copies") + == record.get("output_copies") + ) + return not signal_vetoed + + try: + if kill is not None: + delivered = _deliver_exact_process( + proc, + pid, + start_ticks, + signum, + still_expected=_still_the_worker, + send_signal=kill, + before_signal=evidence_still_armed, + ) + else: + delivered = _deliver( + proc, + pid, + start_ticks, + signum, + before_signal=evidence_still_armed, + ) + except ProcessLookupError: + action["signal"] = "already-gone" + except OSError as error: + action["signal"] = f"error:{error.errno}" + if signal_vetoed and signal_output is not None: + # The copy set changed inside the guarded signal window. + # Observe that movement exactly as a normal pass would and + # require another stable pass before attempting delivery. + record = { + **signal_output, + "last_active": now, + "term_sent": committed, + } + if committed and isinstance(prior, dict): + record["last_active"] = prior.get("last_active", now) + elif signal_vetoed and signal_output_error: + refusal = { + "at": now, + "pid": pid, + "start_ticks": start_ticks, + "agent_id": agent["agent_id"], + "parent": agent["parent"], + "decision": "refused-output", + "reason": signal_output_error, + } + _log(log_path, refusal) + if not committed: + # Unreadable is never an idle answer. A later readable + # pass must watch a fresh full window before TERM. + continue + if signal_vetoed: + # A callback result means delivery was vetoed. It is not a + # signal action and must not commit TERM or enter the log. + next_tracked[key] = record + continue + # An undelivered TERM is not a TERM: escalation to KILL must + # always follow a signal that actually landed (X20-F5). A + # failed KILL keeps term_sent so the next pass retries KILL. + if delivered and not escalate: + record["term_sent"] = True + _log(log_path, action) + actions.append(action) + next_tracked[key] = record + actions.extend( + _sweep_background_shells( + proc=proc, + tracked=tracked, + next_tracked=next_tracked, + boot_id=boot_id, + now=now, + idle_seconds_required=window_seconds, + kill=kill, + dry_run=dry_run, + log_path=log_path, + ) + ) + if not dry_run: + _save_state( + state_path, + next_tracked, + boot_id=boot_id, + last_pass=now, + window_seconds=window_seconds, + ) + return actions + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["sweep"]) + parser.add_argument("--proc", default="/proc") + parser.add_argument("--state-dir", required=True) + parser.add_argument("--dry-run", action="store_true") + options = parser.parse_args(argv) + state_dir = Path(options.state_dir) + if not state_dir.is_dir(): + print(f"subagent-sweep: no state directory: {state_dir}", file=sys.stderr) + return 2 + actions = sweep( + proc=Path(options.proc), + state_dir=state_dir, + environ=dict(os.environ), + now=time.time(), + dry_run=options.dry_run, + ) + for action in actions: + print(json.dumps(action, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/lib/sessionkit_inventory/terminal.py b/lib/sessionkit_inventory/terminal.py new file mode 100644 index 0000000..a95e24f --- /dev/null +++ b/lib/sessionkit_inventory/terminal.py @@ -0,0 +1,270 @@ +"""Terminal numbers: the retirement ledger and boot-stable selector assignment. + +The registry file primitives live in ``state_io``; this module holds the policy +built on them, which identity owns a number, how long a dead number stays +reserved, and how a live inventory is numbered without disturbing rows. +""" + +from __future__ import annotations + +import json +from pathlib import Path +import time +from typing import Any, Callable, Mapping + +from .common import PROVIDERS, CollectionError, valid_uuid +from .state_io import _read_bounded_owner_file, _terminal_generation_key +from .validation import _missing_shell_generation_is_quarantinable + + +# A dead session's number stays reserved this long before a new session may +# take it (maintainer decision, 2026-08-02: recycle-with-quarantine). A +# conversation recovered inside the window keeps its number via its +# surviving AI binding. +TERMINAL_NUMBER_QUARANTINE_SECONDS = 7 * 86400 + + +def _with_supervisor_identities( + registry: Mapping[str, Any], supervisor_dir: Path | None = None +) -> dict[str, Any]: + """Compatibility shim: the supervisor role is retired. + + Terminal 1 is an ordinary allocation again, so a registry read carries no + extra identities. The facade still routes reads through this name; it now + hands the registry back unchanged. + """ + return dict(registry) + + +def _terminal_ai_key(item: Mapping[str, Any]) -> str | None: + identity = item.get("identity") + provider = item.get("provider") + if ( + provider in PROVIDERS + and isinstance(identity, Mapping) + and identity.get("confidence") == "exact" + ): + uuid = valid_uuid(identity.get("uuid")) + if uuid: + return f"ai:{provider}:{uuid}" + hint = item.get("_terminal_identity_hint") + if isinstance(hint, Mapping) and hint.get("provider") in PROVIDERS: + uuid = valid_uuid(hint.get("uuid")) + if uuid: + return f"ai:{hint['provider']}:{uuid}" + return None + + +def _read_terminal_retirements( + path: Path, + boot_id: str, + *, + max_bytes: int, +) -> dict[int, float]: + """Retirement ledger: number -> unix time its last session disappeared. + + Kept OUTSIDE the registry file: pinned schema-v1 releases reject unknown + registry keys, and standing windows keep running them. A boot change + resets the ledger with the registry (numbers restart at 1 anyway). + """ + raw_bytes = _read_bounded_owner_file( + path, + label="terminal retirement ledger", + max_bytes=max_bytes, + exact_mode=0o600, + allow_missing=True, + ) + if raw_bytes is None: + return {} + try: + payload = json.loads(raw_bytes.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + return {} + if not isinstance(payload, Mapping) or payload.get("boot_id") != boot_id: + return {} + raw = payload.get("retired") + result: dict[int, float] = {} + if isinstance(raw, Mapping): + for key, value in raw.items(): + if ( + isinstance(key, str) + and key.isdigit() + and int(key) > 0 + and isinstance(value, (int, float)) + and not isinstance(value, bool) + and value > 0 + ): + result[int(key)] = float(value) + return result + + +def _terminal_retirement_payload( + retired: Mapping[int, float], + boot_id: str, + *, + schema_version: int, +) -> dict[str, Any]: + return { + "schema_version": schema_version, + "boot_id": boot_id, + "retired": {str(k): retired[k] for k in sorted(retired)}, + } + + +def apply_terminal_numbers( + inventory: dict[str, Any], + registry: dict[str, Any], + *, + boot_id: str, + allocate: bool, + retired: dict[int, float] | None = None, + current_time: float | None = None, + supervisor_key: str | None = None, + previous_supervisor_key: str | None = None, + validate_registry: Callable[[Any, str], dict[str, Any]], + schema_version: int, + quarantine_seconds: float, +) -> dict[str, Any]: + """Apply boot-stable selectors without changing contiguous internal rows. + + ``supervisor_key``/``previous_supervisor_key`` are accepted and ignored: + the supervisor role is retired and terminal 1 is an ordinary number. + """ + del supervisor_key, previous_supervisor_key + checked = validate_registry(registry, boot_id) + bindings: dict[str, int] = dict(checked["bindings"]) + previously_bound = set(bindings.values()) + next_number = checked["next_number"] + recycle = allocate and retired is not None + now = current_time if current_time is not None else time.time() + if recycle: + assert retired is not None + # Bindings whose number finished its quarantine are pruned; the + # number becomes allocatable below. Live numbers are never here. + expired = { + number + for number, stamp in retired.items() + if now - stamp >= quarantine_seconds + } + if expired: + for key in [k for k, v in bindings.items() if v in expired]: + del bindings[key] + for expired_number in expired: + retired.pop(expired_number, None) + reserved_numbers: set[int] = set(bindings.values()) + active_numbers: set[int] = set() + for item in inventory.get("sessions", ()): + if not isinstance(item, dict): + raise CollectionError("cannot number a non-object managed session") + generation_key = _terminal_generation_key(inventory, item, boot_id) + ai_key = _terminal_ai_key(item) + if generation_key is None: + if _missing_shell_generation_is_quarantinable(item): + item["terminal_number"] = None + item["mutation_allowed"] = False + item["mutation_rejection_reason"] = "missing-shell-generation" + item.pop("_terminal_identity_hint", None) + continue + if allocate: + # One row that cannot be numbered used to refuse the WHOLE + # collection. That took the machine read-only: every close, + # switch and repair goes through the guard snapshot, and + # arming a launch needs one that is live, fresh and + # warning-free, so a single unprovable session stopped every + # session on the box, with a message that named none of them. + # Quarantine the row that cannot be proven, name it, and let + # the rest of the estate work. + item["terminal_number"] = None + item["mutation_allowed"] = False + item["mutation_rejection_reason"] = "unprovable-generation" + item.pop("_terminal_identity_hint", None) + continue + ai_number = bindings.get(ai_key) if ai_key else None + generation_number = bindings.get(generation_key) if generation_key else None + generation_ai_key = ( + next( + ( + key + for key, value in bindings.items() + if key.startswith("ai:") and value == generation_number + ), + None, + ) + if generation_number is not None + else None + ) + number: int | None + if ai_number is not None: + # Exact AI identity is the continuity proof when a recovered + # conversation appears in a newly created shpool generation. The + # provisional number remains consumed, but the new generation is + # rebound to the conversation's established terminal number. + number = ai_number + elif generation_number is not None and ( + ai_key is None or generation_ai_key is None + ): + number = generation_number + elif generation_number is not None and not allocate: + # The generation was already promoted to another exact + # conversation. A read-only guard must not expose that number as + # belonging to this unrelated identity. + number = None + elif allocate and recycle: + # Lowest free number: anything not reserved by a live or + # quarantined binding. Keeps numbers small forever. + number = 1 + while number in reserved_numbers: + number += 1 + reserved_numbers.add(number) + if number >= next_number: + next_number = number + 1 + elif allocate: + number = next_number + while number in reserved_numbers: + number += 1 + reserved_numbers.add(number) + next_number = number + 1 + else: + number = None + if number is not None: + if number in active_numbers: + raise CollectionError( + "terminal number registry maps two active sessions to one number" + ) + active_numbers.add(number) + if allocate: + assert generation_key is not None + for key, value in tuple(bindings.items()): + if ( + key.startswith("generation:") + and value == number + and key != generation_key + ): + del bindings[key] + bindings[generation_key] = number + if ai_key: + bindings[ai_key] = number + item["terminal_number"] = number + item.pop("_terminal_identity_hint", None) + if recycle: + assert retired is not None + # Stamp newly dead numbers; clear stamps on anything alive again + # (an exact recovery inside the quarantine window). + bound = set(bindings.values()) + # Include bindings dropped during this pass so their old numbers + # receive the normal quarantine. + for number in (bound | previously_bound) - active_numbers: + retired.setdefault(number, now) + for number in active_numbers: + retired.pop(number, None) + # Legacy invariant for pinned releases: next_number stays above + # every stored binding even after pruning shrank the map. + top = max(bound, default=0) + if next_number <= top: + next_number = top + 1 + return { + "schema_version": schema_version, + "boot_id": boot_id, + "next_number": next_number, + "bindings": dict(sorted(bindings.items())), + } diff --git a/lib/sessionkit_inventory/transcript_text.py b/lib/sessionkit_inventory/transcript_text.py new file mode 100644 index 0000000..f345245 --- /dev/null +++ b/lib/sessionkit_inventory/transcript_text.py @@ -0,0 +1,430 @@ +"""Render a provider transcript (.jsonl) into clean readable text. + +The conversation transcript is the one history that survives everything: it +is written by the provider itself, never braided by terminal repaints, and +exists even for sessions whose terminal output was never recorded. `sp +history` falls back to this view when a session has no journal (2026-08-12: +recording was silently off for two weeks, so every session from that window +recalls through here). + +Both providers are read here. Claude writes one transcript per conversation; +Codex writes a rollout of the same conversation in its own shape. The fallback +existed for Claude only, so a Codex session with no recording answered "no +history" while its rollout sat on disk unread. +""" +from __future__ import annotations + +import glob +import hashlib +import json +import os +import re +import sys +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +USER_RULE = "═" * 60 +MAX_RESULT_LINES = 12 +MAX_AGENT_RESULT_CHARS = 8192 +SCAFFOLD_TAGS = ( + "recommended_plugins", + "permissions instructions", + "apps_instructions", + "skills_instructions", + "environment_context", + "INSTRUCTIONS", + "codex_internal_context", + "hook_prompt", +) + + +def _user_text(body: str) -> list[str]: + return ["\n══ OPERATOR " + USER_RULE, body.strip()] + + +def _text_parts(content: Any) -> Iterator[str]: + """Yield readable text without exposing encrypted or metadata blocks.""" + if isinstance(content, str): + yield content + elif isinstance(content, list): + for inner in content: + if isinstance(inner, dict) and inner.get("type") in ( + "input_text", "output_text", "text" + ): + text = inner.get("text") + if isinstance(text, str): + yield text + elif isinstance(content, dict): + yield from _text_parts(content.get("content") or content.get("output")) + + +def _result_text(content: Any, *, max_chars: int | None = None) -> str | None: + parts: list[str] = [] + retained = 0 + clipped = False + for part in _text_parts(content): + if max_chars is None: + parts.append(part) + continue + available = max_chars - retained + if available <= 0: + clipped = True + break + if len(part) > available: + parts.append(part[:available]) + clipped = True + break + parts.append(part) + retained += len(part) + text = "\n".join(parts).rstrip() + if not text: + return None + if clipped: + text += "\n … (more text)" + lines = text.split("\n") + if len(lines) > MAX_RESULT_LINES: + hidden = len(lines) - MAX_RESULT_LINES + lines = lines[:MAX_RESULT_LINES] + [f" … ({hidden} more lines)"] + return "\n".join(" │ " + line for line in lines) + + +def _tool_text(name: Any, tool_input: Any) -> str: + clean_name = name if isinstance(name, str) and name.strip() else "?" + detail = "" + if isinstance(tool_input, str): + try: + decoded = json.loads(tool_input) + except ValueError: + decoded = None + if isinstance(decoded, dict): + tool_input = decoded + elif tool_input.strip(): + detail = tool_input.strip().replace("\n", " ")[:160] + if isinstance(tool_input, dict): + for key in ( + "command", "cmd", "description", "file_path", "pattern", + "prompt", "query", "question", + ): + value = tool_input.get(key) + if isinstance(value, str) and value.strip(): + detail = value.strip().replace("\n", " ")[:160] + break + return f"⏺ {clean_name}: {detail}" if detail else f"⏺ {clean_name}" + + +def _block_text(block: dict) -> str | None: + kind = block.get("type") + if kind == "text": + return block.get("text", "") + if kind == "tool_use": + return _tool_text(block.get("name"), block.get("input")) + if kind == "tool_result": + return _result_text(block.get("content")) + return None + + +def render_transcript_entry(entry: object) -> list[str]: + """Render one decoded Claude JSONL record, or reject its shape.""" + out: list[str] = [] + if not isinstance(entry, dict): + return out + message = entry.get("message") or {} + if not isinstance(message, dict): + return out + content = message.get("content") + etype = entry.get("type") + if etype == "user": + if isinstance(content, str) and content.strip(): + out.extend(_user_text(content)) + elif isinstance(content, list): + texts = [b.get("text", "") for b in content + if isinstance(b, dict) and b.get("type") == "text"] + if any(t.strip() for t in texts): + out.extend(_user_text("\n".join(texts))) + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_result": + text = _block_text(block) + if text: + out.append(text) + elif etype == "assistant" and isinstance(content, list): + for block in content: + if not isinstance(block, dict): + continue + text = _block_text(block) + if text is None: + continue + if block.get("type") == "text": + out.append("\n● " + text.strip()) + else: + out.append(text) + return out + + +def render_transcript(path: Path) -> list[str]: + out: list[str] = [] + with open(path, encoding="utf-8") as handle: + for raw in handle: + try: + entry = json.loads(raw) + except ValueError: + continue + out.extend(render_transcript_entry(entry)) + return out + + +def _strip_codex_scaffolding(body: str) -> str: + """Remove harness preambles without discarding the person's prompt.""" + text = body.lstrip() + while text: + if text.startswith("# AGENTS.md instructions"): + heading = re.match( + r"^# AGENTS\.md instructions(?: for [^\n]*)?[ \t]*\n+[ \t]*", + text, + ) + if heading: + instructions = re.match( + r"^<INSTRUCTIONS>.*?</INSTRUCTIONS>\s*", + text[heading.end():], + flags=re.DOTALL, + ) + if instructions: + text = text[heading.end() + instructions.end():].lstrip() + continue + before = text + for tag in SCAFFOLD_TAGS: + pattern = ( + rf"^<{re.escape(tag)}(?:[ \t][^\n>]*)?>" + rf".*?</{re.escape(tag)}>\s*" + ) + text, count = re.subn(pattern, "", text, count=1, flags=re.DOTALL) + if count: + text = text.lstrip() + break + if text == before: + break + return text.strip() + + +def _rollout_text(payload: dict) -> list[str]: + """One readable line-group from a Codex rollout record.""" + if payload.get("type") != "message": + return [] + role = payload.get("role") + if role not in ("user", "assistant"): + return [] + parts: list[str] = [] + for block in payload.get("content") or (): + if not isinstance(block, dict): + continue + if block.get("type") in ("input_text", "output_text", "text"): + text = block.get("text") + if isinstance(text, str) and text.strip(): + parts.append(text) + body = "\n".join(parts).strip() + if not body: + return [] + if role == "user": + body = _strip_codex_scaffolding(body) + return _user_text(body) if body else [] + return ["\n● " + body] + + +def _rollout_tool(payload: dict) -> str | None: + kind = payload.get("type") + if kind in ("function_call", "custom_tool_call"): + tool_input = payload.get("arguments") + if kind == "custom_tool_call": + tool_input = payload.get("input") + return _tool_text(payload.get("name"), tool_input) + if kind in ("function_call_output", "custom_tool_call_output"): + return _result_text(payload.get("output")) + if isinstance(kind, str) and kind.endswith("_call"): + return _tool_text(payload.get("name") or kind.removesuffix("_call"), payload) + if isinstance(kind, str) and kind.endswith("_call_output"): + return _result_text(payload.get("output")) + return None + + +def _rollout_agent_result(payload: dict) -> list[str]: + """Render the readable result a sub-agent sent back to its parent.""" + if payload.get("type") != "agent_message": + return [] + readable = "\n".join(_text_parts(payload.get("content"))).strip() + if not readable: + return [] + envelope = re.match( + r"^Message Type: FINAL_ANSWER\n.*?\nPayload:\n(.*)$", + readable, + flags=re.DOTALL, + ) + if not envelope or not envelope.group(1).strip(): + return [] + result = _result_text(envelope.group(1), max_chars=MAX_AGENT_RESULT_CHARS) + if not result: + return [] + author = payload.get("author") + label = "⏺ sub-agent result" + if isinstance(author, str) and author.strip(): + label += f": {author.strip()}" + return [label, result] + + +def _rollout_payload(payload: dict) -> list[str]: + rendered = _rollout_text(payload) + if rendered: + return rendered + rendered = _rollout_agent_result(payload) + if rendered: + return rendered + tool = _rollout_tool(payload) + return [tool] if tool else [] + + +def _message_key(payload: dict) -> bytes | None: + """Identity used only to avoid replaying a compacted message twice.""" + if payload.get("type") != "message": + return None + role = payload.get("role") + if not isinstance(role, str): + return None + digest = hashlib.blake2b(digest_size=16) + digest.update(role.encode("utf-8", "replace")) + found = False + for text in _text_parts(payload.get("content")): + encoded = text.encode("utf-8", "replace") + digest.update(len(encoded).to_bytes(8, "big")) + digest.update(encoded) + found = True + return digest.digest() if found else None + + +def render_rollout(path: Path) -> list[str]: + """Codex's rollout, rendered in the same shape as a Claude transcript.""" + out: list[str] = [] + precompact_spans: dict[bytes, list[tuple[int, int]]] = {} + compacted_seen = False + continuation_pending = False + with open(path, encoding="utf-8") as handle: + for raw in handle: + try: + record = json.loads(raw) + except ValueError: + continue + if not isinstance(record, dict): + continue + if record.get("type") == "compacted" and not compacted_seen: + compact_payload = record.get("payload") + history = ( + compact_payload.get("replacement_history") + if isinstance(compact_payload, dict) else None + ) + if not isinstance(history, list): + continue + compacted: list[str] = [] + duplicate_spans: list[tuple[int, int]] = [] + for payload in history: + if not isinstance(payload, dict): + continue + rendered = _rollout_payload(payload) + if rendered: + compacted.extend(rendered) + key = _message_key(payload) + spans = precompact_spans.get(key) if key else None + if spans: + duplicate_spans.append(spans.pop(0)) + if not compacted: + continue + removed = { + index + for start, end in duplicate_spans + for index in range(start, end) + } + remaining = [ + line for index, line in enumerate(out) if index not in removed + ] + out = ["\n══ COMPACTED HISTORY " + USER_RULE, *compacted] + if remaining: + out.extend(["\n══ CONTINUED HISTORY " + USER_RULE, *remaining]) + else: + continuation_pending = True + compacted_seen = True + continue + if record.get("type") != "response_item": + continue + payload = record.get("payload") + if not isinstance(payload, dict): + continue + rendered = _rollout_payload(payload) + if not rendered: + continue + if continuation_pending: + out.append("\n══ CONTINUED HISTORY " + USER_RULE) + continuation_pending = False + start = len(out) + out.extend(rendered) + if not compacted_seen: + key = _message_key(payload) + if key: + precompact_spans.setdefault(key, []).append((start, len(out))) + return out + + +def find_rollout(uuid: str) -> Path | None: + """Locate a Codex rollout across every local profile root.""" + # `sp history` runs this file as a script, so the package it lives in is + # not importable by name until its parent is on the path. + if __package__: + from . import transcripts + else: + sys.path.insert(0, os.fspath(Path(__file__).resolve().parents[1])) + from sessionkit_inventory import transcripts + + return transcripts.locate_transcript("codex", uuid) + + +def find_transcript(uuid: str) -> Path | None: + """Locate a conversation transcript across every local profile root.""" + home = Path.home() + roots = [home / ".claude"] + account_root = Path( + os.environ.get("SESSION_KIT_ACCOUNT_ROOT", + home / ".local/share/session-kit/accounts") + ) + roots.extend(sorted(account_root.glob("claude/*"))) + best: Path | None = None + for root in roots: + for candidate in glob.glob(str(root / "projects" / "*" / f"{uuid}.jsonl")): + path = Path(candidate) + if best is None or path.stat().st_mtime > best.stat().st_mtime: + best = path + return best + + +def main(argv: list[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + provider = "claude" + if len(args) == 2 and args[0] in ("claude", "codex"): + provider, args = args[0], args[1:] + if len(args) != 1: + print( + "usage: transcript_text.py [claude|codex] <conversation-uuid-or-jsonl-path>", + file=sys.stderr, + ) + return 2 + target = args[0] + path = Path(target) + if not path.is_file(): + found = find_rollout(target) if provider == "codex" else find_transcript(target) + if found is None: + print(f"no transcript found for {target}", file=sys.stderr) + return 1 + path = found + render = render_rollout if provider == "codex" else render_transcript + for line in render(path): + print(line) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lib/sessionkit_inventory/transcripts.py b/lib/sessionkit_inventory/transcripts.py new file mode 100644 index 0000000..aa5dbd6 --- /dev/null +++ b/lib/sessionkit_inventory/transcripts.py @@ -0,0 +1,179 @@ +"""Where a conversation's own record lives, whichever profile wrote it. + +One resolver, used by everything that has to answer "can this machine still +read that conversation": the closed-session list (a conversation whose +transcript is gone is not restorable and stops being offered), the history +fallback, and the doctor's reachability check. + +The measured defect this answers: a live session running under a rotated +account profile had its transcript on this disk, and the tool asked for it +reported "no transcript found", because one resolver knew a root the other did +not. Every root the kit may have written to is searched here, once. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import stat +from typing import Iterator, Mapping + +from .common import valid_uuid + +MAX_ROLLOUT_FILES = 20_000 + + +def _home(environ: Mapping[str, str]) -> Path: + return Path(environ.get("HOME") or Path.home()) + + +def _account_root(environ: Mapping[str, str]) -> Path: + return Path( + environ.get("SESSION_KIT_ACCOUNT_ROOT") + or _home(environ) / ".local/share/session-kit/accounts" + ) + + +def claude_roots(environ: Mapping[str, str] | None = None) -> list[Path]: + """Every Claude profile root this machine may have written to.""" + env = environ if environ is not None else os.environ + roots = [_home(env) / ".claude"] + explicit = env.get("CLAUDE_CONFIG_DIR") + if explicit: + roots.append(Path(explicit)) + try: + roots.extend(sorted((_account_root(env) / "claude").glob("*"))) + except OSError: + pass + return [root for root in roots if root.is_dir()] + + +def codex_roots(environ: Mapping[str, str] | None = None) -> list[Path]: + """Every Codex home this machine may have written rollouts to.""" + env = environ if environ is not None else os.environ + roots = [_home(env) / ".codex"] + for name in ("SESSION_KIT_CODEX_HOME", "CODEX_HOME"): + explicit = env.get(name) + if explicit: + roots.append(Path(explicit)) + try: + roots.extend(sorted((_account_root(env) / "codex").glob("*"))) + except OSError: + pass + return [root for root in roots if root.is_dir()] + + +def _newest(paths: Iterator[Path]) -> Path | None: + best: Path | None = None + best_mtime = -1.0 + for path in paths: + try: + if path.is_symlink() or not path.is_file(): + continue + mtime = path.stat().st_mtime + except OSError: + continue + if mtime > best_mtime: + best, best_mtime = path, mtime + return best + + +def _claude_transcript(uuid: str, environ: Mapping[str, str]) -> Path | None: + def candidates() -> Iterator[Path]: + for root in claude_roots(environ): + try: + yield from (root / "projects").glob(f"*/{uuid}.jsonl") + except OSError: + continue + + return _newest(candidates()) + + +def _codex_rollout(uuid: str, environ: Mapping[str, str]) -> Path | None: + def candidates() -> Iterator[Path]: + seen = 0 + for root in codex_roots(environ): + sessions = root / "sessions" + if not sessions.is_dir(): + continue + try: + for directory, names, files in os.walk(sessions): + names[:] = sorted( + name for name in names if not name.startswith(".") + ) + for name in sorted(files): + seen += 1 + if seen > MAX_ROLLOUT_FILES: + return + if ( + uuid in name + and name.startswith("rollout-") + and name.endswith(".jsonl") + ): + yield Path(directory) / name + except OSError: + continue + + return _newest(candidates()) + + +def locate_transcript( + provider: str, uuid: str, *, environ: Mapping[str, str] | None = None +) -> Path | None: + """The conversation's own record, or None when this machine cannot read it.""" + env = environ if environ is not None else os.environ + exact = valid_uuid(uuid) + if not exact: + return None + if provider == "claude": + return _claude_transcript(exact, env) + if provider == "codex": + return _codex_rollout(exact, env) + return None + + +def transcript_snapshot( + provider: str, uuid: str, *, environ: Mapping[str, str] | None = None +) -> dict[str, object] | None: + """The sweep's exact transcript-movement evidence for one conversation. + + Path discovery stays with the kit's existing provider-neutral resolver. The + selected path is then opened without following a symlink, read once to + prove it is readable, and statted through that open descriptor. This is + the same size/mtime evidence family used by the shipped sub-agent sweep; + missing, unreadable and irregular files are no evidence of idleness. + """ + env = environ if environ is not None else os.environ + path = locate_transcript(provider, uuid, environ=env) + if path is None: + return None + return stable_transcript_snapshot(path) + + +def stable_transcript_snapshot(path: Path) -> dict[str, object] | None: + """Snapshot one exact path with the shipped sweep's stable-open rules.""" + flags = ( + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_NONBLOCK", 0) + ) + try: + descriptor = os.open(path, flags) + except OSError: + return None + try: + info = os.fstat(descriptor) + if not stat.S_ISREG(info.st_mode): + return None + os.read(descriptor, 1) + info = os.fstat(descriptor) + except OSError: + return None + finally: + os.close(descriptor) + return { + "path": os.fspath(path), + "size": info.st_size, + "mtime_ns": info.st_mtime_ns, + } diff --git a/lib/sessionkit_inventory/validation.py b/lib/sessionkit_inventory/validation.py new file mode 100644 index 0000000..2481205 --- /dev/null +++ b/lib/sessionkit_inventory/validation.py @@ -0,0 +1,398 @@ +"""Strict validation of snapshots and operator-supplied inventory input.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Callable, Mapping + +from .common import ( + PROVIDERS, + CollectionError, + clean_text, + shpool_id_mutation_policy, + valid_uuid, +) + + +def _missing_shell_generation_is_quarantinable( + item: Mapping[str, Any], +) -> bool: + """Recognize only an inert disconnected shpool row with no live shell.""" + identity = item.get("identity") + recovery = item.get("recovery") + raw_id = item.get("shpool_id_raw") + started = item.get("started_at_unix_ms") + diagnostics = item.get("diagnostics") + expected_diagnostic = ( + f"expected one daemon child for {raw_id!r}, found 0" + if isinstance(raw_id, str) + else "" + ) + return ( + item.get("provider") == "unknown" + and item.get("display_provider") == "unknown" + and item.get("availability") == "ready" + and clean_text(item.get("shpool_status"), 32).casefold() == "disconnected" + and isinstance(raw_id, str) + and bool(raw_id) + and raw_id == item.get("shpool_id") + and shpool_id_mutation_policy(raw_id) == (True, None) + and isinstance(started, int) + and not isinstance(started, bool) + and started > 0 + and item.get("shpool_shell") is None + and isinstance(identity, Mapping) + and identity.get("confidence") == "unknown" + and identity.get("uuid") is None + and identity.get("pid") is None + and identity.get("process_start_ticks") is None + and identity.get("provenance") == "none" + and isinstance(recovery, Mapping) + and recovery.get("available") is False + and recovery.get("provider") is None + and recovery.get("uuid") is None + and not isinstance(item.get("_terminal_identity_hint"), Mapping) + and isinstance(diagnostics, list) + and expected_diagnostic in diagnostics + ) + + +def _missing_shell_generation_is_quarantined( + item: Mapping[str, Any], +) -> bool: + return ( + _missing_shell_generation_is_quarantinable(item) + and item.get("terminal_number") is None + and item.get("mutation_allowed") is False + and item.get("mutation_rejection_reason") == "missing-shell-generation" + ) + + +def _generation_is_quarantined(item: Mapping[str, Any]) -> bool: + """Either shape of "this one row could not be numbered". + + The first is the inert daemon-listed husk with no shell at all. The second + is any other row whose generation could not be proven: numbering used to + raise for the WHOLE estate on one of those, which took every session on + the machine read-only, no close, no switch, and no launch, because arming + a launch needs a clean snapshot. One row is now unactionable and named + instead, and the other rows keep working. + """ + return _missing_shell_generation_is_quarantined(item) or ( + item.get("terminal_number") is None + and item.get("mutation_allowed") is False + and item.get("mutation_rejection_reason") == "unprovable-generation" + ) + + +def guard_live_inventory( + inventory: Mapping[str, Any], + *, + schema_version: int, +) -> bool: + """Validate a live mutation snapshot without rejecting unrelated unknown rows. + + Unlike ``strict_live_inventory``, this predicate permits an unresolved + provider session. It still requires an exact daemon generation, an exact + shell generation for every managed row, and exact provider identity for + every row whose provider is known. + """ + + def positive_int(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value > 0 + + if not isinstance(inventory, Mapping): + return False + generation = inventory.get("daemon_generation") + warnings = inventory.get("warnings") + sessions = inventory.get("sessions") + outside_agents = inventory.get("outside_agents") + if ( + inventory.get("schema_version") != schema_version + or inventory.get("source") != "live" + or inventory.get("stale") is not False + or not isinstance(warnings, list) + or warnings + or not isinstance(generation, Mapping) + or not positive_int(generation.get("pid")) + or not positive_int(generation.get("process_start_ticks")) + or not isinstance(sessions, list) + or not isinstance(outside_agents, list) + ): + return False + + rows: set[int] = set() + terminal_numbers: set[int] = set() + raw_ids: set[str] = set() + managed_provider_uuids: set[tuple[str, str]] = set() + for item in sessions: + if not isinstance(item, Mapping): + return False + row = item.get("row") + raw_id = item.get("shpool_id_raw") + identity = item.get("identity") + shell = item.get("shpool_shell") + provider = item.get("provider") + terminal_number = item.get("terminal_number") + if _generation_is_quarantined(item): + if ( + isinstance(row, bool) + or not isinstance(row, int) + or row <= 0 + or row in rows + or not isinstance(raw_id, str) + or raw_id in raw_ids + ): + return False + rows.add(row) + raw_ids.add(raw_id) + continue + mutation_allowed, mutation_reason = shpool_id_mutation_policy(raw_id) + if ( + isinstance(row, bool) + or not isinstance(row, int) + or row <= 0 + or row in rows + or not isinstance(raw_id, str) + or not raw_id + or raw_id in raw_ids + or raw_id != item.get("shpool_id") + or item.get("mutation_allowed") is not mutation_allowed + or item.get("mutation_rejection_reason") != mutation_reason + or not isinstance(identity, Mapping) + or not isinstance(shell, Mapping) + or not positive_int(shell.get("pid")) + or not positive_int(shell.get("process_start_ticks")) + ): + return False + if terminal_number is not None and ( + isinstance(terminal_number, bool) + or not isinstance(terminal_number, int) + or terminal_number <= 0 + or terminal_number in terminal_numbers + ): + return False + if isinstance(terminal_number, int): + terminal_numbers.add(terminal_number) + rows.add(row) + raw_ids.add(raw_id) + + if provider == "unknown": + if ( + identity.get("confidence") != "unknown" + or identity.get("uuid") is not None + or identity.get("pid") != shell.get("pid") + or identity.get("process_start_ticks") + != shell.get("process_start_ticks") + ): + return False + continue + if provider not in {"claude", "codex", "shell"}: + return False + if ( + identity.get("confidence") != "exact" + or not positive_int(identity.get("pid")) + or not positive_int(identity.get("process_start_ticks")) + ): + return False + if provider in {"claude", "codex"}: + uuid = valid_uuid(identity.get("uuid")) + if not uuid: + return False + key = (provider, uuid) + if key in managed_provider_uuids: + return False + managed_provider_uuids.add(key) + elif identity.get("uuid") is not None: + return False + + outside_provider_uuids: set[tuple[str, str]] = set() + for item in outside_agents: + if not isinstance(item, Mapping): + return False + provider = item.get("provider") + identity = item.get("identity") + if ( + provider not in PROVIDERS + or not isinstance(identity, Mapping) + or identity.get("confidence") != "exact" + or not positive_int(identity.get("pid")) + or not positive_int(identity.get("process_start_ticks")) + ): + return False + if not isinstance(provider, str): + return False + uuid = valid_uuid(identity.get("uuid")) + if not uuid: + return False + key = (provider, uuid) + # A provider may be resumed outside shpool while its managed copy is + # still alive. That is unsafe for conversation-level recovery, but it + # does not make an unrelated, proof-bound shpool session ambiguous. + # Keep rejecting duplicate outside rows while allowing the guard used + # by exact session actions and new-session generation capture to work. + if key in outside_provider_uuids: + return False + outside_provider_uuids.add(key) + + return rows == set(range(1, len(sessions) + 1)) + + +def strict_live_inventory( + inventory: Mapping[str, Any], + *, + guard_inventory: Callable[[Mapping[str, Any]], bool], +) -> bool: + """True only for a complete, unambiguous live mutation guard snapshot.""" + if not guard_inventory(inventory): + return False + generation = inventory.get("daemon_generation") + outside_agents = inventory.get("outside_agents") + if ( + inventory.get("source") != "live" + or inventory.get("stale") is not False + or inventory.get("warnings") + or not isinstance(generation, Mapping) + or not isinstance(generation.get("pid"), int) + or generation.get("pid", 0) <= 0 + or not isinstance(generation.get("process_start_ticks"), int) + or generation.get("process_start_ticks", 0) <= 0 + or not isinstance(outside_agents, list) + ): + return False + exact_provider_uuids: set[tuple[str, str]] = set() + for item in inventory.get("sessions", ()): + identity = item.get("identity") + shell = item.get("shpool_shell") + raw_id = item.get("shpool_id_raw") + provider = item.get("provider") + mutation_allowed, mutation_reason = shpool_id_mutation_policy(raw_id) + if ( + not isinstance(raw_id, str) + or raw_id != item.get("shpool_id") + or item.get("mutation_allowed") is not mutation_allowed + or item.get("mutation_rejection_reason") != mutation_reason + or provider not in {"claude", "codex", "shell"} + or not isinstance(identity, Mapping) + or identity.get("confidence") != "exact" + or not isinstance(identity.get("pid"), int) + or not isinstance(identity.get("process_start_ticks"), int) + or not isinstance(shell, Mapping) + or not isinstance(shell.get("pid"), int) + or not isinstance(shell.get("process_start_ticks"), int) + ): + return False + if provider in PROVIDERS: + if not isinstance(provider, str): + return False + uuid = valid_uuid(identity.get("uuid")) + if not uuid: + return False + key = (provider, uuid) + if key in exact_provider_uuids: + return False + exact_provider_uuids.add(key) + elif identity.get("uuid") is not None: + return False + for item in outside_agents: + if not isinstance(item, Mapping): + return False + provider = item.get("provider") + identity = item.get("identity") + if ( + provider not in PROVIDERS + or not isinstance(identity, Mapping) + or identity.get("confidence") != "exact" + or isinstance(identity.get("pid"), bool) + or not isinstance(identity.get("pid"), int) + or identity.get("pid", 0) <= 0 + or isinstance(identity.get("process_start_ticks"), bool) + or not isinstance(identity.get("process_start_ticks"), int) + or identity.get("process_start_ticks", 0) <= 0 + ): + return False + if not isinstance(provider, str): + return False + uuid = valid_uuid(identity.get("uuid")) + if not uuid: + return False + key = (provider, uuid) + if key in exact_provider_uuids: + return False + exact_provider_uuids.add(key) + return True + + +def load_inventory_input( + path: str | Path, + *, + schema_version: int, + load_json_file: Callable[[Path], Any], +) -> dict[str, Any]: + """Load a previously frozen v1 snapshot for TOCTOU-safe render/lookup.""" + source = Path(path).expanduser() + try: + value = load_json_file(source) + except (OSError, ValueError) as exc: + raise CollectionError(f"cannot read inventory input {source}: {exc}") from exc + if ( + not isinstance(value, Mapping) + or value.get("schema_version") != schema_version + or not isinstance(value.get("sessions"), list) + or not isinstance(value.get("outside_agents"), list) + ): + raise CollectionError(f"inventory input {source} is not a valid v1 snapshot") + names: set[str] = set() + rows: set[int] = set() + terminal_numbers: set[int] = set() + has_terminal_numbers = any( + isinstance(item, Mapping) + and isinstance(item.get("terminal_number"), int) + and not isinstance(item.get("terminal_number"), bool) + and item.get("terminal_number", 0) > 0 + for item in value["sessions"] + ) + for item in value["sessions"]: + if not isinstance(item, Mapping): + raise CollectionError(f"inventory input {source} has a non-object session") + name = item.get("shpool_id") + raw_name = item.get("shpool_id_raw", name) + row = item.get("row") + terminal_number = item.get("terminal_number") + if ( + not isinstance(name, str) + or not name + or raw_name != name + or not isinstance(row, int) + or row <= 0 + or name in names + or row in rows + or ( + has_terminal_numbers + and _missing_shell_generation_is_quarantinable(item) + and not _missing_shell_generation_is_quarantined(item) + ) + or ( + has_terminal_numbers + and terminal_number is None + and not _generation_is_quarantined(item) + ) + or ( + has_terminal_numbers + and terminal_number is not None + and ( + isinstance(terminal_number, bool) + or not isinstance(terminal_number, int) + or terminal_number <= 0 + or terminal_number in terminal_numbers + ) + ) + ): + raise CollectionError( + f"inventory input {source} has invalid or duplicate selectors" + ) + names.add(name) + rows.add(row) + if has_terminal_numbers and terminal_number is not None: + terminal_numbers.add(terminal_number) + return dict(value) diff --git a/lib/sessionkit_inventory/worker_model.py b/lib/sessionkit_inventory/worker_model.py new file mode 100644 index 0000000..63ae95c --- /dev/null +++ b/lib/sessionkit_inventory/worker_model.py @@ -0,0 +1,323 @@ +"""Provider-specific worker model validation, and whether it is really served. + +Extracted verbatim from the retired ``sessionkit_supervisor.intake`` module +(2026-08-12 one-door rebuild): the ``validate-worker-model`` verb sits on the +live launch path, ``bashrc/shpool.bashrc`` and ``sp new`` both call it before +starting a provider, so the gate survives the supervisor's deletion. + +That gate only ever checked the *shape* of an identifier. A model can be +perfectly well-formed, accepted by the provider's command line, and quietly +served by something smaller, which is how a session runs for an hour on a +model nobody chose. So there is a second question here, asked before a session +starts and never answered by guessing: + + is this model actually served on this machine? + +Two local sources answer it, in this order: + +* **What was served last time.** When something reads the model a session is + really on, it records the pair here with :func:`record_served`. A recorded + answer that differs from the request is the silent downgrade, by name. +* **The machine's own model list** (``models.tsv``), which is the operator + saying what this host offers. + +With neither, the answer is ``unknown``, said as unknown, never as verified. +Nothing in this module ever substitutes a model: it refuses, names what would +serve the request instead, and leaves the choice to the person. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import re +import tempfile +import time +from typing import Any, Iterator, Mapping, Sequence + +MODEL_RE = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") + +# Verdicts. The first three start a session; the last two refuse one. +SERVED = "served" +OFFERED = "offered" +UNKNOWN = "unknown" +NOT_OFFERED = "not-offered" +DOWNGRADED = "downgraded" +REFUSALS = (NOT_OFFERED, DOWNGRADED) + +SERVED_FILE = "served-models.tsv" +MAX_SERVED_BYTES = 256 * 1024 +MAX_SERVED_ROWS = 512 + + +class IntakeError(ValueError): + """A requested worker model is invalid.""" + + +def _text(value: object, *, limit: int, label: str, required: bool = True) -> str: + """One bounded line: control characters stripped, whitespace flattened.""" + if value is None: + if required: + raise IntakeError(f"{label} is required") + return "" + if not isinstance(value, str): + raise IntakeError(f"{label} must be a string") + flattened = " ".join( + "".join(character for character in value if character >= " ").split() + ) + if required and not flattened: + raise IntakeError(f"{label} is required") + return flattened[:limit] + + +def _model(provider: str, value: object) -> str: + model = _text(value, limit=128, label="requested model") + if not MODEL_RE.fullmatch(model): + raise IntakeError("requested model is not a supported identifier") + if provider == "claude" and not model.startswith("claude-"): + raise IntakeError("a Claude worker needs a Claude model identifier") + if provider == "codex" and not model.startswith(("gpt-", "o3", "o4", "codex-")): + raise IntakeError("a Codex worker needs a Codex model identifier") + return model + + +def validate_requested_model(provider: object, value: object) -> str: + """Single provider-specific model gate shared by launch and ``sp new``.""" + if provider not in ("claude", "codex"): + raise IntakeError("worker provider must be claude or codex") + return _model(str(provider), value) + + +# ---- what was actually served ------------------------------------------- + + +def served_path(state_dir: Path | str) -> Path: + return Path(state_dir) / "models" / SERVED_FILE + + +def read_served(state_dir: Path | str) -> dict[tuple[str, str], dict[str, Any]]: + """Every recorded (provider, requested) -> what really served it.""" + path = served_path(state_dir) + try: + if path.is_symlink() or not path.is_file(): + return {} + if path.stat().st_size > MAX_SERVED_BYTES: + return {} + text = path.read_text(encoding="utf-8", errors="strict") + except (OSError, UnicodeDecodeError, ValueError): + return {} + rows: dict[tuple[str, str], dict[str, Any]] = {} + for line in text.splitlines(): + if not line.strip() or line.startswith("#"): + continue + fields = line.split("\t") + if len(fields) < 3: + continue + provider, requested, served = (value.strip() for value in fields[:3]) + if provider not in ("claude", "codex") or not requested or not served: + continue + try: + observed = int(fields[3]) if len(fields) > 3 else 0 + except ValueError: + observed = 0 + rows[(provider, requested)] = {"served": served, "at_unix_ms": observed} + return rows + + +def record_served( + state_dir: Path | str, + provider: str, + requested: str, + served: str, + *, + now_unix_ms: int | None = None, +) -> dict[str, Any]: + """Record the model a request was really answered with. + + This is the one call anything reading a live session's actual model needs + to make. The newest observation for a pair replaces the older one, a model + that has come back is as important as one that went away. + """ + provider = str(provider) + if provider not in ("claude", "codex"): + raise IntakeError("worker provider must be claude or codex") + requested_model = _model(provider, requested) + served_model = _model(provider, served) + stamp = int(time.time() * 1000) if now_unix_ms is None else int(now_unix_ms) + rows = read_served(state_dir) + rows[(provider, requested_model)] = { + "served": served_model, + "at_unix_ms": stamp, + } + ordered = sorted( + rows.items(), key=lambda item: int(item[1]["at_unix_ms"]), reverse=True + )[:MAX_SERVED_ROWS] + payload = "".join( + f"{key[0]}\t{key[1]}\t{value['served']}\t{value['at_unix_ms']}\n" + for key, value in sorted(ordered) + ).encode("utf-8") + path = served_path(state_dir) + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb", closefd=True) as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + except OSError: + try: + os.unlink(temporary) + except OSError: + pass + raise + return { + "provider": provider, + "requested": requested_model, + "served": served_model, + "at_unix_ms": stamp, + "downgraded": served_model != requested_model, + } + + +def availability( + provider: str, + model: str, + *, + state_dir: Path | str, + environ: Mapping[str, str] | None = None, +) -> dict[str, Any]: + """Whether this machine really serves the model that was asked for. + + Never substitutes and never guesses: it answers ``served``, ``offered``, + ``unknown``, or one of the two refusals, and when it refuses it names what + would serve the request instead. + """ + environ = os.environ if environ is None else environ + wanted = validate_requested_model(provider, model) + offered = configured_models(str(provider), environ) + observed = read_served(state_dir).get((str(provider), wanted)) + if observed is not None: + served_model = str(observed["served"]) + if served_model != wanted: + return { + "provider": provider, + "model": wanted, + "verdict": DOWNGRADED, + "serves": served_model, + "offered": list(offered), + "observed_at_unix_ms": int(observed["at_unix_ms"]), + "reason": ( + f"the last session that asked for {wanted} actually ran on " + f"{served_model}" + ), + } + return { + "provider": provider, + "model": wanted, + "verdict": SERVED, + "serves": wanted, + "offered": list(offered), + "observed_at_unix_ms": int(observed["at_unix_ms"]), + "reason": f"{wanted} served a session on this machine before", + } + if offered: + if wanted in offered: + return { + "provider": provider, + "model": wanted, + "verdict": OFFERED, + "serves": wanted, + "offered": list(offered), + "reason": f"{wanted} is on this machine's model list", + } + return { + "provider": provider, + "model": wanted, + "verdict": NOT_OFFERED, + "serves": "", + "offered": list(offered), + "reason": f"{wanted} is not on this machine's model list", + } + return { + "provider": provider, + "model": wanted, + "verdict": UNKNOWN, + "serves": "", + "offered": [], + "reason": ( + "this machine has no model list and has not seen this model serve " + "a session, so nothing here can confirm it" + ), + } + + +def render_availability(verdict: Mapping[str, Any], *, flag: str) -> str: + """What a person reads when a launch is refused, in their words.""" + model = str(verdict.get("model") or "") + serves = str(verdict.get("serves") or "") + offered: Sequence[str] = list(verdict.get("offered") or []) + lines = [f"You asked for {model}."] + if verdict.get("verdict") == DOWNGRADED: + lines.append( + f"The last session that asked for it actually ran on {serves} instead." + ) + lines.append( + f"Start it on {serves} with --model {serves}, or repeat the command " + f"with {flag} to ask for {model} anyway." + ) + else: + lines.append("This machine's model list does not offer it.") + if offered: + lines.append("It offers: " + ", ".join(offered) + ".") + lines.append( + f"Name one of those, add {model} to the list, or repeat the command " + f"with {flag} to ask for it anyway." + ) + return "\n".join(lines) + "\n" + + +def configured_models(provider: str, environ: Mapping[str, str]) -> tuple[str, ...]: + """Configured model IDs accepted by the model-change verb. + + Configuration chooses the catalog, but the launch-path validator chooses + which entries are valid for a provider. Both pickers and the destructive + verb therefore share one acceptance rule and never guess a catalog. + """ + inline = environ.get("SESSION_KIT_TUI_MODELS", "") + candidates: Iterator[str] + if inline.strip(): + candidates = (part.strip() for part in inline.split(",")) + else: + path = environ.get("SESSION_KIT_MODELS_FILE") or str( + Path(environ.get("HOME", "/tmp")) / ".config/session-kit/models.tsv" + ) + found: list[str] = [] + try: + with open(path, encoding="utf-8") as handle: + for line in handle: + parts = line.rstrip("\n").split("\t") + if len(parts) < 2 or parts[0].casefold() not in { + provider.casefold(), + "*", + }: + continue + found.append(parts[1].strip()) + except OSError: + return () + candidates = iter(found) + valid: list[str] = [] + for candidate in candidates: + candidate = " ".join( + "".join(character for character in candidate if character >= " ").split() + )[:128] + if not candidate: + continue + try: + candidate = validate_requested_model(provider, candidate) + except IntakeError: + continue + if candidate not in valid: + valid.append(candidate) + return tuple(valid) diff --git a/lib/sessionkit_inventory/worktrees.py b/lib/sessionkit_inventory/worktrees.py new file mode 100644 index 0000000..7ce1c30 --- /dev/null +++ b/lib/sessionkit_inventory/worktrees.py @@ -0,0 +1,1640 @@ +"""Git worktree isolation for delegated workers. + +A delegated worker already has a branch: the reviewed preflight names one per +workstream, and the intake entry records it before anything launches. What the +worker never had was a directory of its own, so two workers on one project +edited the same files at the same time and the branch existed only as a label. +This module materializes that already-recorded branch as a real worktree, +records it, and prunes it again once the work is merged. + +Five rules the code enforces rather than documents: + +* Nothing outside the kit's own worktree root is ever removed. Teardown reads + a registry record, checks the recorded path is inside that root, and refuses + anything else, a hand-made worktree elsewhere is the operator's, not ours. +* Nothing is written into a checkout the machine *runs*. `shared_checkout` + answers that from the path, armed by default, and it gates creation and + removal alike, a removal runs `git worktree remove` inside the repository, + which is a write. +* Only this module's own registrations are ever unregistered. `git worktree + prune` is repository-wide and takes the index, reflog and rebase state of any + worktree it cannot reach at that moment; `forget_registration` removes the + one entry that names the directory being given back, and nothing else. +* Dirty is refused, and "dirty" is everything work can be: uncommitted and + untracked files, files the repository *ignores*, stashes made from the copy, + a half-finished merge / rebase / cherry-pick / revert / bisect, and submodule + churn. `git status --porcelain` alone answers a narrower question than the + one being asked, and the narrow answer is indistinguishable from "empty". +* Unmerged is refused, measured against the copy's **actual HEAD** rather than + the branch the registry recorded. Commits are never destroyed either way: + teardown removes the worktree, never the branch, but a commit made on a + detached HEAD is on no branch, so that promise only holds if the check looks + at where the copy really is. +* A check that could not run has not passed. Every git question here is asked + in order to *prove* something is safe; when git cannot answer, the reason + goes in the refusal list next to the real ones and the copy stays. There is + no path in this module where an error makes a removal more likely. + +The worktrees live under the kit's state directory rather than beside the +repository on purpose. A sibling directory inside the project would show up as +untracked in `git status` and in the installer's own clean-tree gate. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +import hashlib +import os +from pathlib import Path +import re +import shutil +import subprocess +import time +from typing import Any + +from .private_store import ( + PrivateStoreError, + private_directory, + private_names, + read_private_json, + write_private_json, +) + +SCHEMA_VERSION = 1 +# Same branch grammar the intake preflight validates worker branches with, so a +# branch that reaches a worker plan can always be materialized. +BRANCH_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,127}") +MAX_RECORD_BYTES = 8192 +# One record per (repository, branch) the kit has ever cut, and a released one +# leaves a tombstone behind. `private_names` truncates silently past its limit, +# and `records()` is what `release`, the sweep, `sp worktree list` and the +# picker's branch labels all read -- so a registry over the limit does not fail, +# it quietly stops answering for the copies past it. The cap is high, and +# tombstones expire (:data:`TOMBSTONE_TTL_MS`), so the live count is bounded by +# what actually exists rather than by everything that ever did. +MAX_RECORDS = 4096 +TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000 +GIT_TIMEOUT_SECONDS = 60 + +Runner = Callable[..., "subprocess.CompletedProcess[str]"] + + +class WorktreeError(ValueError): + """A worktree request is invalid, unsafe, or refused by git.""" + + +def now_unix_ms(clock: Callable[[], float] = time.time) -> int: + return int(clock() * 1000) + + +# ---- paths -------------------------------------------------------------- + + +def valid_branch(value: object) -> str: + if not isinstance(value, str) or not BRANCH_RE.fullmatch(value.strip()): + raise WorktreeError( + "a worktree branch must be 1-128 characters of A-Z a-z 0-9 . _ / - " + "and start with a letter or digit" + ) + branch = value.strip() + if branch.endswith("/") or ".." in branch or "//" in branch: + raise WorktreeError(f"branch {branch} is not a usable git ref name") + return branch + + +def slug(value: str, *, limit: int = 48) -> str: + """One filesystem-safe component that still reads like its source.""" + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-.") or "worktree" + return cleaned[:limit] + + +SHARED_MARKER = ".session-kit-shared" + +# Where a machine keeps the software it *runs*, as opposed to where a person +# keeps a checkout to work in. A repository at or under one of these is a +# deployment: a checkout under `/srv` or `/var/www` is often the site being +# served, where the files on disk ARE the running thing. Copying one is not +# isolation, it is editing somewhere the change never takes effect, so the +# edit looks done and the site keeps serving the old page, and writing a +# branch or a worktree administrative directory into one is touching +# production. +# +# This list is armed by default, on purpose. A guarantee that has to be +# switched on is not a guarantee: the marker file and `SESSION_KIT_SHARED_REPOS` +# below are both opt-in, and on the machine this was written for neither had +# ever been set, so "nothing can reach the live checkout" was true only of a +# host that had already been configured to make it true. +SYSTEM_ROOTS = ( + "/bin", + "/boot", + "/dev", + "/etc", + "/lib", + "/lib32", + "/lib64", + "/libx32", + "/opt", + "/proc", + "/root", + "/run", + "/sbin", + "/srv", + "/sys", + "/usr", + "/var", +) + + +# Directories a system sets aside for scratch. They sit inside system roots and +# are not software the machine runs, so they are named out, otherwise a +# repository or a worktree root in `/var/tmp` or `$XDG_RUNTIME_DIR` would be +# refused for living where scratch is supposed to live. +SCRATCH_ROOTS = ("/tmp", "/var/tmp", "/run/user", "/run/shm", "/dev/shm") + + +def _within(path: Path, root: Path) -> bool: + return path == root or root in path.parents + + +def _absolute_entries(value: str) -> list[Path]: + """The absolute paths in a colon-separated host setting.""" + found: list[Path] = [] + for entry in (value or "").split(":"): + candidate = entry.strip() + if candidate.startswith("/"): + found.append(Path(candidate).resolve(strict=False)) + return found + + +def system_root_of(path: Path | str) -> str: + """The system directory this path lives under, or ``""``. + + Both the path as given and the path with its symlinks resolved are checked: + a repository reached through a link that lands in `/srv` is still in `/srv`. + """ + raw = Path(path) + candidates = [raw] + resolved = raw.resolve(strict=False) + if resolved != raw: + candidates.append(resolved) + for candidate in candidates: + if any(_within(candidate, Path(root)) for root in SCRATCH_ROOTS): + continue + if candidate == Path("/"): + return "/" + for root in SYSTEM_ROOTS: + if _within(candidate, Path(root)): + return root + return "" + + +def shared_checkout(repo: Path | str, environ: Mapping[str, str] | None = None) -> str: + """Why this repository must never be copied or written into, or ``""``. + + Some checkouts *are* the running thing. Editing a copy of one is not + isolation; it is working somewhere the change never takes effect, and on a + checkout that serves a live site, a delegated agent that thinks it is + editing the site would be editing nothing. Nothing in this module writes + into a repository this refuses: no branch, no worktree, no prune, and no + removal either. + + Four answers, in the order they are asked: + + * the host says this one *is* copyable, `SESSION_KIT_COPYABLE_REPOS`, the + one way to overrule everything below, because a rule with no exit is a + rule somebody works around; + * the host names it as shared, `SESSION_KIT_SHARED_REPOS`; + * it is under a system root (:data:`SYSTEM_ROOTS`), which is a path fact + and needs nobody to have configured anything; + * the repository says so itself with a `.session-kit-shared` file. + """ + environ = os.environ if environ is None else environ + raw = Path(repo) + resolved = raw.resolve(strict=False) + candidates = [raw] if raw == resolved else [raw, resolved] + for allowed in _absolute_entries(environ.get("SESSION_KIT_COPYABLE_REPOS", "")): + if any(_within(candidate, allowed) for candidate in candidates): + return "" + for marked in _absolute_entries(environ.get("SESSION_KIT_SHARED_REPOS", "")): + if any(_within(candidate, marked) for candidate in candidates): + return f"{marked} is listed in SESSION_KIT_SHARED_REPOS" + system = system_root_of(raw) + if system: + return ( + f"it is under {system}, where this machine keeps the software it " + "runs rather than checkouts to copy; " + "SESSION_KIT_COPYABLE_REPOS is how a host says otherwise" + ) + marker = resolved / SHARED_MARKER + try: + if marker.is_file() or marker.is_symlink(): + return f"{resolved} carries a {SHARED_MARKER} marker" + except OSError as error: + # The one question left is whether this checkout claims to be shared, + # and it could not be asked. Refusing costs a copy; guessing "no" costs + # a branch written into something that might be serving live. + return ( + f"whether {resolved} carries a {SHARED_MARKER} marker could not be " + f"read ({error.strerror or error}), and an unanswered question is " + "not a yes" + ) + return "" + + +DEFAULT_MAX_AUTO_COPY_FILES = 20_000 + + +def auto_copy_refusal( + repo: Path | str, + environ: Mapping[str, str] | None = None, + *, + runner: Runner = subprocess.run, +) -> str: + """Why this repository must not be copied *without being asked*, or ``""``. + + Isolation that happens by itself has to be cheap enough to happen by + itself. A checkout of a few hundred files costs milliseconds; one of forty + thousand costs gigabytes of disk and seconds of wall clock inside `sp new`, + per session, and nobody chose that at the moment it happens. So a large + checkout is not copied automatically, the session runs in it and says so, + naming the two ways to decide otherwise. An explicit `--worktree BRANCH` is + a decision and is never refused for size. + """ + environ = os.environ if environ is None else environ + shared = shared_checkout(repo, environ) + if shared: + return shared + raw = (environ.get("SESSION_KIT_AUTO_WORKTREE_MAX_FILES") or "").strip() + try: + limit = int(raw) if raw else DEFAULT_MAX_AUTO_COPY_FILES + except ValueError: + limit = DEFAULT_MAX_AUTO_COPY_FILES + if limit <= 0: + return "" + completed = _git(runner, Path(repo), "ls-files", "-z", check=False) + if completed.returncode != 0: + return "" + tracked = (completed.stdout or "").count("\0") + if tracked > limit: + return ( + f"it has {tracked:,} tracked files, more than the {limit:,} this " + "copies without asking" + ) + return "" + + +def worktree_root( + state_dir: Path | str, environ: Mapping[str, str] | None = None +) -> Path: + """Where materialized worktrees live; overridable for tests and operators.""" + override = (environ or os.environ).get("SESSION_KIT_WORKTREE_ROOT", "").strip() + if override: + root = Path(override) + if not root.is_absolute(): + raise WorktreeError("SESSION_KIT_WORKTREE_ROOT must be an absolute path") + return root + return Path(state_dir) / "worktrees" + + +def _trees_dir(root: Path) -> Path: + return private_directory(root / "trees", label="worktree root") + + +def _registry_dir(root: Path) -> Path: + return private_directory(root / "registry", label="worktree registry") + + +def token_for(repo: Path, branch: str) -> str: + digest = hashlib.sha256(f"{repo}\0{branch}".encode("utf-8")).hexdigest() + return digest[:32] + + +# ---- git ---------------------------------------------------------------- + + +def _git( + runner: Runner, cwd: Path, *arguments: str, check: bool = True +) -> subprocess.CompletedProcess[str]: + completed = runner( + ["git", "-C", os.fspath(cwd), *arguments], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=GIT_TIMEOUT_SECONDS, + check=False, + ) + if check and completed.returncode != 0: + detail = " ".join((completed.stderr or completed.stdout or "").split())[:300] + raise WorktreeError( + f"git {' '.join(arguments)} failed ({completed.returncode}): {detail}" + ) + return completed + + +def repository_root( + path: Path | str, *, runner: Runner = subprocess.run +) -> Path | None: + """The main working tree the directory belongs to, or None when it is not one. + + None is a real answer here, the launcher asks this question exactly so it + can say in the receipt that a project has no repository to isolate in, + instead of failing a launch or pretending isolation happened. + + A directory inside a linked worktree resolves to the *main* working tree, + not to itself. Worktrees are siblings, never nested: asking from inside one + and getting itself back would key a second registry record for the same + repository and leave `git worktree add` building a worktree of a worktree. + """ + directory = Path(path) + if not directory.is_dir(): + return None + completed = _git( + runner, + directory, + "rev-parse", + "--path-format=absolute", + "--show-toplevel", + "--git-dir", + "--git-common-dir", + check=False, + ) + if completed.returncode != 0: + return None + lines = [ + line.strip() for line in (completed.stdout or "").splitlines() if line.strip() + ] + if len(lines) != 3: + return None + top, git_dir, common_dir = (Path(value) for value in lines) + if git_dir != common_dir and common_dir.name == ".git": + return common_dir.parent + return top + + +MAX_ADMIN_ENTRIES = 4096 + + +def forget_registration(runner: Runner, repo: Path, tree_path: Path) -> str: + """Make git forget ONE worktree registration: the one naming ``tree_path``. + + Never `git worktree prune`. That verb is repository-wide: it deletes + ``.git/worktrees/<name>`` for *every* worktree of the repository whose + directory it cannot reach at that moment, one on a volume that is not + mounted, one on a share that is briefly down, one somebody renamed and + means to move back. That directory is where a worktree keeps its index, so + its staged-but-uncommitted content, its HEAD, its reflog and its + half-finished rebase all go with it. Running it for this module's own + housekeeping destroys work in checkouts this module was told never to + touch, and it does so with `check=False`, so nobody hears about it. + + Nothing here ever needed that. It needs git to forget one registration and + it knows exactly which one, so it reads the registrations, matches the one + whose `gitdir` points at this directory, and removes that alone. A locked + registration is left alone and named: git refuses to prune those, and so + does this. + + Returns the registration name it removed, ``""`` when there was none. + """ + completed = _git( + runner, + repo, + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + check=False, + ) + if completed.returncode != 0: + return "" + common = (completed.stdout or "").strip() + if not common: + return "" + admin_root = Path(common) / "worktrees" + if not admin_root.is_dir(): + return "" + wanted = tree_path.resolve(strict=False) + try: + entries = sorted(admin_root.iterdir())[:MAX_ADMIN_ENTRIES] + except OSError: + return "" + for entry in entries: + if not entry.is_dir() or entry.is_symlink(): + continue + pointer = entry / "gitdir" + try: + recorded = pointer.read_text(encoding="utf-8", errors="strict").strip() + except (OSError, UnicodeDecodeError): + continue + if not recorded: + continue + # `gitdir` holds `<worktree>/.git`; the worktree is its parent. + registered = Path(recorded).resolve(strict=False) + if registered.name == ".git": + registered = registered.parent + if registered != wanted: + continue + if (entry / "locked").exists(): + return "" + shutil.rmtree(entry, ignore_errors=True) + return entry.name + return "" + + +def _branch_exists(runner: Runner, repo: Path, branch: str) -> bool: + completed = _git( + runner, + repo, + "rev-parse", + "--verify", + "--quiet", + f"refs/heads/{branch}", + check=False, + ) + return completed.returncode == 0 + + +def _checked_out_at(runner: Runner, repo: Path, branch: str) -> str | None: + """The path where the branch is already checked out, if any.""" + completed = _git(runner, repo, "worktree", "list", "--porcelain") + path: str | None = None + for line in (completed.stdout or "").splitlines(): + if line.startswith("worktree "): + path = line[len("worktree ") :].strip() + elif line.startswith("branch ") and path is not None: + reference = line[len("branch ") :].strip() + if reference == f"refs/heads/{branch}": + return path + return None + + +# ---- registry ----------------------------------------------------------- + + +def _record_path(root: Path, token: str) -> Path: + return _registry_dir(root) / f"{token}.json" + + +def _validated_record(document: Mapping[str, Any], path: Path) -> dict[str, Any]: + required = ("token", "repo", "branch", "path", "created_unix_ms") + missing = [key for key in required if not document.get(key)] + if missing: + raise WorktreeError(f"worktree record is missing {', '.join(missing)}: {path}") + record = dict(document) + for key in ("repo", "path"): + value = record[key] + if not isinstance(value, str) or not value.startswith("/"): + raise WorktreeError(f"worktree record {key} must be absolute: {path}") + record["branch"] = valid_branch(record["branch"]) + return record + + +def read_record(root: Path, token: str) -> dict[str, Any] | None: + path = _record_path(root, token) + document = read_private_json(path, limit=MAX_RECORD_BYTES, label="worktree record") + if document is None: + return None + return _validated_record(document, path) + + +def records( + state_dir: Path | str, + environ: Mapping[str, str] | None = None, + *, + include_released: bool = False, + faults: list[str] | None = None, +) -> list[dict[str, Any]]: + """Every recorded worktree, newest first. + + A released copy leaves a tombstone behind so the session that ran in it can + still be traced back to its repository. A tombstone is not a worktree, so + it is out of every listing unless a caller asks for it by name. + + A record this cannot read is skipped so it does not hide the healthy ones, + and its token is appended to ``faults`` when a caller supplies a list. That + matters for one caller in particular: deciding "this session holds exactly + one copy" from a listing that silently dropped the other one is how the + second copy becomes permanent. + """ + root = worktree_root(state_dir, environ) + registry = root / "registry" + if not registry.is_dir(): + return [] + found: list[dict[str, Any]] = [] + for token in private_names(registry, ".json", limit=MAX_RECORDS): + try: + record = read_record(root, token) + except (WorktreeError, PrivateStoreError): + # A damaged record must not hide the healthy ones from a listing; + # `teardown` still refuses to act on anything it cannot validate. + if faults is not None: + faults.append(token) + continue + if record is None: + continue + if record.get("released") and not include_released: + continue + found.append(record) + found.sort(key=lambda item: int(item.get("created_unix_ms") or 0), reverse=True) + return found + + +def expire_tombstones(root: Path, *, now: int, ttl_ms: int = TOMBSTONE_TTL_MS) -> int: + """Drop released records older than the tombstone lifetime. + + A tombstone exists so a closed session can still be traced back to the + repository its copy came from. That is worth a month, not forever: the + registry is read through a hard cap that truncates in silence, so a file + that never expires eventually pushes a *live* copy out of every listing and + out of the sweep. Only records marked released are touched, and only ones + whose removal stamp is readable, an unreadable record is left where it is. + """ + registry = root / "registry" + if not registry.is_dir(): + return 0 + dropped = 0 + for token in private_names(registry, ".json", limit=MAX_RECORDS): + try: + record = read_record(root, token) + except (WorktreeError, PrivateStoreError): + continue + if record is None or not record.get("released"): + continue + try: + removed = int(record.get("removed_unix_ms") or 0) + except (TypeError, ValueError): + continue + if removed <= 0 or now - removed < ttl_ms: + continue + try: + _record_path(root, token).unlink() + except OSError: + continue + dropped += 1 + return dropped + + +def lookup( + state_dir: Path | str, + *, + path: Path | str | None = None, + repo: Path | str | None = None, + branch: str | None = None, + environ: Mapping[str, str] | None = None, + include_released: bool = False, +) -> dict[str, Any] | None: + """One recorded worktree by its directory, or by repository and branch.""" + root = worktree_root(state_dir, environ) + if repo is not None and branch is not None: + record = read_record(root, token_for(Path(repo), valid_branch(branch))) + if record is not None and record.get("released") and not include_released: + return None + return record + if path is None: + raise WorktreeError("a worktree lookup needs a path, or a repo and branch") + wanted = os.fspath(Path(path)) + for record in records(state_dir, environ, include_released=include_released): + if record["path"] == wanted: + return record + return None + + +def labels( + state_dir: Path | str, environ: Mapping[str, str] | None = None +) -> dict[str, dict[str, str]]: + """Worktree directory -> what a row can say about it. + + ``path`` is in the value as well as the key so a consumer holding one + annotation can name the working copy itself, not only the repository it + belongs to: the project view places a session by `repo` and then has a + real directory to show for it. + """ + return { + record["path"]: { + "branch": record["branch"], + "repo": record["repo"], + "path": record["path"], + } + for record in records(state_dir, environ) + } + + +# ---- verbs -------------------------------------------------------------- + + +def preflight( + *, + repo: Path | str, + branch: str, + state_dir: Path | str, + environ: Mapping[str, str] | None = None, + runner: Runner = subprocess.run, +) -> dict[str, Any]: + """Whether this branch could be materialized, without creating anything. + + The delegate path asks this before it reserves or dispatches anything. Once + a worker row is marked dispatching, a refusal from inside the launcher + reads as an uncertain dispatch and costs somebody a manual reconcile, so + the answer has to be available while refusing is still free. + """ + wanted = valid_branch(branch) + top = repository_root(repo, runner=runner) + if top is None: + return { + "repo": os.fspath(Path(repo)), + "branch": wanted, + "path": "", + "exists": False, + "blocked_by": "", + "reason": "not a git repository", + } + guarded = shared_checkout(top, environ) + if guarded: + return { + "repo": os.fspath(top), + "branch": wanted, + "path": "", + "exists": False, + "blocked_by": os.fspath(top), + "reason": f"nothing is copied out of {top}: {guarded}", + } + root = worktree_root(state_dir, environ) + destination = ( + root / "trees" / f"{slug(top.name)}-{token_for(top, wanted)[:8]}" / slug(wanted) + ) + occupied = _checked_out_at(runner, top, wanted) + blocked = occupied if occupied is not None and Path(occupied) != destination else "" + return { + "repo": os.fspath(top), + "branch": wanted, + "path": os.fspath(destination), + "exists": destination.is_dir(), + "blocked_by": blocked, + "reason": ( + f"branch {wanted} is already checked out at {blocked}" if blocked else "" + ), + } + + +def materialize( + *, + repo: Path | str, + branch: str, + state_dir: Path | str, + start_ref: str = "HEAD", + environ: Mapping[str, str] | None = None, + runner: Runner = subprocess.run, + clock: Callable[[], float] = time.time, + auto: bool = False, + origin: str = "", +) -> dict[str, Any]: + """Materialize the already-recorded branch as its own worktree. + + Idempotent by (repository, branch): a second call for a worktree that is + still on disk returns the same record with ``created`` false, so a retried + delegate never forks a worker's directory. + """ + wanted_branch = valid_branch(branch) + top = repository_root(repo, runner=runner) + if top is None: + raise WorktreeError(f"{repo} is not inside a git repository") + # Checked before a single git command runs against it, so a shared checkout + # never gains a branch, a worktree administrative directory, or a prune. + shared = shared_checkout(top, environ) + if shared: + raise WorktreeError( + f"{top} is a shared checkout ({shared}), so nothing is copied out " + "of it and nothing is written into it" + ) + root = worktree_root(state_dir, environ) + # The other direction of the same rule: a working copy is never *built* + # where the machine keeps the software it runs, however the root was + # configured. Checked here rather than in `worktree_root` so that listing + # and labelling a badly configured host still work, only creation stops. + landing = system_root_of(root) + if landing: + raise WorktreeError( + f"refusing to build a working copy under {landing}: the worktree " + f"root is {root}, and that is where this machine keeps software it " + "runs. Point SESSION_KIT_WORKTREE_ROOT somewhere of your own." + ) + private_directory(root, label="worktree root") + token = token_for(top, wanted_branch) + existing = read_record(root, token) + if existing is not None: + current = Path(existing["path"]) + if current.is_dir() and not current.is_symlink(): + return {**existing, "created": False} + # The directory is gone from under a live record: let git forget that + # one registration, then fall through and build it again at the + # recorded path. Scoped on purpose, see `forget_registration`. + forget_registration(runner, top, current) + trees = _trees_dir(root) + # The repository's own name is not unique on a machine: two projects on + # this host are both called `app`. The token disambiguates them, so the + # same branch name in two repositories cannot land on one directory and + # refuse the second launch. + destination = trees / f"{slug(top.name)}-{token[:8]}" / slug(wanted_branch) + occupied = _checked_out_at(runner, top, wanted_branch) + if occupied is not None and Path(occupied) != destination: + raise WorktreeError( + f"branch {wanted_branch} is already checked out at {occupied}" + ) + if occupied is None: + private_directory(destination.parent, label="worktree root") + if destination.exists() or destination.is_symlink(): + raise WorktreeError(f"worktree destination already exists: {destination}") + arguments = ["worktree", "add"] + if _branch_exists(runner, top, wanted_branch): + arguments += [os.fspath(destination), wanted_branch] + else: + arguments += ["-b", wanted_branch, os.fspath(destination), start_ref] + _git(runner, top, *arguments) + record = { + "schema_version": SCHEMA_VERSION, + "token": token, + "repo": os.fspath(top), + "branch": wanted_branch, + "path": os.fspath(destination), + "created_unix_ms": now_unix_ms(clock), + "shpool_id": "", + "launch_key": "", + # Who chose this working copy, and therefore who may remove it again. + # `auto` means the kit cut the branch itself because the session is + # delegated work; only those are released when the session closes. A + # branch a person named is theirs, and so is every worktree that + # predates this record field: absent means no. + "auto": bool(auto), + "origin": str(origin or ""), + } + write_private_json(_record_path(root, token), record) + return {**record, "created": True} + + +def bind( + *, + state_dir: Path | str, + path: Path | str, + shpool_id: str = "", + launch_key: str = "", + environ: Mapping[str, str] | None = None, +) -> dict[str, Any]: + """Attach the session that owns a worktree to its record.""" + record = lookup(state_dir, path=path, environ=environ) + if record is None: + raise WorktreeError(f"no recorded worktree at {path}") + identifier = shpool_id.strip() + key = launch_key.strip() + for value, label in ((identifier, "shpool id"), (key, "launch key")): + if value and not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", value): + raise WorktreeError(f"worktree {label} is not a supported identifier") + if identifier: + record["shpool_id"] = identifier + if key: + record["launch_key"] = key + root = worktree_root(state_dir, environ) + write_private_json(_record_path(root, str(record["token"])), record) + return record + + +def status(*, path: Path | str, runner: Runner = subprocess.run) -> list[str]: + """Uncommitted, untracked and ignored entries in one worktree. + + ``--ignored`` is not optional here. A fresh worktree starts with no ignored + files at all, so every ignored file in one was made by whoever worked in + it: a log, a screenshot, a report, a ``.env``, a one-off script. Asking git + the default question returns nothing for those, and the copy was removed + with them in it. ``--untracked-files=all`` counts inside an untracked + directory rather than reporting the directory once, and submodules are not + ignored, so a repository that hides its own submodule churn cannot hide it + here either. + """ + completed = _git( + runner, + Path(path), + "status", + "--porcelain", + "--untracked-files=all", + "--ignored=matching", + "--ignore-submodules=none", + ) + return [line for line in (completed.stdout or "").splitlines() if line.strip()][:50] + + +# A worktree's own git directory holds the state of anything half-finished in +# it. `git status --porcelain` is empty in the middle of every one of these. +WORK_MARKERS = ( + ("MERGE_HEAD", "a merge is in progress"), + ("rebase-merge", "a rebase is in progress"), + ("rebase-apply", "a rebase or patch series is in progress"), + ("CHERRY_PICK_HEAD", "a cherry-pick is in progress"), + ("REVERT_HEAD", "a revert is in progress"), + ("BISECT_LOG", "a bisect is in progress"), + ("sequencer", "an unfinished sequence of commits is in progress"), +) + + +def head_of(*, path: Path | str, runner: Runner = subprocess.run) -> dict[str, Any]: + """What the copy is actually on: its commit, and its branch if it has one. + + The registry records the branch a worktree was cut on. It is not evidence + of where the worktree is *now*: a worker can detach HEAD, or check out + something else entirely, and the recorded ref never moves. Every decision + about removing a directory has to be made against this, not against the + record. + + Each question is asked without ``check``, because a failure here is an + answer the caller has to act on rather than an exception to unwind on, but + it is the answer *"nobody knows"*, and it comes back in ``unreadable`` so + that no caller can mistake an empty string for "there is nothing there". + Every guarantee in this module rests on one of these three values; a git + that could not answer switches off exactly the check that was protecting + the work. + """ + completed = _git(runner, Path(path), "rev-parse", "HEAD", check=False) + commit = (completed.stdout or "").strip() if completed.returncode == 0 else "" + named = _git( + runner, Path(path), "symbolic-ref", "--quiet", "--short", "HEAD", check=False + ) + # A failure here is normal and means exactly one thing: HEAD is detached. + branch = (named.stdout or "").strip() if named.returncode == 0 else "" + top = _git(runner, Path(path), "rev-parse", "--absolute-git-dir", check=False) + git_dir = (top.stdout or "").strip() if top.returncode == 0 else "" + unreadable: list[str] = [] + if not commit: + detail = " ".join((completed.stderr or "").split())[:160] + unreadable.append( + "git could not say which commit this copy is on" + + (f" ({detail})" if detail else "") + + ", so nothing here can prove its commits exist anywhere else" + ) + if not git_dir: + detail = " ".join((top.stderr or "").split())[:160] + unreadable.append( + "git could not say where this copy's own git directory is" + + (f" ({detail})" if detail else "") + + ", so a half-finished merge or rebase in it would be invisible" + ) + return { + "commit": commit, + "branch": branch, + "detached": bool(commit) and not branch, + "git_dir": git_dir, + "unreadable": unreadable, + } + + +def in_progress(git_dir: str | None) -> list[str]: + """Half-finished git operations whose state lives in the worktree.""" + if not git_dir: + return [] + root = Path(git_dir) + return [reason for name, reason in WORK_MARKERS if (root / name).exists()] + + +def stashes_for( + *, + path: Path | str, + branches: Sequence[str] = (), + head_commit: str = "", + detached: bool = False, + runner: Runner = subprocess.run, +) -> dict[str, Any]: + """Stash entries this copy could have made. + + A stash is somebody's work by definition, and `git status` cannot see one. + The entries live in the repository rather than in the worktree, so they + have to be attributed to it. Three ways, and any one is enough to keep the + directory: + + * **the entry's first parent is the commit this copy is standing on.** The + only definitive answer, and the only one that works for a stash pushed + from a detached HEAD, git records those as ``On (no branch): …``, which + names nothing at all. + * **the subject names one of this copy's branches**, compared with case + folded on both sides. Git writes the branch as spelled, so a copy on + ``Feature/Work`` gets ``On Feature/Work:``; a search that lower-cases only + the line it is reading finds nothing. + * **the copy is detached and the subject says ``(no branch)``.** + + Over-matching is the safe direction here and is deliberate: a stash made in + the main checkout at the same commit keeps the copy, which costs a + directory. Under-matching costs the stash. + + A `git stash list` that fails comes back in ``unreadable``. "The question + could not be asked" is never rendered as "the answer is no". + """ + completed = _git( + runner, + Path(path), + "stash", + "list", + "--format=%gd%x09%P%x09%gs", + check=False, + ) + if completed.returncode != 0: + detail = " ".join((completed.stderr or "").split())[:160] + return { + "entries": [], + "unreadable": ( + "the stash list could not be read" + + (f" ({detail})" if detail else "") + + ", so a stashed change would be invisible" + ), + } + markers: list[str] = [] + for name in branches: + folded = (name or "").strip().casefold() + if folded: + markers.extend((f"on {folded}:", f"on {folded} ")) + found: list[str] = [] + for line in (completed.stdout or "").splitlines(): + fields = line.split("\t") + if len(fields) < 3: + continue + name = fields[0].strip() + parents = fields[1].split() + subject = "\t".join(fields[2:]).strip() + base = parents[0] if parents else "" + folded = subject.casefold() + if ( + (head_commit and base == head_commit) + or any(marker in folded for marker in markers) + or (detached and "(no branch)" in folded) + ): + found.append(f"{name} {subject}".strip()) + if len(found) >= 20: + break + return {"entries": found, "unreadable": ""} + + +def inspect_work( + *, + path: Path | str, + repo: Path | str, + branch: str, + reference: str, + runner: Runner = subprocess.run, +) -> dict[str, Any]: + """Everything in a working copy that a person would call their work. + + One place, so no caller can decide a copy is empty by asking a narrower + question than the next caller does. It answers with facts and a list of + plain-language reasons to keep the directory; an empty list is the only + thing that permits a removal. + + Two kinds of reason go in that list and both keep the copy. ``refusals`` + that name work, a file, a commit, a stash, and ``unreadable`` ones that + name a question git could not answer. The second kind matters as much as + the first: every check here is "prove the work is safe", so a check that + could not run has not passed, and reading its empty result as "there is + nothing there" is how a copy with work in it is removed by a guard that was + switched off by an error nobody saw. + """ + tree = Path(path) + repository = Path(repo) + refusals: list[str] = [] + blind: list[str] = [] + # Reasons `--force` does not cover, collected where each one is decided + # rather than matched back out of the sentence it produced. A substring + # search over rendered text is one reworded message away from letting a + # force through the thing it was written to stop. + unforceable: list[str] = [] + facts: dict[str, Any] = { + "head": {}, + "dirty": [], + "ignored": [], + "stashes": [], + "in_progress": [], + "unreadable": [], + "unforceable": [], + "merged": False, + "identity": "", + } + if not tree.is_dir(): + return { + **facts, + "refusals": refusals, + "present": False, + } + head = head_of(path=tree, runner=runner) + facts["head"] = head + blind.extend(head.get("unreadable") or []) + # Is the checkout at that path still the one the record describes? A kit + # worktree removed outside the kit leaves the record behind, and a + # directory made there afterwards is somebody else's checkout, not ours. + # The comparison is component-wise: a plain string prefix would read + # `<repo>/.git/worktrees-elsewhere/x` as being inside `<repo>/.git/worktrees`. + expected = (repository / ".git" / "worktrees").resolve(strict=False) + if head["git_dir"] and not _within( + Path(head["git_dir"]).resolve(strict=False), expected + ): + reason = ( + "the checkout there is not the one recorded, so it is not ours to remove" + ) + refusals.append(reason) + unforceable.append(reason) + facts["identity"] = "foreign" + elif branch and head["branch"] and head["branch"] != branch: + reason = f"it is on {head['branch']} now, not the recorded {branch}" + refusals.append(reason) + unforceable.append(reason) + facts["identity"] = "moved" + entries = status(path=tree, runner=runner) + dirty = [line for line in entries if not line.startswith("!!")] + ignored = [line for line in entries if line.startswith("!!")] + facts["dirty"] = dirty + facts["ignored"] = ignored + if dirty: + refusals.append(f"{len(dirty)} uncommitted or untracked file(s)") + if ignored: + # Named separately: "untracked" reads like scratch, and this is where + # an agent's report, log, screenshot set or .env actually lives. + refusals.append( + f"{len(ignored)} file(s) the repository ignores but somebody made" + ) + facts["in_progress"] = in_progress(head.get("git_dir")) + refusals.extend(facts["in_progress"]) + if facts["in_progress"]: + # The conflict resolution in the tree and the replay state in + # `.git/worktrees/<n>/rebase-merge` are both destroyed by + # `git worktree remove --force`, and neither is on a branch, so nothing + # can bring them back. `--force` is a decision about a directory; this + # is not one. The way out is one command, and it is named. + unforceable.extend( + f"{reason} (finish it or undo it in the copy first: " + f"git -C {tree} rebase --abort, merge --abort, cherry-pick --abort, " + "revert --abort or bisect reset)" + for reason in facts["in_progress"] + ) + stash = stashes_for( + path=tree, + branches=(head.get("branch") or "", branch), + head_commit=str(head.get("commit") or ""), + detached=bool(head.get("detached")), + runner=runner, + ) + facts["stashes"] = stash["entries"] + if stash["entries"]: + refusals.append(f"{len(stash['entries'])} stashed change(s) from this copy") + if stash["unreadable"]: + blind.append(stash["unreadable"]) + # The commit the copy is actually standing on has to be in the reference, + # whether it is on the recorded branch, on another branch, or on no branch + # at all. Asking only whether the recorded ref is merged says "merged" for + # a worker who detached HEAD and committed -- and removing the worktree + # then deletes that commit's only reflog with it. + if not repository.is_dir(): + blind.append( + f"the repository this copy came from is gone ({repository}), so " + "nothing here can prove its commits exist anywhere else" + ) + elif head.get("commit"): + completed = _git( + runner, + repository, + "merge-base", + "--is-ancestor", + head["commit"], + reference, + check=False, + ) + if completed.returncode not in (0, 1): + detail = " ".join((completed.stderr or "").split())[:200] + blind.append( + f"its commits could not be compared with {reference}: {detail}" + ) + else: + facts["merged"] = completed.returncode == 0 + if not facts["merged"]: + where = head["branch"] or f"the commit it is on ({head['commit'][:12]})" + refusals.append(f"{where} is not merged into {reference}") + # Nothing but this copy's own reflog reaches a commit that is on no branch, + # and `git worktree remove` deletes that reflog with the directory. So the + # rule is not "detached and *known* to be unmerged", it is detached and + # not PROVED merged. A `merge-base` that errored, a reference that does not + # exist, a HEAD git could not read: each of those leaves `merged` false for + # a reason that has nothing to do with the work being safe, and each of + # them used to be forceable while the one case that was spelled out was not. + if not facts["merged"] and (head.get("detached") or not head.get("commit")): + unforceable.append( + "its HEAD is not on a branch, and nothing here could prove its " + f"commits are already in {reference}; removing the copy would take " + "the only reflog that reaches them" + ) + # Last, so a person reads what is *in* the copy before what could not be + # checked about it. Most of these stay forceable: a failed stash list, a + # missing repository, a comparison that errored on a copy whose branch does + # hold its commits -- a person who has read the reason can still remove the + # directory. The exception is above: anything that leaves "is there a + # commit here that only this copy holds" unanswered. + facts["unreadable"] = blind + facts["unforceable"] = unforceable + refusals.extend(blind) + return {**facts, "refusals": refusals, "present": True} + + +# `merged_into()` used to live here: it asked whether `refs/heads/<recorded +# branch>` was an ancestor of the reference. That is the question that removed a +# commit made on a detached HEAD, the recorded branch had not moved, so it was +# an ancestor, so the copy read as merged. `inspect_work` asks about the copy's +# actual HEAD instead. The old helper is deleted rather than left dead beside +# the new one: a function that answers a nearly-identical question with the +# wrong evidence is a trap for whoever edits this next. + + +def teardown( + *, + state_dir: Path | str, + path: Path | str | None = None, + repo: Path | str | None = None, + branch: str | None = None, + merged_into_ref: str = "HEAD", + force: bool = False, + environ: Mapping[str, str] | None = None, + runner: Runner = subprocess.run, + proc: Path | str = Path("/proc"), + exclude_pids: Sequence[int] = (), +) -> dict[str, Any]: + """Prune one recorded worktree after its branch is merged. + + Refuses a dirty or unmerged worktree unless ``force`` is set, and never + touches the branch itself: the commits outlive the directory either way. + A working copy somebody is still standing in is refused whatever ``force`` + says. That is not a decision about unsaved work; it is pulling the floor + out from under a live session. + """ + record = lookup(state_dir, path=path, repo=repo, branch=branch, environ=environ) + if record is None: + raise WorktreeError("no recorded worktree matches that request") + root = worktree_root(state_dir, environ) + tree_path = Path(record["path"]) + trees = (root / "trees").resolve(strict=False) + if ( + not tree_path.is_absolute() + or trees not in tree_path.resolve(strict=False).parents + ): + raise WorktreeError( + f"refusing to remove {tree_path}: outside the kit's worktree root" + ) + # The second half of "nothing reaches a checkout like that". Creation is + # refused in `materialize`; removal has its own door, because a removal + # runs `git worktree remove` *in the repository*, a write into the very + # thing the creation guard exists to keep out of. A + # record naming a protected repository is refused before either runs, and + # `--force` is not a way past it: the answer is not about this copy. + guarded = shared_checkout(Path(record["repo"]), environ) + if guarded: + raise WorktreeError( + f"refusing to touch {record['repo']}: {guarded}. Nothing is written " + "into a checkout like that, removal included." + ) + scan = busy_scan( + tree_path, proc=proc, exclude=[*_ancestor_pids(Path(proc)), *exclude_pids] + ) + occupants = scan["pids"] + if occupants: + listed = ", ".join(str(pid) for pid in occupants[:5]) + raise WorktreeError( + f"refusing to remove {tree_path}: pid(s) {listed} are still working " + "in it. Close that session first." + ) + if scan["unreadable"]: + raise WorktreeError(f"refusing to remove {tree_path}: {scan['unreadable']}") + repository = Path(record["repo"]) + worktree_branch = str(record["branch"]) + work = inspect_work( + path=tree_path, + repo=repository, + branch=worktree_branch, + reference=merged_into_ref, + runner=runner, + ) + refusals = list(work["refusals"]) + dirty = list(work["dirty"]) + list(work["ignored"]) + merged = bool(work["merged"]) + # Some of these are not decisions about unsaved work, and `--force` does + # not cover them: a directory that is not the checkout we recorded is + # somebody else's, a commit on no branch cannot be re-made from the branch + # afterwards, and a half-finished rebase lives nowhere but here. The list + # comes from `inspect_work`, which marks each one where it decides it + # rather than being matched back out of the sentence it produced. + unforceable = list(work.get("unforceable") or []) + if unforceable: + others = [reason for reason in refusals if reason not in unforceable] + raise WorktreeError( + "worktree kept: " + + "; ".join(unforceable) + + (f"; also {'; '.join(others)}" if others else "") + + ". This is refused whatever --force says, because nothing else " + "holds that work." + ) + if refusals and not force: + raise WorktreeError( + "worktree kept: " + + "; ".join(refusals) + + ". Merge the branch, or pass --force to remove the directory anyway " + "(the branch and its commits are never deleted)." + ) + removed = False + if tree_path.is_dir() and repository.is_dir(): + arguments = ["worktree", "remove"] + if force: + arguments.append("--force") + arguments.append(os.fspath(tree_path)) + _git(runner, repository, *arguments) + removed = True + elif tree_path.is_dir(): + # The repository itself is gone; the directory is still ours to remove + # because it is inside the kit's root and recorded. + shutil.rmtree(tree_path) + removed = True + if repository.is_dir(): + # `git worktree remove` already dropped the registration in the normal + # case; this covers the one it left behind. Scoped to this copy's own + # entry, a repository-wide prune here would delete the administrative + # directory, and therefore the index and rebase state, of every other + # worktree of this repository that happens to be unreachable right now. + forget_registration(runner, repository, tree_path) + # The record becomes a tombstone rather than disappearing. A session that + # ran in this copy is still in the closed list, and `sp recover` hands its + # recorded directory back to the restore path -- which would refuse a + # directory that no longer exists. The tombstone is how that path finds the + # repository the copy came from instead of dead-ending. + stamp = now_unix_ms() + tombstone = { + **{key: value for key, value in record.items() if key != "removed_unix_ms"}, + "released": True, + "removed_unix_ms": stamp, + } + write_private_json(_record_path(root, str(record["token"])), tombstone) + expire_tombstones(root, now=stamp) + return { + "removed": removed, + "path": os.fspath(tree_path), + "branch": worktree_branch, + "repo": os.fspath(repository), + "merged_into": merged_into_ref, + "merged": merged, + "dirty": dirty, + "forced": bool(force and refusals), + "shpool_id": str(record.get("shpool_id") or ""), + } + + +# ---- release: the copy goes when the session that got it closes --------- +# +# `teardown` is the operator's verb and raises at them. `release` is what a +# close calls, so it answers with a verdict instead: a close must never fail +# because of a directory, and a directory must never be removed because a +# close was in a hurry. Four things stop it, each of them said out loud: +# +# * no record, or a record this did not create -> not ours to remove +# * anything outside the kit's own worktree root -> never touched +# * a live process still working in it -> kept, with the pid +# * uncommitted or unmerged work -> kept, with the reason +KEPT = "kept" +REMOVED = "removed" +NONE = "none" +# One session, more than one copy: each is answered for, in `verdicts`. +MANY = "many" + + +def _ancestor_pids(proc: Path = Path("/proc")) -> set[int]: + """This process and everything that started it. + + A session closing from the inside (`bye`, or a provider that exited) + runs this code *in* the working copy being released, so its own shell + would otherwise count as somebody still working in it. + """ + found: set[int] = set() + pid = os.getpid() + for _ in range(64): + if pid <= 0 or pid in found: + break + found.add(pid) + try: + text = (proc / str(pid) / "status").read_text(encoding="utf-8") + except (OSError, ValueError): + break + parent = 0 + for line in text.splitlines(): + if line.startswith("PPid:"): + try: + parent = int(line.split()[1]) + except (IndexError, ValueError): + parent = 0 + break + pid = parent + return found + + +def busy_scan( + path: Path | str, + *, + proc: Path | str = Path("/proc"), + exclude: Sequence[int] = (), +) -> dict[str, Any]: + """Live processes whose working directory is in ``path``, and what was hidden. + + Evidence, not bookkeeping: a session that is still standing in the + directory shows up here whether or not any record says it owns it. + + The process table is not always readable. ``hidepid``, a restricted + container, a `/proc` that is not mounted, each of those produces an empty + list that is indistinguishable from "nobody is working in it", and that + list is the *only* thing standing between a live session and having the + floor pulled out from under it. So the scan reports what it could not read: + ``unreadable`` non-empty means this answer is not evidence of anything. + + One process whose own `cwd` link cannot be read is *not* reported. On any + ordinary machine most of the process table belongs to somebody else and is + unreadable by design; treating that as doubt would keep every copy forever + and turn the whole feature off. The line drawn here is between "some of the + table is not mine to read", which is normal, and "the table could not be + read at all", which is the case that silently answers "nobody is here". + """ + root = Path(proc) + wanted = Path(path).resolve(strict=False) + skip = set(exclude) + found: list[int] = [] + try: + entries = sorted( + (entry for entry in root.iterdir() if entry.name.isdigit()), + key=lambda entry: int(entry.name), + ) + except OSError as error: + return { + "pids": [], + "unreadable": ( + f"the process table at {root} could not be read " + f"({error.strerror or error}), so nothing here can say whether " + "a session is still working in it" + ), + } + for entry in entries: + pid = int(entry.name) + if pid in skip: + continue + try: + cwd = (entry / "cwd").resolve(strict=True) + except (OSError, RuntimeError, ValueError): + # Not ours to read, or gone between listing and reading. Either + # way it is not evidence of use. + continue + if cwd == wanted or wanted in cwd.parents: + found.append(pid) + return {"pids": found, "unreadable": ""} + + +def busy_pids( + path: Path | str, + *, + proc: Path | str = Path("/proc"), + exclude: Sequence[int] = (), +) -> list[int]: + """The pids from :func:`busy_scan`, for callers that only want the list.""" + return list(busy_scan(path, proc=proc, exclude=exclude)["pids"]) + + +def _verdict( + action: str, reason: str, record: Mapping[str, Any] | None = None, **extra: Any +) -> dict[str, Any]: + verdict = { + "action": action, + "reason": reason, + "path": str((record or {}).get("path") or extra.pop("path", "")), + "branch": str((record or {}).get("branch") or ""), + "shpool_id": str((record or {}).get("shpool_id") or ""), + } + verdict.update(extra) + return verdict + + +def release( + state_dir: Path | str, + *, + shpool_id: str | None = None, + path: Path | str | None = None, + merged_into_ref: str = "HEAD", + environ: Mapping[str, str] | None = None, + runner: Runner = subprocess.run, + proc: Path | str = Path("/proc"), + exclude_pids: Sequence[int] = (), +) -> dict[str, Any]: + """Give back the working copy a closing session was given. + + Never raises: every outcome is a verdict the caller prints. Only worktrees + this kit cut for a delegated session are candidates, so the ones already on + the machine when this shipped are left exactly where they are. + """ + record: Mapping[str, Any] | None = None + if shpool_id: + wanted = str(shpool_id).strip() + faults: list[str] = [] + owned = [ + item + for item in records(state_dir, environ, faults=faults) + if str(item.get("shpool_id") or "") == wanted + ] + if faults: + # "This session holds exactly one copy" cannot be concluded from a + # listing that dropped a record it could not read. The one below + # would be released and the unreadable one forgotten, which is the + # permanent-directory failure the MANY branch exists to prevent. + return _verdict( + KEPT, + f"{len(faults)} worktree record(s) in the registry could not be " + "read, so nothing here can say how many copies this session " + "holds; `sp worktree list` shows the ones that are readable", + None, + faults=faults, + ) + if len(owned) > 1: + # A session can hold more than one copy (a retried launch on a new + # branch). Releasing the newest and forgetting the rest is how a + # directory becomes permanent, so each one is answered for. + return _verdict( + MANY, + f"{len(owned)} working copies are bound to that session", + None, + verdicts=[ + release( + state_dir, + path=item["path"], + merged_into_ref=merged_into_ref, + environ=environ, + runner=runner, + proc=proc, + exclude_pids=exclude_pids, + ) + for item in owned + ], + ) + record = owned[0] if owned else None + if record is None and path is not None: + try: + record = lookup(state_dir, path=path, environ=environ) + except WorktreeError as error: + return _verdict(NONE, str(error), path=os.fspath(Path(path))) + if record is None: + return _verdict( + NONE, + "no worktree record covers that session", + path=os.fspath(Path(path)) if path is not None else "", + ) + if not record.get("auto"): + return _verdict( + KEPT, + "that working copy was asked for by name, so it stays; " + "remove it with sp teardown", + record, + ) + tree_path = Path(str(record["path"])) + try: + root = worktree_root(state_dir, environ) + except WorktreeError as error: + return _verdict(KEPT, str(error), record) + trees = (root / "trees").resolve(strict=False) + if ( + not tree_path.is_absolute() + or trees not in tree_path.resolve(strict=False).parents + ): + return _verdict( + KEPT, "it is outside the kit's worktree root, so it is not ours", record + ) + scan = busy_scan( + tree_path, + proc=proc, + exclude=[*_ancestor_pids(Path(proc)), *exclude_pids], + ) + busy = scan["pids"] + if busy: + listed = ", ".join(str(pid) for pid in busy[:5]) + return _verdict( + KEPT, + f"something is still working in it (pid {listed})", + record, + busy=busy, + ) + if scan["unreadable"]: + return _verdict(KEPT, scan["unreadable"], record) + try: + result = teardown( + state_dir=state_dir, + path=tree_path, + merged_into_ref=merged_into_ref, + force=False, + environ=environ, + runner=runner, + # The same `/proc` and the same exemptions this call was given. + # Dropping them made `teardown` re-run the occupancy check against + # the real process table with the closing session's own pids back + # in it -- and made every test that hands `release` a fake `/proc` + # prove nothing about the check it was written for. + proc=proc, + exclude_pids=exclude_pids, + ) + except WorktreeError as error: + return _verdict(KEPT, str(error), record) + return _verdict( + REMOVED, + f"merged into {merged_into_ref} and clean", + record, + removed=bool(result.get("removed")), + ) + + +def release_idle( + state_dir: Path | str, + active_shpool_ids: Sequence[str] | None, + *, + merged_into_ref: str = "HEAD", + environ: Mapping[str, str] | None = None, + runner: Runner = subprocess.run, + proc: Path | str = Path("/proc"), +) -> list[dict[str, Any]]: + """Release every automatic worktree whose session is provably gone. + + A close that lands while the worker is still writing keeps the directory + on purpose. Without this sweep that decision would be permanent, so the + same check runs again later, but only ever against a list of what is + alive that the caller actually supplies. + + Two rules make this safe to schedule: + + * **No list, no sweep.** ``None`` is refused rather than read as "nothing + is alive". A verb that deletes must never treat missing knowledge as + permission, and an empty list is only accepted when the caller means it. + * **No owner, no removal.** A copy whose session was never recorded cannot + be matched against the live list at all, so nothing here can prove it is + idle. It is reported and left alone, however old it is. Age is not + ownership, and a bind that failed leaves a *running* session in exactly + that state. + """ + if active_shpool_ids is None: + raise WorktreeError( + "a sweep needs the list of sessions that are alive; refusing to " + "treat 'no list' as 'nothing is alive'" + ) + live = {str(value).strip() for value in active_shpool_ids if str(value).strip()} + verdicts: list[dict[str, Any]] = [] + for record in records(state_dir, environ): + if not record.get("auto"): + continue + owner = str(record.get("shpool_id") or "") + if owner and owner in live: + continue + if not owner: + verdicts.append( + _verdict( + KEPT, + "no session is recorded for this copy, so nothing here can " + "prove it is idle; remove it with sp teardown when you know " + "it is finished", + record, + ) + ) + continue + verdicts.append( + release( + state_dir, + path=record["path"], + merged_into_ref=merged_into_ref, + environ=environ, + runner=runner, + proc=proc, + ) + ) + return verdicts + + +def render_verdict(verdict: Mapping[str, Any]) -> str: + """One line a person reads after a close.""" + action = str(verdict.get("action") or NONE) + if action == NONE: + return "" + if action == MANY: + return "".join(render_verdict(item) for item in verdict.get("verdicts") or []) + branch = str(verdict.get("branch") or "") + path = str(verdict.get("path") or "") + if action == REMOVED: + return f"Removed the {branch} working copy at {path}.\n" + if not branch and not path: + # A keep that is about the registry rather than about one copy: there + # is no branch and no directory to name, and "Kept the working copy + # at :" reads like a bug on top of the thing it is reporting. + return f"Kept the working copies of this session: {verdict.get('reason')}\n" + return f"Kept the {branch} working copy at {path}: {verdict.get('reason')}\n" + + +def render(records_in: Sequence[Mapping[str, Any]]) -> str: + """The worktree list a person reads.""" + if not records_in: + return " No delegated worktrees.\n" + lines = [" Delegated worktrees"] + width = max(len(str(record.get("branch") or "")) for record in records_in) + for record in records_in: + branch = str(record.get("branch") or "") + session = str(record.get("shpool_id") or "") + suffix = f" [session {session}]" if session else "" + lines.append(f" {branch.ljust(width)} {record.get('path')}{suffix}") + return "\n".join(lines) + "\n" diff --git a/lib/sessionkit_messages/__init__.py b/lib/sessionkit_messages/__init__.py new file mode 100644 index 0000000..0310bb2 --- /dev/null +++ b/lib/sessionkit_messages/__init__.py @@ -0,0 +1,8 @@ +"""Delivery clients for the vendors' own message channels. + +One module per vendor channel, each answering the same question: did this +message reach that session, and if not, exactly what stopped it. "Exactly" is +the point -- the failure this package exists to end is one report ("not +registered (possible trust prompt)") standing in for a missing binary, a dead +session, a refused socket and a real trust prompt alike. +""" diff --git a/lib/sessionkit_messages/claude_socket.py b/lib/sessionkit_messages/claude_socket.py new file mode 100644 index 0000000..5113938 --- /dev/null +++ b/lib/sessionkit_messages/claude_socket.py @@ -0,0 +1,1168 @@ +"""Deliver one message into a RUNNING Claude Code session over its own socket. + +Claude Code 2.1.224+ listens on a per-session Unix socket and Claude Code +2.1.228+ authenticates callers on it. That is an interface built for exactly +this: putting a line into a session a person is sitting in, without spawning a +second Claude to do the typing. + +What it replaces +---------------- +Today's Claude delivery starts a whole `claude -p` sender, tells it to use +SendMessage against a display name, and then reads the TARGET's transcript to +find out whether anything arrived. It costs a model turn per message, it can +only address sessions the registry lists, and its one failure sentence -- +"not registered (possible trust prompt)" -- is printed for a missing binary, a +dead session, a refused socket and a real trust prompt alike. All twenty-one +undelivered events in the last audit carried that sentence; seven were measured +and none of them was a trust prompt. + +This client keeps the spawned sender available as the fallback and makes every +outcome name its own cause. + +The protocol, as read off the running binary (2.1.229, 2026-08-13) +------------------------------------------------------------------ +* The session publishes its socket path in its own registry record, + ``~/.claude/sessions/<pid>.json`` → ``messagingSocketPath``. The default path + is ``$XDG_RUNTIME_DIR/cc-socks/<pid>.sock``, mode 0600. +* Auth: the session writes a peer token to + ``<config dir>/sessions/<pid>.<sha256 of the resolved socket path>.key``, + mode 0600, as ``{"peerToken": "<32 hex>", "procStart": "..."}``. A caller + sends ``{"type":"auth","token":"<peerToken>"}`` as its FIRST line; when auth + is required and the first line is anything else, the daemon logs a drop and + destroys the connection without a word on the wire. +* Messages are JSON lines. A user message is + ``{"type":"user","message":{"content":"..."},"session_id":"<uuid>", …}``. + ``session_id`` is checked against the receiver's own id and the message is + dropped on a mismatch -- which is what makes a recycled pid safe to address. +* Receipts travel back the same way: the receiver connects to the socket named + in the sender's ``from`` address and writes + ``{"type":"control","action":"peer_message_status","orig_msg_id":…, + "status":"delivered"|"held"|"denied"|"expired"}``. So a caller that binds its + own socket in the same directory gets a real cause -- "held" means the target + is asking its operator to approve the message, which is a different world + from "denied". + +MEASURED, not assumed (drill against a live 2.1.229 interactive session, +2026-08-13): a message the target ACCEPTS produces no receipt at all. The +session rendered the injected prompt and answered it, and nothing was written +back to the sender's socket. So the receipt channel is, in practice, how a +message that did NOT go through says why; silence on it is not proof of +anything. This client therefore never reports ``delivered`` without an explicit +receipt: an accepted-looking send returns ``sent-no-receipt`` and carries the +``message_uuid`` it used, which is the id the message keeps in the target's own +transcript -- the caller's existing receipt of record confirms it from there. +Claiming delivery on the sender's own say-so is exactly the mistake the +spawned-sender path made. + +Nothing here writes to a session's files, and nothing here can start a session. + +WHERE THE TOKEN MAY GO. The socket path comes out of a FILE -- the session's +own registry record -- and this module carries the one secret in the round. So +a target is proved to be ours before anything is dialled: the socket must be +owned by this euid, must not be group- or world-writable, must live under this +user's runtime directory, must be reached through no symlink, and its +containing directory must be owned by this euid and writable by nobody else. +Anything else is ``foreign-socket``: no connect, no auth line, no message, no +receipt socket bound beside it. The check is at :func:`_socket_problem`, which +is the one place the capability probe, the send, the rename and the +stale-record disambiguation all pass through, so no path can skip it. + +Kill switch: ``SESSION_KIT_CLAUDE_SOCKET=0`` makes :func:`capability` report +the channel off, which is how a caller falls back to the spawned sender +without a code change. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import hashlib +import json +import os +from pathlib import Path +import re +import socket as socketmod +import stat as statmod +import sys +import tempfile +import time +from typing import Any, Mapping +import uuid as uuidmod + + +UUID_PATTERN = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" +) +TOKEN_PATTERN = re.compile(r"^[0-9a-f]{32}$") +MAX_TEXT_CHARS = 100_000 +MAX_RECORD_BYTES = 64 * 1024 +DEFAULT_TIMEOUT_SECONDS = 20.0 +MAX_TIMEOUT_SECONDS = 600.0 +# How long to wait for a refusal after the auth line. The daemon decides on the +# first frame, so this is a network-free round trip on a local socket; a second +# is generous and it is only ever paid once per send. +AUTH_PROBE_SECONDS = 1.0 +PRIORITIES = ("now", "next", "later") + +# Every way this can end, each meaning one thing. The whole point of the +# module is that a caller never has to guess which of these happened. +DELIVERED = "delivered" +HELD = "held" +DENIED = "denied" +EXPIRED = "expired" +SENT_NO_RECEIPT = "sent-no-receipt" +CLOSED_AFTER_SEND = "closed-after-send" +RENAME_SENT = "rename-sent" +NO_SESSION_RECORD = "no-session-record" +NO_SOCKET_PATH = "no-socket-path" +SOCKET_MISSING = "socket-missing" +NOT_A_SOCKET = "not-a-socket" +FOREIGN_SOCKET = "foreign-socket" +SOCKET_REFUSED = "socket-refused" +PERMISSION_DENIED = "permission-denied" +NO_AUTH_KEY = "no-auth-key" +AUTH_REJECTED = "auth-rejected" +CHANNEL_OFF = "channel-off" +BAD_REQUEST = "bad-request" +SOCKET_ERROR = "socket-error" + +# The whole list, in one place, so "how many outcomes are there" has one +# answer. DELIVERED is the only one that is not a failure; every other name +# below carries its own sentence in FAILURE_DETAIL. +OUTCOMES = ( + DELIVERED, + HELD, + DENIED, + EXPIRED, + SENT_NO_RECEIPT, + CLOSED_AFTER_SEND, + RENAME_SENT, + NO_SESSION_RECORD, + NO_SOCKET_PATH, + SOCKET_MISSING, + NOT_A_SOCKET, + FOREIGN_SOCKET, + SOCKET_REFUSED, + PERMISSION_DENIED, + NO_AUTH_KEY, + AUTH_REJECTED, + CHANNEL_OFF, + BAD_REQUEST, + SOCKET_ERROR, +) + +FAILURE_DETAIL = { + NO_SESSION_RECORD: ( + "Claude has no session record for this conversation on this machine" + ), + NO_SOCKET_PATH: ( + "the session's record names no messaging socket " + "(older Claude Code, or started with --bare)" + ), + SOCKET_MISSING: "the session's messaging socket is not on disk", + NOT_A_SOCKET: "the messaging socket path is not a socket", + # Never a connect and never a token: the record named a socket this user + # cannot prove is one of their own sessions', so nothing was written to it. + FOREIGN_SOCKET: ( + "the record names a socket this user does not own, or one outside this " + "user's runtime directory; nothing was sent to it" + ), + # Two paths end here and the sentence has to be true for both: a connect + # that was refused outright (the session is gone, its socket file left + # behind), and a peer that took the token and then went away before the + # message itself. Nothing was written in either case. + SOCKET_REFUSED: ( + "the messaging socket stopped answering before the message went in; " + "nothing was written, so it is safe to send another way" + ), + PERMISSION_DENIED: "this user may not connect to that session's socket", + NO_AUTH_KEY: ( + "the session published no auth key; a message sent now would be dropped " + "unauthenticated" + ), + AUTH_REJECTED: "the session refused the auth token and closed the connection", + SENT_NO_RECEIPT: "the message was written to the socket but nothing confirmed it", + CLOSED_AFTER_SEND: ( + "the message was written and the session then closed the connection; " + "whether it landed is the target's transcript to answer, and sending it " + "again would risk delivering it twice" + ), + RENAME_SENT: ( + "the rename was written to the session's control channel; the session " + "sends nothing back, so the next snapshot is what shows the new name" + ), + HELD: ( + "the session is holding the message for its operator to approve " + "(crossSessionInbound)" + ), + DENIED: "the session's operator declined the message", + EXPIRED: "the session shut down while still holding the message", + CHANNEL_OFF: "the socket channel is switched off (SESSION_KIT_CLAUDE_SOCKET=0)", + BAD_REQUEST: "the message could not be built", + SOCKET_ERROR: "the socket could not be used", +} + + +@dataclass +class Outcome: + delivered: bool + how: str + detail: str + session_id: str = "" + pid: int | None = None + socket_path: str = "" + # "The session did not refuse our token before the body was written" -- NOT + # "the session confirmed our token". The vendor answers a bad token by + # destroying the connection and saying nothing, so silence is the only + # evidence there is, and silence is not proof. `auth_confirmed` in `extra` + # is the positive form: it is True only when a receipt came back. + authenticated: bool = False + receipt: str = "" + extra: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + record = { + "delivered": self.delivered, + "how": self.how, + "detail": self.detail, + "session_id": self.session_id, + "pid": self.pid, + "socket_path": self.socket_path, + "authenticated": self.authenticated, + "receipt": self.receipt, + } + record.update(self.extra) + return record + + +def _failure(how: str, **kwargs: Any) -> Outcome: + return Outcome( + delivered=False, how=how, detail=FAILURE_DETAIL.get(how, how), **kwargs + ) + + +def _valid_uuid(value: Any) -> str: + text = str(value or "").strip().lower() + return text if UUID_PATTERN.match(text) else "" + + +def claude_config_dir(environ: Mapping[str, str], home: Path) -> Path: + configured = environ.get("CLAUDE_CONFIG_DIR") + return Path(configured) if configured else home / ".claude" + + +def channel_enabled(environ: Mapping[str, str]) -> bool: + value = (environ.get("SESSION_KIT_CLAUDE_SOCKET") or "").strip().casefold() + return value not in {"0", "off", "no", "false"} + + +def _read_json(path: Path, *, no_follow: bool = False) -> Any: + """One bounded JSON file, or None. + + ``no_follow`` is for the auth key: the token is the one secret this module + touches, and a symlink where the vendor publishes a key is not a key. The + writer of our own records is equally careful (attention.write_record). + """ + try: + if no_follow: + descriptor = os.open( + path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + ) + with os.fdopen(descriptor, "rb") as handle: + payload = handle.read(MAX_RECORD_BYTES + 1) + else: + with open(path, "rb") as handle: + payload = handle.read(MAX_RECORD_BYTES + 1) + except OSError: + return None + if len(payload) > MAX_RECORD_BYTES: + return None + try: + return json.loads(payload.decode("utf-8", "strict")) + except (UnicodeDecodeError, ValueError): + return None + + +@dataclass +class Target: + session_id: str + pid: int + socket_path: Path + record_path: Path + version: str = "" + kind: str = "" + status: str = "" + + +def find_target( + session_id: str, + *, + environ: Mapping[str, str] | None = None, + home: Path | None = None, + config_dir: Path | None = None, +) -> Target | Outcome: + """The live session record for one conversation, or the reason there is none. + + The record is the vendor's own registry, one file per PID, and it carries + the socket path. Reading it (rather than running `claude agents --json`) + keeps this path free of a subprocess that restarts a daemon. + """ + exact = _valid_uuid(session_id) + if not exact: + return _failure(BAD_REQUEST, session_id=str(session_id or "")) + environ = environ if environ is not None else os.environ + home = home if home is not None else Path.home() + root = config_dir if config_dir is not None else claude_config_dir(environ, home) + sessions = root / "sessions" + try: + candidates = sorted(sessions.glob("*.json")) + except OSError: + candidates = [] + found: list[Target] = [] + incomplete: Outcome | None = None + for path in candidates: + if path.is_symlink() or not path.name[:-5].isdigit(): + continue + record = _read_json(path) + if not isinstance(record, Mapping): + continue + if _valid_uuid(record.get("sessionId")) != exact: + continue + pid = record.get("pid") + if not isinstance(pid, int) or pid <= 0: + continue + socket_path = record.get("messagingSocketPath") + if not isinstance(socket_path, str) or not socket_path: + # Remember it, but keep looking: a crash-and-resume leaves the old + # record on disk beside the live one, and the dead one must not be + # allowed to answer for the conversation. + if incomplete is None: + incomplete = _failure(NO_SOCKET_PATH, session_id=exact, pid=pid) + continue + found.append( + Target( + session_id=exact, + pid=pid, + socket_path=Path(socket_path), + record_path=path, + version=str(record.get("version") or ""), + kind=str(record.get("kind") or ""), + status=str(record.get("status") or ""), + ) + ) + if not found: + return incomplete or _failure(NO_SESSION_RECORD, session_id=exact) + if len(found) == 1: + return found[0] + # Same conversation, several records. The one whose socket ANSWERS is the + # live session; the others are what a crash left behind. Highest pid first, + # because a resumed session is the newer process -- but the connect is what + # decides, not the ordering. + found.sort(key=lambda item: item.pid, reverse=True) + for target in found: + # The gate first: a stale-record probe is still a connect, and a + # record planted to be the "live" one must not get one either. + if _socket_problem(target.socket_path, environ=environ): + continue + probe = socketmod.socket(socketmod.AF_UNIX, socketmod.SOCK_STREAM) + probe.settimeout(1.0) + try: + probe.connect(os.fspath(target.socket_path)) + except OSError: + continue + finally: + probe.close() + return target + return found[0] + + +def auth_key_path(target: Target, *, config_dir: Path) -> Path: + """Where the session publishes the token a peer must present. + + Named ``<pid>.<sha256 of the socket path>.key``. The vendor hashes + ``path.resolve(...)``, which normalises the path but does NOT follow + symlinks, so this normalises the same way rather than calling ``realpath``: + on a platform where the runtime directory has a symlinked component -- macOS + ``/tmp`` and ``/var``, which the vendor's own fallback path uses -- resolving + the link would produce a key name that does not exist, and the channel would + report ``no-auth-key`` forever with nothing to point at. + """ + normalised = os.path.normpath(os.path.abspath(os.fspath(target.socket_path))) + digest = hashlib.sha256(normalised.encode("utf-8")).hexdigest() + return config_dir / "sessions" / f"{target.pid}.{digest}.key" + + +def read_peer_token(target: Target, *, config_dir: Path) -> str: + record = _read_json(auth_key_path(target, config_dir=config_dir), no_follow=True) + if not isinstance(record, Mapping): + return "" + token = record.get("peerToken") + if isinstance(token, str) and TOKEN_PATTERN.match(token): + return token + return "" + + +def capability( + session_id: str = "", + *, + environ: Mapping[str, str] | None = None, + home: Path | None = None, + config_dir: Path | None = None, +) -> dict[str, Any]: + """Can this box deliver over the socket -- and to this session in particular. + + Answered from files only: no connection is made, nothing is sent. A caller + uses it to choose a channel before it has a message to lose. + """ + environ = environ if environ is not None else os.environ + home = home if home is not None else Path.home() + root = config_dir if config_dir is not None else claude_config_dir(environ, home) + report: dict[str, Any] = { + "available": False, + "enabled": channel_enabled(environ), + "reason": "", + "session_id": _valid_uuid(session_id), + "socket_path": "", + "authenticated": False, + } + if not report["enabled"]: + report["reason"] = CHANNEL_OFF + return report + if not report["session_id"]: + # No session named: report only whether the machine has the channel at + # all, which is true when any live record publishes a socket THIS gate + # would let us dial. A record naming somebody else's socket is not a + # channel this box has -- answering yes on one would send a caller down + # a path every later step refuses. + try: + records = sorted((root / "sessions").glob("*.json")) + except OSError: + records = [] + for path in records: + record = _read_json(path) + if not isinstance(record, Mapping): + continue + published = record.get("messagingSocketPath") + if not isinstance(published, str) or not published: + continue + if _socket_problem(Path(published), environ=environ) in { + "", + SOCKET_MISSING, + }: + # SOCKET_MISSING is a session that has since exited, not a + # foreign target: the box still has the channel. + report["available"] = True + return report + report["reason"] = NO_SOCKET_PATH + return report + found = find_target( + session_id, environ=environ, home=home, config_dir=root + ) + if isinstance(found, Outcome): + report["reason"] = found.how + return report + report["socket_path"] = os.fspath(found.socket_path) + report["pid"] = found.pid + problem = _socket_problem(found.socket_path, environ=environ) + if problem: + report["reason"] = problem + return report + report["authenticated"] = bool(read_peer_token(found, config_dir=root)) + # Without a key there is nothing to authenticate with, and send() refuses + # rather than writing blind -- so "available" would be a yes a caller + # cannot use. It reports the same cause the send would. + report["available"] = report["authenticated"] + if not report["authenticated"]: + report["reason"] = NO_AUTH_KEY + return report + + +def _bounded_seconds(timeout: object) -> float: + """Any caller's timeout, made finite. Infinity is not a waiting time.""" + import math + + try: + seconds = float(timeout) # type: ignore[arg-type] + except (TypeError, ValueError): + return DEFAULT_TIMEOUT_SECONDS + if not math.isfinite(seconds): + return DEFAULT_TIMEOUT_SECONDS + return min(MAX_TIMEOUT_SECONDS, max(1.0, seconds)) + + +def _peer_closed(connection: socketmod.socket, *, timeout: float) -> bool: + """Has the peer closed its end? A read that ends with nothing says so.""" + previous = connection.gettimeout() + connection.settimeout(max(0.05, float(timeout))) + try: + return connection.recv(1) == b"" + except (socketmod.timeout, TimeoutError, BlockingIOError): + return False + except OSError: + return True + finally: + try: + connection.settimeout(previous) + except OSError: + pass + + +def _write_auth(connection: socketmod.socket, token: str) -> bool: + """Send the auth line alone. True when the session refused it. + + Refusal is silent by design (the daemon destroys the connection), so the + probe is a short read that ends. Nothing else has been written at this + point, which is what makes a True answer safe to retry on another channel. + """ + try: + connection.sendall( + json.dumps({"type": "auth", "token": token}).encode("utf-8") + b"\n" + ) + except OSError: + return True + return _peer_closed(connection, timeout=AUTH_PROBE_SECONDS) + + +def _allowed_socket_roots(environ: Mapping[str, str]) -> list[Path]: + """The directories a session's own messaging socket may live in. + + The vendor's documented path is ``$XDG_RUNTIME_DIR/cc-socks/<pid>.sock`` + (module docstring), so the runtime directory is the root. It is read from + the environment when it is there and derived when it is not: a cron or a + non-login shell -- which is what runs the fleet's delivery -- does not + always export it, and an empty root list would switch the channel off for + every real session rather than refusing a fake one. macOS has no + ``/run/user`` and no ``XDG_RUNTIME_DIR``; its per-user ``$TMPDIR`` (0700, + owned by the user) is where the vendor's fallback path goes, and it is + still held to every ownership and mode check below. + """ + roots: list[Path] = [] + configured = str(environ.get("XDG_RUNTIME_DIR") or "").strip() + if configured: + roots.append(Path(configured)) + return roots + default = Path(f"/run/user/{os.geteuid()}") + if default.is_dir(): + roots.append(default) + if sys.platform == "darwin": + temporary = str(environ.get("TMPDIR") or "").strip() + if temporary: + roots.append(Path(temporary)) + return roots + + +def _absolute(path: Path) -> Path: + """Textually absolute and normalised -- no symlink is followed here. + + ``..`` is removed by text, which is what makes "is it under the root" + answerable; a component that is a symlink is then rejected outright by + :func:`_symlink_between`, so the two together cannot be walked around. + """ + return Path(os.path.normpath(os.path.abspath(os.fspath(path)))) + + +def _under(child: Path, root: Path) -> bool: + try: + common = os.path.commonpath([os.fspath(child), os.fspath(root)]) + except (ValueError, OSError): + return False + return common == os.fspath(root) and child != root + + +def _containing_root(absolute: Path, environ: Mapping[str, str]) -> Path | None: + """Which allowed root holds this path, in its own or its resolved form. + + Both forms are tried because a platform may publish either: macOS's + ``/var`` and ``/tmp`` are symlinks into ``/private``, so the root as + configured and the root as resolved are two different strings for one + directory. Resolving the ROOT is safe; resolving the path below it is not, + which is why only the root is put through ``realpath``. + """ + for root in _allowed_socket_roots(environ): + raw = _absolute(root) + candidates = [raw] + try: + resolved = Path(os.path.realpath(os.fspath(raw))) + except OSError: + resolved = raw + if resolved != raw: + candidates.append(resolved) + for candidate in candidates: + if _under(absolute, candidate): + return candidate + return None + + +def _symlink_between(root: Path, absolute: Path) -> bool: + """True when any component below the root is a symlink. + + Walked one component at a time from the root down, so a link planted + anywhere on the way -- ``$XDG_RUNTIME_DIR/cc-socks`` replaced by a link + into /tmp, or the socket name itself being a link -- is caught. The root + itself is not examined: it is allowed to be a symlinked system path + (macOS), and it is not a component an attacker gets to add. + """ + try: + relative = os.path.relpath(os.fspath(absolute), os.fspath(root)) + except (ValueError, OSError): + return True + current = root + for part in Path(relative).parts: + if part in ("..", "/"): + return True + current = current / part + try: + if os.path.islink(current): + return True + except OSError: + return True + return False + + +def _foreign_reason(path: Path, info: os.stat_result, environ: Mapping[str, str]) -> str: + """Empty when this socket is provably one of THIS user's own sessions'. + + Empty is the only answer that lets a connect happen; the sentence returned + otherwise is for a test or a person reading a probe, never for the wire. + """ + euid = os.geteuid() + if info.st_uid != euid: + return "the socket is owned by another user" + if info.st_mode & (statmod.S_IWGRP | statmod.S_IWOTH): + return "the socket is writable by other users" + absolute = _absolute(path) + root = _containing_root(absolute, environ) + if root is None: + return "the socket is outside this user's runtime directory" + if _symlink_between(root, absolute): + return "a symlink stands between the runtime directory and the socket" + try: + directory = os.stat(absolute.parent) + except OSError: + return "the socket's directory cannot be read" + if directory.st_uid != euid: + return "the socket's directory is owned by another user" + if directory.st_mode & (statmod.S_IWGRP | statmod.S_IWOTH): + return "the socket's directory is writable by other users" + return "" + + +def _socket_problem(path: Path, *, environ: Mapping[str, str] | None = None) -> str: + """The one gate every path to the wire passes through. + + ``capability``, ``send``, ``rename`` and ``find_target``'s stale-record + probe all call this before they touch the socket, which is what makes the + ownership check impossible to skip: there is no second route to a connect. + """ + environ = environ if environ is not None else os.environ + try: + info = os.lstat(path) + except FileNotFoundError: + return SOCKET_MISSING + except PermissionError: + return PERMISSION_DENIED + except OSError: + return SOCKET_MISSING + if statmod.S_ISLNK(info.st_mode): + # A session publishes its own socket, not a link to one. Refused before + # the target is even looked at, so what it points to never matters. + return FOREIGN_SOCKET + if not statmod.S_ISSOCK(info.st_mode): + return NOT_A_SOCKET + if _foreign_reason(path, info, environ): + return FOREIGN_SOCKET + return "" + + +class _ReceiptListener: + """Our own socket, so the target has somewhere to send the receipt. + + The receiver only answers into its own namespace -- same directory as its + socket, name ending in .sock -- so this binds beside the target's socket + and removes itself afterwards. That directory is only ever the target's, + and a target only exists after _socket_problem proved the directory is + owned by this euid and writable by nobody else; the umask below closes the + remaining instant between bind and chmod, so the socket is never reachable + at the process umask even for the length of one syscall. + """ + + def __init__(self, directory: Path) -> None: + self.path: Path | None = None + self.socket: socketmod.socket | None = None + self.directory = directory + + def __enter__(self) -> "_ReceiptListener": + try: + self.directory.mkdir(mode=0o700, parents=True, exist_ok=True) + handle, name = tempfile.mkstemp( + prefix="session-kit-receipt.", suffix=".sock", dir=self.directory + ) + os.close(handle) + os.unlink(name) + listener = socketmod.socket(socketmod.AF_UNIX, socketmod.SOCK_STREAM) + # bind() takes the mode from the umask, so a permissive one would + # publish this socket world-writable until the chmod lands. The + # chmod stays as the belt: this is the brace. + previous_umask = os.umask(0o077) + try: + listener.bind(name) + finally: + os.umask(previous_umask) + os.chmod(name, 0o600) + listener.listen(4) + listener.setblocking(False) + self.path = Path(name) + self.socket = listener + except OSError: + self.close() + return self + + def __exit__(self, *_: object) -> None: + self.close() + + def close(self) -> None: + if self.socket is not None: + try: + self.socket.close() + except OSError: + pass + self.socket = None + if self.path is not None: + try: + os.unlink(self.path) + except OSError: + pass + self.path = None + + def address(self) -> str: + return f"uds:{self.path}" if self.path is not None else "" + + def wait_for_status(self, msg_id: str, deadline: float) -> str: + """The status the target reports for this exact message, or "".""" + if self.socket is None: + return "" + import selectors + + selector = selectors.DefaultSelector() + selector.register(self.socket, selectors.EVENT_READ) + buffers: dict[socketmod.socket, bytes] = {} + try: + while time.monotonic() < deadline: + for key, _ in selector.select( + timeout=max(0.05, min(0.5, deadline - time.monotonic())) + ): + if key.fileobj is self.socket: + try: + connection, _ = self.socket.accept() + except OSError: + continue + connection.setblocking(False) + buffers[connection] = b"" + selector.register(connection, selectors.EVENT_READ) + continue + connection = key.fileobj # type: ignore[assignment] + try: + block = connection.recv(65536) + except (BlockingIOError, InterruptedError): + continue + except OSError: + block = b"" + if not block: + selector.unregister(connection) + buffers.pop(connection, None) + connection.close() + continue + buffers[connection] = (buffers.get(connection, b"") + block)[ + :MAX_RECORD_BYTES + ] + while b"\n" in buffers[connection]: + line, _, rest = buffers[connection].partition(b"\n") + buffers[connection] = rest + status = _receipt_status(line, msg_id) + if status: + return status + return "" + finally: + for connection in list(buffers): + try: + selector.unregister(connection) + except (KeyError, ValueError): + pass + connection.close() + selector.close() + + +def _receipt_status(line: bytes, msg_id: str) -> str: + try: + record = json.loads(line.decode("utf-8", "strict")) + except (UnicodeDecodeError, ValueError): + return "" + if not isinstance(record, Mapping): + return "" + if record.get("type") != "control": + return "" + if record.get("action") != "peer_message_status": + return "" + if str(record.get("orig_msg_id") or "") != msg_id: + return "" + status = str(record.get("status") or "") + return status if status in {DELIVERED, HELD, DENIED, EXPIRED} else "" + + +def send( + session_id: str, + text: str, + *, + priority: str = "next", + sender: str = "session-kit", + timeout: float = DEFAULT_TIMEOUT_SECONDS, + want_receipt: bool = True, + environ: Mapping[str, str] | None = None, + home: Path | None = None, + config_dir: Path | None = None, +) -> Outcome: + """Put one message into a running session. Every failure names itself.""" + environ = environ if environ is not None else os.environ + home = home if home is not None else Path.home() + root = config_dir if config_dir is not None else claude_config_dir(environ, home) + if not channel_enabled(environ): + return _failure(CHANNEL_OFF, session_id=_valid_uuid(session_id)) + if not isinstance(text, str) or not text.strip(): + return _failure(BAD_REQUEST, session_id=_valid_uuid(session_id)) + if len(text) > MAX_TEXT_CHARS: + return _failure(BAD_REQUEST, session_id=_valid_uuid(session_id)) + if priority not in PRIORITIES: + priority = "next" + found = find_target(session_id, environ=environ, home=home, config_dir=root) + if isinstance(found, Outcome): + return found + # Before the key is even read: a foreign target never gets a connect, and + # the token is never carried past this line for one. + problem = _socket_problem(found.socket_path, environ=environ) + if problem: + return _failure( + problem, + session_id=found.session_id, + pid=found.pid, + socket_path=os.fspath(found.socket_path), + ) + token = read_peer_token(found, config_dir=root) + if not token: + # Sending anyway would be dropped by any 2.1.228+ session and look + # exactly like a session that ignored us. + return _failure( + NO_AUTH_KEY, + session_id=found.session_id, + pid=found.pid, + socket_path=os.fspath(found.socket_path), + ) + msg_id = uuidmod.uuid4().hex + # The id the message keeps once the target queues it, so a caller can find + # this exact message in the target's own transcript. + message_uuid = str(uuidmod.uuid4()) + deadline = time.monotonic() + _bounded_seconds(timeout) + listener = _ReceiptListener(found.socket_path.parent) + with listener: + message = { + "type": "user", + "message": {"content": text}, + # Checked against the receiver's own id: a recycled pid cannot be + # handed a message meant for the conversation that used to own it. + "session_id": found.session_id, + "uuid": message_uuid, + "msg_id": msg_id, + "priority": priority, + "from": listener.address() or sender, + } + connection = socketmod.socket(socketmod.AF_UNIX, socketmod.SOCK_STREAM) + connection.settimeout(min(10.0, _bounded_seconds(timeout))) + try: + connection.connect(os.fspath(found.socket_path)) + except ConnectionRefusedError: + connection.close() + return _failure( + SOCKET_REFUSED, + session_id=found.session_id, + pid=found.pid, + socket_path=os.fspath(found.socket_path), + ) + except PermissionError: + connection.close() + return _failure( + PERMISSION_DENIED, + session_id=found.session_id, + pid=found.pid, + socket_path=os.fspath(found.socket_path), + ) + except OSError as error: + connection.close() + outcome = _failure( + SOCKET_ERROR, + session_id=found.session_id, + pid=found.pid, + socket_path=os.fspath(found.socket_path), + ) + outcome.detail = f"{FAILURE_DETAIL[SOCKET_ERROR]}: {error}" + return outcome + # THE AUTH WINDOW AND THE SEND WINDOW ARE SEPARATE, and this is the + # whole safety of the channel. The daemon answers a bad token by + # destroying the connection without a word, so "the peer closed" is the + # only refusal signal there is -- but it is also what a peer does after + # accepting a message, or when the session exits a second later. Sent + # together, one close would have to mean both "refused, safe to resend" + # and "accepted, resending would deliver it twice" (B13/C13). + # + # So the auth line goes first, ALONE, and the body is written only if + # the connection is still open. A close before the body is a refusal + # and nothing was sent. A close after it is never a refusal again. + rejected = _write_auth(connection, token) + if rejected: + connection.close() + return _failure( + AUTH_REJECTED, + session_id=found.session_id, + pid=found.pid, + socket_path=os.fspath(found.socket_path), + authenticated=False, + extra={"sent": False}, + ) + try: + connection.sendall(json.dumps(message).encode("utf-8") + b"\n") + except BrokenPipeError: + # The peer went away between the auth probe and the body: the + # message did NOT go in, so this is safe to retry elsewhere -- and + # it is not an auth refusal either, because the token was accepted. + connection.close() + return _failure( + SOCKET_REFUSED, + session_id=found.session_id, + pid=found.pid, + socket_path=os.fspath(found.socket_path), + authenticated=True, + extra={"sent": False}, + ) + except OSError as error: + connection.close() + outcome = _failure( + SOCKET_ERROR, + session_id=found.session_id, + pid=found.pid, + socket_path=os.fspath(found.socket_path), + authenticated=True, + extra={"sent": False}, + ) + outcome.detail = f"{FAILURE_DETAIL[SOCKET_ERROR]}: {error}" + return outcome + status = "" + if want_receipt and listener.path is not None: + status = listener.wait_for_status(msg_id, deadline) + # After the body is on the wire, a closed connection says only that the + # conversation with the socket is over -- never that the message was + # refused. Whether it landed is the transcript's answer, not ours. + closed_after_send = _peer_closed(connection, timeout=0.2) + connection.close() + if status == DELIVERED: + return Outcome( + delivered=True, + how=DELIVERED, + detail="the session confirmed the message reached its queue", + session_id=found.session_id, + pid=found.pid, + socket_path=os.fspath(found.socket_path), + authenticated=True, + receipt=status, + extra={"message_uuid": message_uuid, "sent": True, "auth_confirmed": True}, + ) + if status in {HELD, DENIED, EXPIRED}: + return _failure( + status, + session_id=found.session_id, + pid=found.pid, + socket_path=os.fspath(found.socket_path), + authenticated=True, + receipt=status, + extra={"message_uuid": message_uuid, "sent": True, "auth_confirmed": True}, + ) + # Written, authenticated, nothing came back. Never reported as delivered: + # the receipt is the only proof, and a caller that must be sure confirms it + # from the target's own transcript rather than sending it again. + return _failure( + CLOSED_AFTER_SEND if closed_after_send else SENT_NO_RECEIPT, + session_id=found.session_id, + pid=found.pid, + socket_path=os.fspath(found.socket_path), + authenticated=True, + extra={ + "message_uuid": message_uuid, + "sent": True, + # Nothing came back, so the token was never positively confirmed -- + # a late close cannot be told apart from a session that accepted + # the message and then finished with the connection. + "auth_confirmed": False, + }, + ) + + +def rename( + session_id: str, + name: str, + *, + environ: Mapping[str, str] | None = None, + home: Path | None = None, + config_dir: Path | None = None, +) -> Outcome: + """Rename a RUNNING session through the vendor's own control channel. + + The kit's name push appends an ``agent-name`` record to the session's + transcript -- a file both vendors document as internal (see + docs/pinned-internal-formats.md). This is the supported way to say the same + thing, and it reaches a session that is already on screen rather than the + next one that starts. + + The receiver applies the name and sends nothing back, so this call can + never report a rename as done: a successful return means the frame was + written to a session that accepted our token, and the name itself is + confirmed by the next inventory snapshot. ``delivered`` stays False for the + same reason it does on the send path -- a module whose thesis is "never + claim delivery without a receipt" may not make one exception for itself. + """ + environ = environ if environ is not None else os.environ + home = home if home is not None else Path.home() + root = config_dir if config_dir is not None else claude_config_dir(environ, home) + if not channel_enabled(environ): + return _failure(CHANNEL_OFF, session_id=_valid_uuid(session_id)) + cleaned = " ".join(str(name or "").split())[:120] + if not cleaned: + return _failure(BAD_REQUEST, session_id=_valid_uuid(session_id)) + found = find_target(session_id, environ=environ, home=home, config_dir=root) + if isinstance(found, Outcome): + return found + problem = _socket_problem(found.socket_path, environ=environ) + if problem: + return _failure( + problem, + session_id=found.session_id, + pid=found.pid, + socket_path=os.fspath(found.socket_path), + ) + token = read_peer_token(found, config_dir=root) + if not token: + return _failure( + NO_AUTH_KEY, + session_id=found.session_id, + pid=found.pid, + socket_path=os.fspath(found.socket_path), + ) + connection = socketmod.socket(socketmod.AF_UNIX, socketmod.SOCK_STREAM) + connection.settimeout(5.0) + try: + connection.connect(os.fspath(found.socket_path)) + # Same two windows as send(): the token is offered alone, and the + # control frame follows only if the session kept the connection. + if _write_auth(connection, token): + connection.close() + return _failure( + AUTH_REJECTED, + session_id=found.session_id, + pid=found.pid, + socket_path=os.fspath(found.socket_path), + extra={"sent": False}, + ) + connection.sendall( + json.dumps( + { + "type": "control", + "action": "rename", + "name": cleaned, + "session_id": found.session_id, + } + ).encode("utf-8") + + b"\n" + ) + except ConnectionRefusedError: + return _failure( + SOCKET_REFUSED, + session_id=found.session_id, + pid=found.pid, + socket_path=os.fspath(found.socket_path), + ) + except OSError as error: + outcome = _failure( + SOCKET_ERROR, + session_id=found.session_id, + pid=found.pid, + socket_path=os.fspath(found.socket_path), + ) + outcome.detail = f"{FAILURE_DETAIL[SOCKET_ERROR]}: {error}" + return outcome + finally: + connection.close() + return _failure( + RENAME_SENT, + session_id=found.session_id, + pid=found.pid, + socket_path=os.fspath(found.socket_path), + authenticated=True, + extra={"name": cleaned, "sent": True}, + ) + + +def _timeout_argument(value: str) -> float: + """A timeout has to be a real, bounded number of seconds. + + `float("inf")` parses, and an infinite deadline makes the receipt wait -- + the ordinary accepted-but-no-receipt path -- run until something kills the + process. A refusal here costs one error message; the alternative costs a + hung delivery nobody is watching. + """ + import argparse + import math + + try: + seconds = float(value) + except (TypeError, ValueError): + raise argparse.ArgumentTypeError(f"not a number of seconds: {value!r}") + if not math.isfinite(seconds) or not 0 < seconds <= MAX_TIMEOUT_SECONDS: + raise argparse.ArgumentTypeError( + f"seconds must be above 0 and at most {MAX_TIMEOUT_SECONDS:g}" + ) + return seconds + + +def main(argv: list[str] | None = None) -> int: + import argparse + + parser = argparse.ArgumentParser( + prog="claude_socket", + description="Deliver one message into a running Claude Code session.", + ) + parser.add_argument("--session-id", required=True) + parser.add_argument("--text") + parser.add_argument("--priority", default="next", choices=PRIORITIES) + parser.add_argument( + "--timeout", type=_timeout_argument, default=DEFAULT_TIMEOUT_SECONDS + ) + parser.add_argument("--no-receipt", action="store_true") + parser.add_argument( + "--capability", + action="store_true", + help="report whether this session can be reached, and send nothing", + ) + arguments = parser.parse_args(argv) + if arguments.capability: + print(json.dumps(capability(arguments.session_id), sort_keys=True)) + return 0 + if not arguments.text: + parser.error("--text is required unless --capability is given") + outcome = send( + arguments.session_id, + arguments.text, + priority=arguments.priority, + timeout=arguments.timeout, + want_receipt=not arguments.no_receipt, + ) + print(json.dumps(outcome.as_dict(), sort_keys=True)) + return 0 if outcome.delivered else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lib/sessionkit_projects/__init__.py b/lib/sessionkit_projects/__init__.py new file mode 100644 index 0000000..750368f --- /dev/null +++ b/lib/sessionkit_projects/__init__.py @@ -0,0 +1,43 @@ +"""Projects as one first-class thing. + +A project is a canonical absolute root directory. A ``projects.tsv`` shortcut +names one, a committed ``session-kit.toml`` describes one, an intake arrives +from inside one, and a session runs in one. :mod:`.identity` holds the single +membership rule the rest of the kit resolves against; :mod:`.manifest` reads +the committed description; :mod:`.launch` turns a project into what ``sp new`` +should start; :mod:`.context` answers what is already going on there. +""" + +from __future__ import annotations + +from .context import group_sessions_by_project, project_context +from .identity import ( + MANIFEST_NAME, + Project, + Resolver, + canonical, + default_projects_file, + group_projects, + main_repository, +) +from .launch import approve_startup, launch_plan, plan_for_target, startup_state +from .manifest import ManifestError, loads as load_manifest, read as read_manifest + +__all__ = [ + "MANIFEST_NAME", + "ManifestError", + "Project", + "Resolver", + "approve_startup", + "canonical", + "default_projects_file", + "group_projects", + "group_sessions_by_project", + "launch_plan", + "load_manifest", + "main_repository", + "plan_for_target", + "project_context", + "read_manifest", + "startup_state", +] diff --git a/lib/sessionkit_projects/__main__.py b/lib/sessionkit_projects/__main__.py new file mode 100644 index 0000000..312c244 --- /dev/null +++ b/lib/sessionkit_projects/__main__.py @@ -0,0 +1,7 @@ +"""``python3 -m sessionkit_projects`` runs the project command line.""" + +from __future__ import annotations + +from .cli import main + +raise SystemExit(main()) diff --git a/lib/sessionkit_projects/cli.py b/lib/sessionkit_projects/cli.py new file mode 100644 index 0000000..c36e1a8 --- /dev/null +++ b/lib/sessionkit_projects/cli.py @@ -0,0 +1,352 @@ +"""The machine surface the shell and the picker call. + +Every verb prints one JSON object on standard output and returns 0, or prints +a message on standard error and returns non-zero. Nothing here writes to the +terminal a person is looking at: the callers own presentation, this owns the +answer. + +Exit codes + 0 the answer is on standard output + 1 the target does not resolve to a project + 2 the arguments are wrong + 3 a manifest is malformed +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys +from typing import Any, Mapping, Sequence + +if __package__ in (None, ""): # the shell runs this file as a script + sys.path.insert(0, os.fspath(Path(__file__).resolve().parents[1])) + from sessionkit_projects import context as context_module # noqa: E402 + from sessionkit_projects import identity, launch, manifest # noqa: E402 +else: + from . import context as context_module + from . import identity, launch, manifest + +EXIT_OK = 0 +EXIT_NO_PROJECT = 1 +EXIT_USAGE = 2 +EXIT_MANIFEST = 3 + +MAX_SNAPSHOT_BYTES = 32 * 1024 * 1024 + + +def _print(value: object) -> None: + json.dump(value, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + + +def _state_dir(args: argparse.Namespace, environ: Mapping[str, str]) -> Path: + if getattr(args, "state_dir", None): + return Path(args.state_dir) + explicit = environ.get("SESSION_KIT_STATE_DIR") + if explicit: + return Path(explicit).expanduser() + base = environ.get("XDG_STATE_HOME") or os.path.join( + environ.get("HOME", str(Path.home())), ".local", "state" + ) + return Path(base) / "session-kit" + + +def _resolver( + args: argparse.Namespace, environ: Mapping[str, str] +) -> identity.Resolver: + return identity.Resolver(getattr(args, "projects_file", None), environ=environ) + + +def _target(resolver: identity.Resolver, target: str | None) -> identity.Project | None: + """An alias, a directory, or, with neither, the current directory.""" + if not target: + return resolver.resolve(os.getcwd()) + project = resolver.resolve_alias(target) + if project is not None: + return project + if target.startswith(("/", ".", "~")) or os.sep in target: + return resolver.resolve(target) + return None + + +# ---- reading the other stores ------------------------------------------- + + +def _read_snapshot(path: str | None) -> tuple[list[dict[str, Any]], list[str]]: + """Session rows from a snapshot file, or from the live inventory. + + A context that cannot read the inventory says so; it never reports an + empty session list as though the project were idle. + """ + if path: + try: + source = Path(path) + if source.stat().st_size > MAX_SNAPSHOT_BYTES: + return [], ["the session snapshot is too large to read"] + payload = source.read_text(encoding="utf-8") + except (OSError, ValueError) as error: + return [], [f"the session snapshot is unreadable: {error}"] + else: + import subprocess # noqa: PLC0415, only the live path pays for this + + tool = Path(__file__).resolve().parents[1] / "session_inventory.py" + try: + completed = subprocess.run( + [sys.executable, os.fspath(tool), "snapshot", "--no-write"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except (OSError, subprocess.SubprocessError) as error: + return [], [f"the session inventory is unavailable: {error}"] + if completed.returncode != 0: + return [], ["the session inventory is unavailable"] + payload = completed.stdout + try: + value = json.loads(payload) + except ValueError: + return [], ["the session inventory returned unreadable data"] + rows = value.get("sessions") if isinstance(value, Mapping) else None + if not isinstance(rows, list): + return [], ["the session inventory returned no session list"] + return [row for row in rows if isinstance(row, dict)], [] + + +# ---- verbs -------------------------------------------------------------- + + +def _resolve(args: argparse.Namespace, environ: Mapping[str, str]) -> int: + resolver = _resolver(args, environ) + project = _target(resolver, args.target) + if project is None: + print("no project covers that directory", file=sys.stderr) + return EXIT_NO_PROJECT + _print({"project": project.to_dict(), "warnings": list(resolver.warnings)}) + return EXIT_OK + + +def _list(args: argparse.Namespace, environ: Mapping[str, str]) -> int: + resolver = _resolver(args, environ) + projects = resolver.projects() + _print( + { + "projects": [project.to_dict() for project in projects], + "groups": identity.group_projects(projects), + "warnings": list(resolver.warnings), + } + ) + return EXIT_OK + + +def _launch_plan(args: argparse.Namespace, environ: Mapping[str, str]) -> int: + resolver = _resolver(args, environ) + project = _target(resolver, args.target) + if project is None: + print(f"unknown or invalid project: {args.target}", file=sys.stderr) + return EXIT_NO_PROJECT + try: + plan = launch.launch_plan( + project, + requested_provider=args.provider, + requested_account=args.account, + requested_model=args.model, + state_dir=_state_dir(args, environ), + interactive=args.interactive, + ) + except ValueError as error: + print(str(error), file=sys.stderr) + return EXIT_USAGE + if args.format == "tsv": + return _print_plan_tsv(plan) + _print(plan) + return EXIT_OK + + +# The fields `sp new` needs, in one line the shell can read with `read -r`. +# A shell caller cannot recover from a value containing a tab or a newline, so +# rather than emit an ambiguous line this refuses and the caller keeps the +# JSON path. +PLAN_TSV_FIELDS = ("root", "provider", "account", "model", "startup_state") + + +def _print_plan_tsv(plan: Mapping[str, Any]) -> int: + fields = [] + for name in PLAN_TSV_FIELDS: + value = plan.get(name) + text = "" if value is None else str(value) + if "\t" in text or "\n" in text or "\r" in text: + print( + f"the project's {name} contains a tab or newline and cannot be " + "passed to the shell", + file=sys.stderr, + ) + return EXIT_USAGE + fields.append(text) + sys.stdout.write("\t".join(fields) + "\n") + return EXIT_OK + + +def _approve_startup(args: argparse.Namespace, environ: Mapping[str, str]) -> int: + resolver = _resolver(args, environ) + project = _target(resolver, args.target) + if project is None: + print(f"unknown or invalid project: {args.target}", file=sys.stderr) + return EXIT_NO_PROJECT + values = project.manifest or {} + command = values.get("startup") if isinstance(values, Mapping) else None + if not command: + print("that project's manifest declares no startup command", file=sys.stderr) + return EXIT_USAGE + if not project.trusted: + print( + "add the project with `session-kit projects add` before approving " + "its startup command", + file=sys.stderr, + ) + return EXIT_USAGE + if args.expect and args.expect != launch.startup_digest(str(command)): + # The command the operator was shown must be the command approved. + print("the startup command changed since it was shown", file=sys.stderr) + return EXIT_USAGE + launch.approve_startup(project.root, str(command), _state_dir(args, environ)) + _print( + { + "root": project.root, + "startup": command, + "startup_digest": launch.startup_digest(str(command)), + "approved": True, + } + ) + return EXIT_OK + + +def _context(args: argparse.Namespace, environ: Mapping[str, str]) -> int: + resolver = _resolver(args, environ) + project = _target(resolver, args.target) + if project is None: + print("no project covers that directory", file=sys.stderr) + return EXIT_NO_PROJECT + sessions, session_notes = _read_snapshot(args.snapshot) + _print( + context_module.project_context( + project, + sessions=sessions, + group=resolver.projects(), + unavailable=list(session_notes), + ) + ) + return EXIT_OK + + +def _group_sessions(args: argparse.Namespace, environ: Mapping[str, str]) -> int: + resolver = _resolver(args, environ) + sessions, notes = _read_snapshot(args.snapshot) + assignments = resolver.assign( + str(row.get("cwd")) for row in sessions if isinstance(row.get("cwd"), str) + ) + grouped = context_module.group_sessions_by_project( + sessions, assignments, projects=resolver.projects() + ) + grouped["unavailable"] = notes + grouped["warnings"] = list(resolver.warnings) + _print(grouped) + return EXIT_OK + + +def _check(args: argparse.Namespace, environ: Mapping[str, str]) -> int: + path = Path(args.path) + if path.is_dir(): + path = path / manifest.MANIFEST_NAME + try: + values = manifest.read(path) + except manifest.ManifestError as error: + print(str(error), file=sys.stderr) + return EXIT_MANIFEST + _print({"path": os.fspath(path), "manifest": values, "valid": True}) + return EXIT_OK + + +# ---- entry point -------------------------------------------------------- + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="session-kit projects", + description="Resolve projects, their manifests, and their context.", + ) + parser.add_argument("--projects-file", help="path to projects.tsv") + parser.add_argument("--state-dir", help="path to the Session Kit state directory") + subparsers = parser.add_subparsers(dest="command", required=True) + + resolve = subparsers.add_parser( + "resolve", help="the project a directory belongs to" + ) + resolve.add_argument( + "target", nargs="?", help="alias or directory; default is $PWD" + ) + resolve.set_defaults(handler=_resolve) + + listing = subparsers.add_parser("list", help="every project this host knows") + listing.set_defaults(handler=_list) + + plan = subparsers.add_parser("launch-plan", help="what `sp new` should start") + plan.add_argument("target", nargs="?") + plan.add_argument("--provider", choices=launch.PROVIDERS) + plan.add_argument("--account") + plan.add_argument("--model") + plan.add_argument( + "--interactive", + action="store_true", + help="a person can answer, so an unapproved startup command may be offered", + ) + plan.add_argument( + "--format", + choices=("json", "tsv"), + default="json", + help="tsv prints one line: " + ", ".join(PLAN_TSV_FIELDS), + ) + plan.set_defaults(handler=_launch_plan) + + approve = subparsers.add_parser( + "approve-startup", help="record approval of a project's startup command" + ) + approve.add_argument("target", nargs="?") + approve.add_argument("--expect", help="digest of the command that was shown") + approve.set_defaults(handler=_approve_startup) + + context = subparsers.add_parser("context", help="what is going on in a project") + context.add_argument("target", nargs="?") + context.add_argument("--snapshot", help="read sessions from this snapshot file") + context.set_defaults(handler=_context) + + grouped = subparsers.add_parser( + "group-sessions", help="bucket the session list by project" + ) + grouped.add_argument("--snapshot", help="read sessions from this snapshot file") + grouped.set_defaults(handler=_group_sessions) + + check = subparsers.add_parser("check", help="validate a session-kit.toml") + check.add_argument("path", help="the manifest, or the directory holding it") + check.set_defaults(handler=_check) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _parser() + args = parser.parse_args(argv) + handler = args.handler + try: + return int(handler(args, os.environ)) + except manifest.ManifestError as error: + print(str(error), file=sys.stderr) + return EXIT_MANIFEST + except BrokenPipeError: + return EXIT_OK + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lib/sessionkit_projects/context.py b/lib/sessionkit_projects/context.py new file mode 100644 index 0000000..bbcb479 --- /dev/null +++ b/lib/sessionkit_projects/context.py @@ -0,0 +1,212 @@ +"""What is going on in a project, gathered from every record that mentions it. + +Entering a project should answer the question a person actually has, "where +did I leave this?", and the answer is keyed on the project root: a session +belongs to a project when its working directory is inside that root. The one +membership rule lives in :mod:`lib.sessionkit_projects.identity`, so the +picker and ``sp new`` agree about what is in a project. + +Everything here is a pure function over records the caller supplies. The +inventory snapshot is read by the command line in +:mod:`lib.sessionkit_projects.cli`, which keeps this module testable without +a live provider or a running daemon. +""" + +from __future__ import annotations + +from typing import Any, Iterable, Mapping, Sequence + +from .identity import Project, _within + +MAX_SESSIONS = 200 + +# The fields a caller may see about a session. Copying a whitelist rather than +# the whole row keeps a context view from growing new fields, including +# sensitive ones, whenever the inventory row grows one. +SESSION_FIELDS = ( + "shpool_id", + "display_shpool_id", + "terminal_number", + "provider", + "display_provider", + "cwd", + "title", + "display_title", + "display_color", + "agent_status", + "availability", + "needs_you", + "active_subagent_count", + "started_at_unix_ms", + # Set by the worktree registry when a row's directory is a materialised + # worktree: {"branch", "repo"}, where repo is the main repository, the + # same path this module calls a group root. + "worktree", +) + + +def _text(value: object) -> str | None: + return value if isinstance(value, str) and value else None + + +def _worktree_repo(row: Mapping[str, Any]) -> str | None: + """The main repository a session's worktree was cut from, if recorded. + + A materialised worktree is rarely on the host's shortcut list and need not + carry a manifest, so path resolution alone can leave its sessions + unplaceable. The worktree registry already knows the answer, and records it + as the same absolute repository path this module groups by. + """ + label = row.get("worktree") + if not isinstance(label, Mapping): + return None + repo = _text(label.get("repo")) + return repo if repo and repo.startswith("/") else None + + +def _member_roots(project: Project, group: Sequence[Project] | None) -> list[str]: + """The directories that count as this project. + + A linked worktree is the same project as the repository it was cut from, + so work in a worktree belongs to the project rather than disappearing + into an unnamed directory. + """ + roots = [project.root] + for member in group or (): + if member.group == project.group and member.root not in roots: + roots.append(member.root) + return roots + + +def _belongs(directory: str | None, roots: Iterable[str]) -> str | None: + if not directory: + return None + for root in roots: + if _within(directory, root): + return root + return None + + +def _project_sessions( + sessions: Iterable[Mapping[str, Any]], roots: Sequence[str], group_root: str +) -> list[dict[str, Any]]: + found: list[dict[str, Any]] = [] + for row in sessions: + if not isinstance(row, Mapping): + continue + matched = _belongs(_text(row.get("cwd")), roots) + if matched is None and _worktree_repo(row) == group_root: + matched = group_root + if matched is None: + continue + entry = {field: row.get(field) for field in SESSION_FIELDS if field in row} + entry["project_root"] = matched + found.append(entry) + if len(found) >= MAX_SESSIONS: + break + return found + + +def project_context( + project: Project, + *, + sessions: Iterable[Mapping[str, Any]] = (), + group: Sequence[Project] | None = None, + unavailable: Sequence[str] = (), +) -> dict[str, Any]: + """Everything the kit knows about one project, in one record. + + ``unavailable`` names the stores the caller could not read, so a context + with no sessions because the inventory is missing never reads the same as + a context with no sessions because nothing is running. + """ + roots = _member_roots(project, group) + found_sessions = _project_sessions(sessions, roots, project.group) + manifest = project.manifest if isinstance(project.manifest, Mapping) else {} + team = manifest.get("team", []) + return { + "project": project.to_dict(), + "roots": roots, + "sessions": found_sessions, + "team": [dict(role) for role in team] if isinstance(team, list) else [], + "counts": { + "sessions": len(found_sessions), + "worktrees": max(0, len(roots) - 1), + "team_roles": len(team) if isinstance(team, list) else 0, + }, + "unavailable": list(unavailable), + } + + +def group_sessions_by_project( + sessions: Iterable[Mapping[str, Any]], + assignments: Mapping[str, Project | None], + *, + projects: Sequence[Project] | None = None, +) -> dict[str, Any]: + """Bucket a session list by project for a grouped view. + + ``assignments`` comes from :meth:`Resolver.assign`, so one pass over the + shortcut table and the manifests serves the whole list. Sessions whose + directory belongs to no project are kept together under ``ungrouped`` + rather than dropped, a session the picker cannot place must still be + reachable. + + ``projects`` is the host's project list, used to place a worktree session + by its recorded repository. It must come from the host rather than from + ``assignments``: a project whose work has been fanned out into worktrees + may have no session in its own directory at all, which is precisely the + delegated case this placement exists for. + """ + groups: dict[str, dict[str, Any]] = {} + ungrouped: list[dict[str, Any]] = [] + known = { + project.group: project + for project in assignments.values() + if project is not None + } + for listed in projects or (): + known.setdefault(listed.group, listed) + for row in sessions: + if not isinstance(row, Mapping): + continue + directory = _text(row.get("cwd")) + project = assignments.get(directory) if directory else None + entry = {field: row.get(field) for field in SESSION_FIELDS if field in row} + by_worktree = False + if project is None: + # A worktree of a known project, placed by the registry rather than + # by its path. Its own root is not recorded on the row, so the row + # is attributed to the project it belongs to, not to a root this + # cannot name. + repo = _worktree_repo(row) + project = known.get(repo) if repo else None + by_worktree = project is not None + if project is None: + ungrouped.append(entry) + continue + bucket = groups.setdefault( + project.group, + { + "group_root": project.group, + "name": project.name, + "roots": [], + "sessions": [], + }, + ) + if project.root == project.group: + bucket["name"] = project.name + if not by_worktree and project.root not in bucket["roots"]: + bucket["roots"].append(project.root) + entry["project_root"] = project.group if by_worktree else project.root + entry["project_name"] = project.name + bucket["sessions"].append(entry) + for bucket in groups.values(): + bucket["roots"].sort() + return { + "groups": sorted( + groups.values(), + key=lambda item: (str(item["name"]), str(item["group_root"])), + ), + "ungrouped": ungrouped, + } diff --git a/lib/sessionkit_projects/identity.py b/lib/sessionkit_projects/identity.py new file mode 100644 index 0000000..7682d35 --- /dev/null +++ b/lib/sessionkit_projects/identity.py @@ -0,0 +1,526 @@ +"""One project identity for the two senses the kit grew separately. + +Session Kit had two unrelated things called a project: + +* a shortcut row in ``projects.tsv``, an alias, one default provider, and one + absolute directory, discovered from what the providers had already run; +* a supervisor intake, a unit of delegated work whose only sense of place is + the ``source_cwd`` it arrived from, with no name and no link to any row. + +Session rows carried a third, derived notion: a title hint computed from the +last interesting path component, which never consulted either of the above. + +This module makes those one thing. A project is a **canonical absolute root +directory**. Every other record, a shortcut alias, a manifest, an intake, a +live session, belongs to the project whose root is the deepest ancestor (or +equal) of its directory. That single membership rule is what lets the picker +group sessions, the supervisor place an intake, and ``sp new`` resume a +context without any of them inventing their own idea of a project. + +Trust +----- +A ``session-kit.toml`` is repository content. Reading one is always safe; +letting one decide what launches is not, because cloning a repository would +otherwise be enough to choose a model, an account, and a startup command on +this host. So a manifest's launch fields apply only when the project root is +**host-trusted**, and the trust record is the existing deliberate act of +adding the project: a non-ignore ``projects.tsv`` row for that exact root, or +for the main repository of a linked git worktree. An untrusted manifest is +still parsed, reported, and shown, it just never changes a launch. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import os +from pathlib import Path +import re +from typing import Any, Iterable, Mapping, Sequence + +from . import manifest as manifest_module +from .manifest import MANIFEST_NAME, ManifestError + +# A repository sits a few levels below a home directory, not a few hundred. +# The bound also ends the walk on a cyclic mount rather than spinning. +MAX_WALK_DEPTH = 64 +IGNORE_KIND = "ignore" +# A live row's kind is the project's default provider, and "any" is the row +# that has none: one directory is one project, and which provider opens it is +# answered when a session starts. +ANY_KIND = "any" +GITDIR_RE = re.compile(r"\A\s*gitdir:\s*(?P<path>\S.*?)\s*\Z") +MAX_GIT_POINTER_BYTES = 4096 + +SOURCE_MANIFEST = "manifest" +SOURCE_SHORTCUT = "shortcut" + + +@dataclass(frozen=True) +class Project: + """A resolved project identity. + + ``root`` is the canonical directory that defines the project. ``alias`` + is the shortcut name when the host has one, ``name`` the display name + (manifest name, alias, or the directory's own name, in that order). + ``group_root`` is the main repository when ``root`` is a linked git + worktree, so worktrees group under the project they were cut from. + """ + + root: str + name: str + source: str + alias: str | None = None + group_root: str | None = None + description: str | None = None + trusted: bool = False + manifest: Mapping[str, Any] | None = None + manifest_path: str | None = None + manifest_error: str | None = None + shortcut_provider: str | None = None + warnings: tuple[str, ...] = field(default_factory=tuple) + + @property + def is_worktree(self) -> bool: + return self.group_root is not None and self.group_root != self.root + + @property + def group(self) -> str: + return self.group_root or self.root + + def to_dict(self) -> dict[str, Any]: + return { + "root": self.root, + "name": self.name, + "source": self.source, + "alias": self.alias, + "group_root": self.group, + "is_worktree": self.is_worktree, + "description": self.description, + "trusted": self.trusted, + "manifest": dict(self.manifest) if self.manifest is not None else None, + "manifest_path": self.manifest_path, + "manifest_error": self.manifest_error, + "shortcut_provider": self.shortcut_provider, + "warnings": list(self.warnings), + } + + +# ---- paths -------------------------------------------------------------- + + +def canonical(path: str | os.PathLike[str]) -> str | None: + """Resolve a directory the way every record here stores it, or ``None``. + + Symlinks are resolved so that two records naming the same directory by + different routes are one project rather than two. + """ + try: + resolved = Path(path).expanduser().resolve(strict=False) + except (OSError, RuntimeError, ValueError): + return None + text = os.fspath(resolved) + if not text.startswith("/"): + return None + return text + + +def _within(child: str, ancestor: str) -> bool: + """Is ``child`` the directory ``ancestor`` or inside it? + + Compared on path components, so ``/srv/song`` is not inside ``/srv/so``. + """ + if child == ancestor: + return True + prefix = ancestor if ancestor.endswith("/") else ancestor + "/" + return child.startswith(prefix) + + +def depth(path: str) -> int: + return len([part for part in path.split("/") if part]) + + +# ---- git worktrees ------------------------------------------------------ + + +def main_repository(root: str) -> str | None: + """The main repository of a linked git worktree, or ``None``. + + A linked worktree has a ``.git`` *file* holding ``gitdir: <main>/.git/ + worktrees/<name>``. Reading that pointer is enough; running ``git`` would + execute a binary chosen by ``PATH`` for what is a one-line file read. + """ + pointer = Path(root) / ".git" + try: + if pointer.is_symlink() or not pointer.is_file(): + return None + if pointer.stat().st_size > MAX_GIT_POINTER_BYTES: + return None + text = pointer.read_text(encoding="utf-8", errors="strict") + except (OSError, UnicodeDecodeError, ValueError): + return None + match = GITDIR_RE.match(text.strip()) + if not match: + return None + target = match.group("path") + if not target.startswith("/"): + target = os.path.normpath(os.path.join(root, target)) + marker = "/.git/worktrees/" + index = target.find(marker) + if index <= 0: + return None + return canonical(target[:index]) + + +# ---- the shortcut table ------------------------------------------------- + + +def _shortcut_rows( + projects_file: Path | None, +) -> tuple[list[dict[str, str]], list[str]]: + """Read ``projects.tsv`` into canonical rows, tolerating a missing file. + + Parsing mirrors ``sessionkit_inventory.projects``: three tab fields, a + known kind, an absolute directory. A row this cannot read is skipped with + a warning rather than failing the whole resolution, a project list is a + convenience, and one bad hand-edited line must not blind the picker to + every other project. + """ + warnings: list[str] = [] + if projects_file is None: + return [], warnings + try: + if projects_file.is_symlink() or not projects_file.is_file(): + return [], warnings + payload = projects_file.read_bytes() + except OSError as error: + return [], [f"the project list is unreadable: {error.strerror}"] + try: + text = payload.decode("utf-8", "strict") + except UnicodeDecodeError: + return [], ["the project list is not valid UTF-8"] + rows: list[dict[str, str]] = [] + for number, line in enumerate(text.splitlines(), 1): + if not line.strip() or line.lstrip().startswith("#"): + continue + fields = line.split("\t") + if len(fields) < 3: + warnings.append(f"project list line {number} is not three tab fields") + continue + alias, kind, raw_cwd = fields[0], fields[1], fields[2] + if kind not in ("claude", "codex", "shell", ANY_KIND, IGNORE_KIND): + warnings.append(f"project list line {number} has an unknown kind") + continue + if not raw_cwd.startswith("/"): + warnings.append(f"project list line {number} is not an absolute directory") + continue + resolved = canonical(raw_cwd) + if resolved is None: + warnings.append(f"project list line {number} has an unusable directory") + continue + rows.append({"alias": alias, "kind": kind, "cwd": resolved}) + return rows, warnings + + +# ---- resolution --------------------------------------------------------- + + +class Resolver: + """Resolve directories and aliases to project identities. + + One instance reads the shortcut table once, so resolving every row of a + session list costs one file read rather than one per row. + """ + + def __init__( + self, + projects_file: Path | str | None = None, + *, + environ: Mapping[str, str] | None = None, + ) -> None: + environ = os.environ if environ is None else environ + if projects_file is None: + projects_file = default_projects_file(environ) + self.projects_file = Path(projects_file) if projects_file is not None else None + self._rows, self._warnings = _shortcut_rows(self.projects_file) + self._manifests: dict[ + str, tuple[Mapping[str, Any] | None, str | None, str | None] + ] = {} + + # ---- shortcut table -------------------------------------------------- + + @property + def warnings(self) -> tuple[str, ...]: + return tuple(self._warnings) + + def shortcuts(self) -> list[dict[str, str]]: + return [dict(row) for row in self._rows if row["kind"] != IGNORE_KIND] + + def _shortcut_for_root(self, root: str) -> dict[str, str] | None: + for row in self._rows: + if row["kind"] != IGNORE_KIND and row["cwd"] == root: + return row + return None + + def _deepest_shortcut(self, directory: str) -> dict[str, str] | None: + best: dict[str, str] | None = None + for row in self._rows: + if row["kind"] == IGNORE_KIND or not _within(directory, row["cwd"]): + continue + if best is None or depth(row["cwd"]) > depth(best["cwd"]): + best = row + return best + + def is_ignored(self, directory: str) -> bool: + return any( + row["kind"] == IGNORE_KIND and row["cwd"] == directory for row in self._rows + ) + + # ---- manifests ------------------------------------------------------- + + def _manifest_at( + self, directory: str + ) -> tuple[Mapping[str, Any] | None, str | None, str | None]: + """``(values, path, error)`` for a manifest in ``directory``.""" + cached = self._manifests.get(directory) + if cached is not None: + return cached + path = Path(directory) / MANIFEST_NAME + result: tuple[Mapping[str, Any] | None, str | None, str | None] + try: + present = path.is_file() or path.is_symlink() + except OSError: + present = False + if not present: + result = (None, None, None) + else: + try: + result = (manifest_module.read(path), os.fspath(path), None) + except ManifestError as error: + # A malformed manifest is reported, never guessed around: the + # project still resolves so the directory does not vanish from + # the picker, but nothing it asked for is applied. + result = (None, os.fspath(path), str(error)) + self._manifests[directory] = result + return result + + def _manifest_root( + self, directory: str + ) -> tuple[str, Mapping[str, Any] | None, str | None, str | None] | None: + """Walk up from ``directory`` to the nearest manifest.""" + current = directory + for _ in range(MAX_WALK_DEPTH): + values, path, error = self._manifest_at(current) + if path is not None: + root = current + if values is not None and values.get("root") not in (None, "."): + candidate = canonical(os.path.join(current, str(values["root"]))) + if candidate is not None and _within(candidate, current): + root = candidate + return root, values, path, error + parent = os.path.dirname(current) + if not parent or parent == current: + return None + current = parent + return None + + # ---- trust ----------------------------------------------------------- + + def trusts(self, root: str, group_root: str | None = None) -> bool: + """Has the host deliberately added this project? + + A linked worktree inherits the trust of the repository it was cut + from: ``sp new --worktree`` creates directories the host never listed, + and refusing them would make the manifest useless exactly where + delegated work runs. + """ + if self._shortcut_for_root(root) is not None: + return True + return ( + group_root is not None and self._shortcut_for_root(group_root) is not None + ) + + # ---- the public entry points ---------------------------------------- + + def resolve(self, path: str | os.PathLike[str]) -> Project | None: + """The project a directory belongs to, or ``None`` if it has none.""" + directory = canonical(path) + if directory is None: + return None + found = self._manifest_root(directory) + if found is not None: + root, values, manifest_path, error = found + return self._project( + root, + source=SOURCE_MANIFEST, + values=values, + manifest_path=manifest_path, + error=error, + ) + row = self._deepest_shortcut(directory) + if row is None: + return None + return self._project(row["cwd"], source=SOURCE_SHORTCUT) + + def resolve_alias(self, alias: str) -> Project | None: + """The project a shortcut alias names, manifest included. + + The row that matched is carried through. Looking the directory up again + would answer with the *first* row for it, so on a file that still lists + one directory twice, the shape this release folds, asking for the + second alias returned the first alias's provider and started the wrong + one. + """ + for row in self._rows: + if row["alias"] != alias or row["kind"] == IGNORE_KIND: + continue + values, manifest_path, error = self._manifest_at(row["cwd"]) + source = SOURCE_MANIFEST if manifest_path is not None else SOURCE_SHORTCUT + return self._project( + row["cwd"], + source=source, + values=values, + manifest_path=manifest_path, + error=error, + row=row, + ) + return None + + def projects(self) -> list[Project]: + """Every project the host knows: one per non-ignore shortcut row.""" + seen: set[str] = set() + found: list[Project] = [] + for row in self._rows: + if row["kind"] == IGNORE_KIND or row["cwd"] in seen: + continue + seen.add(row["cwd"]) + values, manifest_path, error = self._manifest_at(row["cwd"]) + source = SOURCE_MANIFEST if manifest_path is not None else SOURCE_SHORTCUT + found.append( + self._project( + row["cwd"], + source=source, + values=values, + manifest_path=manifest_path, + error=error, + ) + ) + found.sort(key=lambda project: (project.name, project.root)) + return found + + def assign(self, directories: Iterable[str]) -> dict[str, Project | None]: + """Resolve many directories at once, for grouping a session list.""" + result: dict[str, Project | None] = {} + for directory in directories: + if directory in result: + continue + result[directory] = self.resolve(directory) + return result + + # ---- construction ---------------------------------------------------- + + def _live_rows_for_root(self, root: str) -> list[dict[str, str]]: + return [ + item + for item in self._rows + if item["kind"] != IGNORE_KIND and item["cwd"] == root + ] + + def _project( + self, + root: str, + *, + source: str, + values: Mapping[str, Any] | None = None, + manifest_path: str | None = None, + error: str | None = None, + row: Mapping[str, str] | None = None, + ) -> Project: + siblings = self._live_rows_for_root(root) + row = dict(row) if row is not None else self._shortcut_for_root(root) + group_root = main_repository(root) + warnings: list[str] = [] + manifest_values = values + if manifest_values is not None and manifest_path is None: + manifest_values = None + name = None + description = None + if manifest_values is not None: + name = manifest_values.get("name") + description = manifest_values.get("description") + if not name and row is not None: + name = row["alias"] + if not name: + name = Path(root).name or root + trusted = self.trusts(root, group_root) + if manifest_path is not None and error is None and not trusted: + warnings.append( + "this project has a session-kit.toml but is not on the host's " + "project list, so its launch settings are shown, not applied; " + "add it with `session-kit projects add` to apply them" + ) + if error is not None: + warnings.append(error) + shortcut_provider = row["kind"] if row is not None else None + if len({item["kind"] for item in siblings}) > 1: + # The directory is still listed more than once and the rows + # disagree about the provider. There is no default to be had from + # that, and picking either row's answer is how a directory worked + # on with both providers started the wrong one. + shortcut_provider = ANY_KIND + warnings.append( + f"{root} is listed more than once with different providers, so " + "it has no default; name one when you start a session, or run " + "`session-kit projects normalize`" + ) + return Project( + root=root, + name=str(name), + source=source, + alias=row["alias"] if row is not None else None, + group_root=group_root, + description=str(description) if description else None, + trusted=trusted, + manifest=manifest_values, + manifest_path=manifest_path, + manifest_error=error, + shortcut_provider=shortcut_provider, + warnings=tuple(warnings), + ) + + +def default_projects_file(environ: Mapping[str, str] | None = None) -> Path: + """The same path ``bin/session_kit_common`` resolves, and by the same rules.""" + environ = os.environ if environ is None else environ + override = environ.get("SESSION_KIT_PROJECTS_FILE") + if override: + return Path(override) + config_home = environ.get("XDG_CONFIG_HOME") or os.path.join( + environ.get("HOME", str(Path.home())), ".config" + ) + return Path(config_home) / "session-kit" / "projects.tsv" + + +def group_projects( + projects: Sequence[Project], +) -> list[dict[str, Any]]: + """Collapse worktrees under the project they were cut from. + + The picker groups sessions by project; a project cut into four delegated + worktrees should read as one project with four working copies, not five + unrelated rows. + """ + groups: dict[str, dict[str, Any]] = {} + for project in projects: + entry = groups.setdefault( + project.group, + {"group_root": project.group, "name": project.name, "members": []}, + ) + if project.root == project.group: + entry["name"] = project.name + entry["members"].append(project.to_dict()) + for entry in groups.values(): + entry["members"].sort(key=lambda member: str(member["root"])) + return sorted( + groups.values(), + key=lambda entry: (str(entry["name"]), str(entry["group_root"])), + ) diff --git a/lib/sessionkit_projects/launch.py b/lib/sessionkit_projects/launch.py new file mode 100644 index 0000000..0ac8f8d --- /dev/null +++ b/lib/sessionkit_projects/launch.py @@ -0,0 +1,261 @@ +"""Turn a project into the exact plan ``sp new`` should launch. + +``sp new <alias>`` used to resolve one thing: the alias to a directory and a +default provider. Everything else that makes a session the right session, the +account, the model, the command that sets it up, the workers a delegated run +needs, lived in the head of whoever typed the command, or in a chat message +that scrolled away. + +A plan here is that missing half, assembled from the committed manifest and +the host's own shortcut row, with the precedence stated once: + + an explicit flag > a trusted manifest > the shortcut row > a default + +Startup commands +---------------- +``model`` and ``account`` select among things this host already has; a +manifest cannot conjure either. ``startup`` is different: it is a command +line, arriving from a repository, that would run on this machine. So a +startup command is approved once per project by its exact text, and the +approval is recorded locally by digest. Editing the command in the repository +withdraws the approval, and a non-interactive launch never approves anything: +a delegated worker starts without the startup command and says so, rather +than running a line nobody read. +""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import stat +import tempfile +from typing import Any, Mapping + +from .identity import Project, Resolver + +PROVIDERS = ("claude", "codex", "shell") +DEFAULT_PROVIDER = "shell" +# The shortcut kind of a project that keeps no default provider at all. +ANY_PROVIDER = "any" +APPROVAL_FILE = "startup-approvals.tsv" +MAX_APPROVAL_BYTES = 256 * 1024 + +APPROVED = "approved" +UNAPPROVED = "unapproved" +CHANGED = "changed" + + +def startup_digest(command: str) -> str: + return hashlib.sha256(command.encode("utf-8")).hexdigest() + + +def _approval_path(state_dir: Path | str) -> Path: + return Path(state_dir) / "projects" / APPROVAL_FILE + + +def _read_approvals(state_dir: Path | str | None) -> dict[str, str]: + if state_dir is None: + return {} + path = _approval_path(state_dir) + try: + if path.is_symlink() or not path.is_file(): + return {} + info = path.stat() + if info.st_size > MAX_APPROVAL_BYTES: + return {} + if info.st_uid != os.getuid() or info.st_mode & (stat.S_IRWXG | stat.S_IRWXO): + # A record another account can write is not a record of what this + # account approved. + return {} + text = path.read_text(encoding="utf-8", errors="strict") + except (OSError, UnicodeDecodeError, ValueError): + return {} + approvals: dict[str, str] = {} + for line in text.splitlines(): + if not line.strip() or line.startswith("#"): + continue + fields = line.split("\t") + if len(fields) != 2 or not fields[0].startswith("/"): + continue + approvals[fields[0]] = fields[1].strip() + return approvals + + +def startup_state( + root: str, command: str | None, state_dir: Path | str | None +) -> str | None: + """``approved``, ``changed``, ``unapproved``, or ``None`` with no command.""" + if not command: + return None + recorded = _read_approvals(state_dir).get(root) + if recorded is None: + return UNAPPROVED + return APPROVED if recorded == startup_digest(command) else CHANGED + + +def approve_startup(root: str, command: str, state_dir: Path | str) -> None: + """Record that this account approved this exact startup command.""" + approvals = _read_approvals(state_dir) + approvals[root] = startup_digest(command) + path = _approval_path(state_dir) + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + payload = "".join( + f"{key}\t{value}\n" for key, value in sorted(approvals.items()) + ).encode("utf-8") + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb", closefd=True) as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + except OSError: + try: + os.unlink(temporary) + except OSError: + pass + raise + + +def _manifest_value(project: Project, key: str) -> Any: + if not project.trusted or project.manifest is None: + return None + return project.manifest.get(key) + + +def launch_plan( + project: Project, + *, + requested_provider: str | None = None, + requested_account: str | None = None, + requested_model: str | None = None, + state_dir: Path | str | None = None, + interactive: bool = False, +) -> dict[str, Any]: + """The full launch plan for one project, with every choice explained. + + ``decisions`` records where each applied value came from, so ``sp new`` + and the picker can tell a person that the model was the manifest's and + the account was theirs, instead of a session that quietly differs from + what they typed. + """ + decisions: dict[str, str] = {} + notes: list[str] = list(project.warnings) + + provider = None + if requested_provider: + if requested_provider not in PROVIDERS: + raise ValueError(f"unknown provider: {requested_provider}") + provider, decisions["provider"] = requested_provider, "flag" + if provider is None: + value = _manifest_value(project, "provider") + if value: + provider, decisions["provider"] = str(value), "manifest" + if provider is None and project.shortcut_provider in PROVIDERS: + provider, decisions["provider"] = project.shortcut_provider, "shortcut" + if provider is None and project.shortcut_provider == ANY_PROVIDER: + # The project is on the list with no default provider, so there is + # nothing to fall back to and nothing to guess: the caller names one + # when the session starts. Falling through to the default would open a + # shell in a directory the person asked to work in. + decisions["provider"] = "unset" + notes.append( + "this project has no default provider; name one when you start a " + "session in it" + ) + elif provider is None: + provider, decisions["provider"] = DEFAULT_PROVIDER, "default" + + account = None + if requested_account: + account, decisions["account"] = requested_account, "flag" + else: + value = _manifest_value(project, "account") + if value: + account, decisions["account"] = str(value), "manifest" + + model = None + if requested_model: + model, decisions["model"] = requested_model, "flag" + else: + value = _manifest_value(project, "model") + if value: + model, decisions["model"] = str(value), "manifest" + + if provider == "shell" and (account or model): + # A shell session has no provider to give an account or a model to. + if account: + notes.append( + "a shell session takes no account; the account was not applied" + ) + account, decisions["account"] = None, "not-applicable" + if model: + notes.append("a shell session takes no model; the model was not applied") + model, decisions["model"] = None, "not-applicable" + + startup = _manifest_value(project, "startup") + startup_text = str(startup) if startup else None + state = startup_state(project.root, startup_text, state_dir) + startup_applied = False + if startup_text: + if state == APPROVED: + startup_applied, decisions["startup"] = True, "manifest" + elif not interactive: + decisions["startup"] = "needs-approval" + notes.append( + "the project's startup command has not been approved on this host " + "and this launch is not interactive, so it was not run" + ) + else: + decisions["startup"] = "needs-approval" + + team = [] + manifest = project.manifest if project.manifest is not None else {} + for role in manifest.get("team", []) if isinstance(manifest, Mapping) else []: + team.append(dict(role)) + if team and not project.trusted: + notes.append( + "team roles are listed from the manifest but will not be launched " + "until the project is on the host's project list" + ) + + return { + "root": project.root, + "name": project.name, + "alias": project.alias, + "group_root": project.group, + "is_worktree": project.is_worktree, + "trusted": project.trusted, + "source": project.source, + "provider": provider, + "account": account, + "model": model, + "startup": startup_text, + "startup_state": state, + "startup_applied": startup_applied, + "startup_digest": startup_digest(startup_text) if startup_text else None, + "team": team, + "team_launchable": bool(team) and project.trusted, + "decisions": decisions, + "manifest_path": project.manifest_path, + "manifest_error": project.manifest_error, + "notes": notes, + } + + +def plan_for_target( + resolver: Resolver, + target: str, + **kwargs: Any, +) -> dict[str, Any] | None: + """Plan for an alias, or for the project containing a directory.""" + project = resolver.resolve_alias(target) + if project is None and ( + target.startswith("/") or target.startswith(".") or os.sep in target + ): + project = resolver.resolve(target) + if project is None: + return None + return launch_plan(project, **kwargs) diff --git a/lib/sessionkit_projects/manifest.py b/lib/sessionkit_projects/manifest.py new file mode 100644 index 0000000..05ca7e8 --- /dev/null +++ b/lib/sessionkit_projects/manifest.py @@ -0,0 +1,415 @@ +"""Read and validate a project's committable ``session-kit.toml`` manifest. + +A manifest is repository content: it travels with a clone, it is written by +whoever can push to that repository, and it names things that decide what +Session Kit launches. It is therefore parsed as untrusted input, and the +fields that change a launch are applied only for a project the host has +deliberately added (see :mod:`lib.sessionkit_projects.identity`). + +The file is parsed by one hand-written reader for a strict TOML subset on +every supported Python, rather than by ``tomllib`` where it exists and a +fallback where it does not. Two parsers mean a manifest that means one thing +on Python 3.11 and another on 3.10; one parser cannot. The subset is the +documented contract, and ``tests/test_projects_manifest.py`` asserts that +``tomllib`` reads the same fixtures to the same values wherever it is +available. +""" + +from __future__ import annotations + +from pathlib import Path +import re +from typing import Any, Iterable, Mapping + +MANIFEST_NAME = "session-kit.toml" +MAX_MANIFEST_BYTES = 64 * 1024 +MAX_MANIFEST_LINES = 2000 +MAX_TEAM_ROLES = 32 +MAX_TEXT = 2000 + +PROVIDERS = ("claude", "codex", "shell") +NAME_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,47}") +ACCOUNT_RE = re.compile(r"[a-z][a-z0-9_-]{0,11}") +MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}") +BRANCH_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,127}") +ROLE_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,31}") + +# The keys a manifest may set. An unknown key is refused by name rather than +# ignored: a typo in `provider` that silently launched the default would be +# indistinguishable from the manifest working. +TOP_LEVEL_KEYS = ( + "name", + "root", + "description", + "provider", + "account", + "model", + "startup", +) +TEAM_KEYS = ( + "role", + "provider", + "account", + "model", + "branch", + "expertise", + "scope", + "workstream", + "rationale", +) + +_BARE_KEY_RE = re.compile(r"[A-Za-z0-9_-]+") +_ESCAPES = { + '"': '"', + "\\": "\\", + "b": "\b", + "f": "\f", + "n": "\n", + "r": "\r", + "t": "\t", +} + + +class ManifestError(ValueError): + """A manifest is unreadable, malformed, or outside the supported subset.""" + + +def _fail(line_number: int, problem: str) -> "ManifestError": + return ManifestError(f"{MANIFEST_NAME} line {line_number}: {problem}") + + +# ---- the strict TOML subset -------------------------------------------- + + +def _basic_string(raw: str, line_number: int) -> tuple[str, str]: + index = 1 + out: list[str] = [] + while index < len(raw): + char = raw[index] + if char == '"': + return "".join(out), raw[index + 1 :] + if char == "\\": + index += 1 + if index >= len(raw): + break + escape = raw[index] + if escape in _ESCAPES: + out.append(_ESCAPES[escape]) + index += 1 + continue + if escape == "u": + digits = raw[index + 1 : index + 5] + if len(digits) != 4 or not re.fullmatch(r"[0-9A-Fa-f]{4}", digits): + raise _fail(line_number, "a \\u escape needs four hex digits") + code = int(digits, 16) + if 0xD800 <= code <= 0xDFFF: + raise _fail(line_number, "a \\u escape may not name a surrogate") + out.append(chr(code)) + index += 5 + continue + raise _fail(line_number, f"unsupported string escape \\{escape}") + if char in "\n\r": + break + out.append(char) + index += 1 + raise _fail(line_number, "a string is missing its closing quote") + + +def _literal_string(raw: str, line_number: int) -> tuple[str, str]: + end = raw.find("'", 1) + if end == -1: + raise _fail(line_number, "a string is missing its closing quote") + return raw[1:end], raw[end + 1 :] + + +def _scalar(raw: str, line_number: int) -> tuple[Any, str]: + raw = raw.lstrip() + if not raw: + raise _fail(line_number, "a key needs a value") + if raw.startswith('"""') or raw.startswith("'''"): + raise _fail(line_number, "multi-line strings are not supported") + if raw.startswith('"'): + return _basic_string(raw, line_number) + if raw.startswith("'"): + return _literal_string(raw, line_number) + match = re.match(r"(true|false)(?![A-Za-z0-9_-])", raw) + if match: + return match.group(1) == "true", raw[match.end() :] + match = re.match(r"[+-]?[0-9](?:_?[0-9])*(?![A-Za-z0-9._+-])", raw) + if match: + return int(match.group(0).replace("_", "")), raw[match.end() :] + raise _fail( + line_number, + "only strings, integers, booleans, and arrays of those are supported", + ) + + +def _array(raw: str, line_number: int) -> tuple[list[Any], str]: + rest = raw[1:] + items: list[Any] = [] + while True: + rest = rest.lstrip() + if not rest: + raise _fail(line_number, "an array must open and close on one line") + if rest.startswith("]"): + return items, rest[1:] + if rest.startswith("["): + raise _fail(line_number, "nested arrays are not supported") + value, rest = _scalar(rest, line_number) + items.append(value) + rest = rest.lstrip() + if rest.startswith(","): + rest = rest[1:] + continue + if rest.startswith("]"): + return items, rest[1:] + raise _fail(line_number, "array items are separated by commas") + + +def _value(raw: str, line_number: int) -> Any: + raw = raw.lstrip() + if raw.startswith("["): + value, rest = _array(raw, line_number) + elif raw.startswith("{"): + raise _fail(line_number, "inline tables are not supported") + else: + value, rest = _scalar(raw, line_number) + rest = rest.lstrip() + if rest and not rest.startswith("#"): + raise _fail(line_number, "unexpected text after a value") + return value + + +def parse_subset(text: str) -> dict[str, Any]: + """Parse the manifest subset: top-level pairs and ``[[team]]`` tables. + + Anything outside the subset raises with its line number. A manifest is a + handful of settings; refusing what this does not understand is what keeps + the meaning of a file identical on every Python the kit supports. + """ + lines = text.splitlines() + if len(lines) > MAX_MANIFEST_LINES: + raise ManifestError( + f"{MANIFEST_NAME} is longer than {MAX_MANIFEST_LINES} lines" + ) + top: dict[str, Any] = {} + team: list[dict[str, Any]] = [] + # As in TOML, a key after a [[team]] header belongs to that team table, + # not to the project. The documented layout puts project settings first + # for exactly that reason. + current: dict[str, Any] = top + for line_number, line in enumerate(lines, 1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith("["): + if stripped.startswith("[["): + header = re.fullmatch( + r"\[\[\s*([A-Za-z0-9_-]+)\s*\]\](?:\s*#.*)?", stripped + ) + if not header: + raise _fail(line_number, "malformed array-of-tables header") + if header.group(1) != "team": + raise _fail( + line_number, + f"unknown section [[{header.group(1)}]]; only [[team]] exists", + ) + if len(team) >= MAX_TEAM_ROLES: + raise ManifestError( + f"{MANIFEST_NAME} declares more than {MAX_TEAM_ROLES} team roles" + ) + current = {} + team.append(current) + continue + header = re.fullmatch(r"\[\s*([A-Za-z0-9_.-]+)\s*\](?:\s*#.*)?", stripped) + if not header: + raise _fail(line_number, "malformed table header") + raise _fail( + line_number, + f"unknown section [{header.group(1)}]; settings are top level " + "and roles are [[team]]", + ) + key_part, separator, value_part = line.partition("=") + if not separator: + raise _fail(line_number, "expected key = value") + key = key_part.strip() + if key.startswith(('"', "'")): + raise _fail(line_number, "quoted keys are not supported") + if not _BARE_KEY_RE.fullmatch(key): + raise _fail(line_number, f"unsupported key {key!r}") + if key in current: + raise _fail(line_number, f"{key} is set twice") + current[key] = _value(value_part, line_number) + if team: + top["team"] = team + return top + + +# ---- validation --------------------------------------------------------- + + +def _string( + values: Mapping[str, Any], + key: str, + *, + pattern: re.Pattern[str] | None = None, + limit: int = MAX_TEXT, + label: str, +) -> str | None: + if key not in values: + return None + raw = values[key] + if not isinstance(raw, str): + raise ManifestError(f"{label} must be text") + value = raw.strip() + if not value: + return None + if len(value) > limit: + raise ManifestError(f"{label} is longer than {limit} characters") + if any(char < " " or char == "\x7f" for char in value): + raise ManifestError(f"{label} may not contain control characters") + if pattern is not None and not pattern.fullmatch(value): + raise ManifestError(f"{label} is not in the accepted form: {value!r}") + return value + + +def _unknown_keys(values: Iterable[str], allowed: Iterable[str], label: str) -> None: + extra = sorted(set(values) - set(allowed)) + if extra: + raise ManifestError(f"{label} has unknown key(s): {', '.join(extra)}") + + +def _path_parts(raw: str) -> list[str]: + return raw.replace("\\", "/").split("/") + + +def _relative_root(raw: str | None) -> str: + """The manifest may point at a subdirectory of itself, never outside it. + + A manifest that could name an absolute path, or climb with ``..``, would + let a cloned repository redirect a launch into an unrelated directory. + """ + if raw is None: + return "." + if raw.startswith("/") or raw.startswith("~"): + raise ManifestError("root must be relative to the manifest, not absolute") + parts = [part for part in _path_parts(raw) if part not in ("", ".")] + if any(part == ".." for part in parts): + raise ManifestError("root may not climb above the manifest with ..") + return "/".join(parts) or "." + + +def _team_role(values: Mapping[str, Any], index: int) -> dict[str, Any]: + if not isinstance(values, Mapping): + raise ManifestError(f"team role {index} must be a table") + _unknown_keys(values.keys(), TEAM_KEYS, f"team role {index}") + role = _string( + values, "role", pattern=ROLE_RE, limit=32, label=f"team role {index} role" + ) + if not role: + raise ManifestError(f"team role {index} needs a role name") + provider = _string( + values, "provider", limit=16, label=f"team role {index} provider" + ) + if provider is not None and provider not in ("claude", "codex"): + raise ManifestError(f"team role {index} provider must be claude or codex") + return { + "role": role, + "provider": provider, + "account": _string( + values, + "account", + pattern=ACCOUNT_RE, + limit=12, + label=f"team role {index} account", + ), + "model": _string( + values, + "model", + pattern=MODEL_RE, + limit=128, + label=f"team role {index} model", + ), + "branch": _string( + values, + "branch", + pattern=BRANCH_RE, + limit=128, + label=f"team role {index} branch", + ), + "expertise": _string( + values, "expertise", limit=64, label=f"team role {index} expertise" + ), + "workstream": _string( + values, "workstream", limit=500, label=f"team role {index} workstream" + ), + "scope": _string(values, "scope", limit=2000, label=f"team role {index} scope"), + "rationale": _string( + values, "rationale", limit=1000, label=f"team role {index} rationale" + ), + } + + +def validate(values: Mapping[str, Any]) -> dict[str, Any]: + """The exact stored shape of a manifest, or a refusal naming the field.""" + if not isinstance(values, Mapping): + raise ManifestError("a manifest must be a table") + _unknown_keys( + (key for key in values if key != "team"), TOP_LEVEL_KEYS, MANIFEST_NAME + ) + raw_team = values.get("team", []) + if not isinstance(raw_team, list): + raise ManifestError("team roles are declared with [[team]] sections") + provider = _string(values, "provider", limit=16, label="provider") + if provider is not None and provider not in PROVIDERS: + raise ManifestError("provider must be claude, codex, or shell") + roles = [_team_role(role, index) for index, role in enumerate(raw_team, 1)] + names = [role["role"] for role in roles] + if len(set(names)) != len(names): + raise ManifestError("two team roles share one role name") + return { + "name": _string(values, "name", pattern=NAME_RE, limit=48, label="name"), + "root": _relative_root(_string(values, "root", limit=512, label="root")), + "description": _string(values, "description", limit=200, label="description"), + "provider": provider, + "account": _string( + values, "account", pattern=ACCOUNT_RE, limit=12, label="account" + ), + "model": _string(values, "model", pattern=MODEL_RE, limit=128, label="model"), + "startup": _string(values, "startup", limit=MAX_TEXT, label="startup"), + "team": roles, + } + + +def loads(text: str) -> dict[str, Any]: + """Parse and validate manifest text.""" + return validate(parse_subset(text)) + + +def read(path: Path) -> dict[str, Any]: + """Read one manifest file. + + The file is repository content, so its mode and owner are whatever the + checkout has; what is bounded here is size and shape. A symlink is + refused because a manifest that points elsewhere is not the committed + file a reviewer of that repository saw. + """ + try: + if path.is_symlink(): + raise ManifestError(f"{path} is a symlink, not a committed manifest") + info = path.stat() + except OSError as error: + raise ManifestError(f"{path} is unreadable: {error.strerror}") from error + if info.st_size > MAX_MANIFEST_BYTES: + raise ManifestError(f"{path} is larger than {MAX_MANIFEST_BYTES} bytes") + try: + payload = path.read_bytes() + except OSError as error: + raise ManifestError(f"{path} is unreadable: {error.strerror}") from error + try: + text = payload.decode("utf-8", "strict") + except UnicodeDecodeError as error: + raise ManifestError(f"{path} is not valid UTF-8") from error + if "\x00" in text: + raise ManifestError(f"{path} contains a null byte") + return loads(text) diff --git a/lib/sessionkit_tui/__init__.py b/lib/sessionkit_tui/__init__.py new file mode 100644 index 0000000..2e9ca0d --- /dev/null +++ b/lib/sessionkit_tui/__init__.py @@ -0,0 +1,18 @@ +"""The picker's screen layer. + +A pure core and a thin curses shell. `model` reads the session snapshot the +rest of the kit already produces, `rows` projects it into what a person sees, +`frame` turns that into painted lines, `state` answers keys and clicks, and +`actions` names the `sp` command every choice runs. Only `app` touches curses, +and only `runner` starts a process, so every rule this picker follows is +testable without a terminal. +""" + +__all__ = [ + "actions", + "frame", + "model", + "rows", + "state", + "voice", +] diff --git a/lib/sessionkit_tui/actions.py b/lib/sessionkit_tui/actions.py new file mode 100644 index 0000000..3663ec3 --- /dev/null +++ b/lib/sessionkit_tui/actions.py @@ -0,0 +1,161 @@ +"""What every choice on the screen actually runs. + +The screen never touches a session itself. It names an `sp` command, the same +proof-bound commands the picker has always used, and hands it to the runner. +Planning is separated from running so the exact command line each choice +produces is testable without starting anything. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Mapping + +from . import voice +from .model import ClosedSession, Session + +# The placeholder the runner replaces with a freshly written proof file. A +# proof names the exact session, so an action can never land on the row that +# took its place during a repaint. +PROOF = "{proof}" + +# Claude Code's palette then the Codex-only one, in palette order. +CLAUDE_COLORS = ("red", "blue", "green", "yellow", "purple", "orange", "pink", "cyan") +CODEX_COLORS = ("lime", "magenta", "silver", "sand", "sky", "sea") + + +@dataclass(frozen=True) +class Plan: + """One command, and the sentence the screen prints once it has run.""" + + argv: tuple[str, ...] + success: str + failure: str + needs_terminal: bool = False + proof_for: str | None = None + confirm_id: str | None = None + env: Mapping[str, str] = field(default_factory=dict) + # A verb the release may not carry yet. Its own refusal is the honest + # answer, so the screen shows that line rather than inventing one. + quote_stderr: bool = False + + @property + def wants_proof(self) -> bool: + return PROOF in self.argv + + +def _selector(session: Session) -> str: + """Numbers are what a person sees; the proof is what names the session. + + Where a verb takes no proof, the number is the only selector the screen is + allowed to pass on: an ID would have to be printed to be typed, and no + screen prints one. + """ + + return str(session.number) if session.number is not None else (session.shpool_id or "") + + +def open_session(session: Session) -> Plan: + verb = "picker-takeover" if session.is_attached else "picker-open" + return Plan( + argv=("sp", verb, PROOF), + success=voice.opened(session.title), + failure=voice.action_failed(voice.ACTION_OPEN), + needs_terminal=True, + proof_for=session.key, + confirm_id=session.shpool_id, + ) + + +def history(session: Session) -> Plan: + return Plan( + argv=("sp", "picker-history", PROOF), + success="", + failure=voice.action_failed(voice.ACTION_HISTORY), + needs_terminal=True, + proof_for=session.key, + confirm_id=session.shpool_id, + ) + + +def close(session: Session) -> Plan: + return Plan( + argv=("sp", "picker-close", PROOF), + success=voice.closed_one(session.title), + failure=voice.action_failed(voice.ACTION_CLOSE), + proof_for=session.key, + confirm_id=session.shpool_id, + ) + + +def rename(session: Session, title: str) -> Plan: + return Plan( + argv=("sp", "picker-name", PROOF, title), + success=voice.renamed(title), + failure=voice.action_failed(voice.ACTION_RENAME), + proof_for=session.key, + confirm_id=session.shpool_id, + ) + + +def change_account(session: Session, alias: str) -> Plan: + return Plan( + argv=("sp", "picker-account-switch", PROOF, alias), + success=voice.account_changed(session.title, alias), + failure=voice.action_failed(voice.ACTION_ACCOUNT), + needs_terminal=True, + proof_for=session.key, + confirm_id=session.shpool_id, + ) + + +def change_model(session: Session, model: str) -> Plan: + """`sp change-model` is the verb this screen calls. + + A release without it refuses in its own words, and those are the words the + screen shows, inventing a friendlier line would hide which release is + installed. + """ + + return Plan( + argv=("sp", "change-model", _selector(session), model), + success=voice.model_changed(session.title, model), + failure=voice.action_failed(voice.ACTION_MODEL), + quote_stderr=True, + ) + + +def color(session: Session, name: str) -> Plan: + return Plan( + argv=("sp", "color", _selector(session), name), + success=voice.color_changed(session.title, name), + failure=voice.action_failed(voice.ACTION_COLOR), + ) + + +def restore(item: ClosedSession) -> Plan: + argv = ["sp", "restore-exact", item.provider, item.uuid, item.cwd or "."] + if item.account_alias: + argv.append(item.account_alias) + return Plan( + argv=tuple(argv), + success=voice.restored(item.title), + failure=voice.action_failed(voice.ACTION_RESTORE), + needs_terminal=True, + ) + + +def new_session(provider: str, project: str | None = None) -> Plan: + argv = ["sp", "new", provider] + if project: + argv.append(project) + return Plan( + argv=tuple(argv), + success="", + failure=voice.action_failed(voice.NEW_SESSION), + needs_terminal=True, + ) + + +def colors_for(provider: str) -> tuple[str, ...]: + return CODEX_COLORS if provider.casefold() == "codex" else CLAUDE_COLORS diff --git a/lib/sessionkit_tui/app.py b/lib/sessionkit_tui/app.py new file mode 100644 index 0000000..ace2039 --- /dev/null +++ b/lib/sessionkit_tui/app.py @@ -0,0 +1,466 @@ +"""The curses shell. + +It paints frames and turns terminal events into the events `state` answers. +Every rule about what a person sees or what a key means lives elsewhere; this +file is the part that cannot be tested without a terminal, so it is kept as +close to nothing as it can be. +""" + +from __future__ import annotations + +import curses +import os +from pathlib import Path +import queue +import sys +import threading +from typing import Mapping + +from sessionkit_inventory.common import CollectionError + +from . import frame as framemod, voice +from .frame import Frame +from .model import Inventory +from .runner import Runner, run_batch +from .state import Picker + +# How often the list catches up with the estate, and how long a keystroke +# waits before the loop looks around. Both are milliseconds. +REFRESH_SECONDS = 5 +INPUT_TIMEOUT_MS = 200 + +# Leaving on purpose is 0. Every way the picker cannot run is 1, with the +# reason on stderr. 2 belongs to arguments and selectors, which this program +# does not take. +EXIT_LEAVE = 0 +EXIT_CANNOT_RUN = 1 + +CTRL_D = 4 +QUIT_KEY = ord("q") +# `b` is back wherever it is a key rather than a letter being typed; the picker +# itself decides which screen that is (`Picker.back_key`). +BACK_KEY = ord("b") + +# How long ncurses waits after a bare Esc before deciding it was not the start +# of an arrow key. The default is a full second, which makes the one key that +# clears the input and leaves the screen feel broken. +ESCAPE_DELAY_MS = 25 + +SESSION_PALETTE = { + "red": (237, 93, 93), + "blue": (97, 166, 240), + "green": (63, 221, 115), + "yellow": (249, 215, 108), + "purple": (173, 115, 239), + "orange": (242, 144, 81), + "pink": (240, 113, 177), + "cyan": (64, 216, 209), + "lime": (170, 230, 70), + "magenta": (255, 95, 255), + "silver": (205, 210, 220), + "sand": (214, 178, 130), + "sky": (150, 205, 255), + "sea": (95, 235, 170), +} + +PALETTE_INDICES = {name: 16 + index for index, name in enumerate(SESSION_PALETTE)} + +FALLBACK_16 = { + "red": 8, + "blue": 12, + "green": 6, + "yellow": 11, + "purple": 12, + "orange": 3, + "pink": 5, + "cyan": 6, + "lime": 3, + "magenta": 13, + "silver": 7, + "sand": 8, + "sky": 7, + "sea": 6, +} + +PROVIDER_PALETTE = { + "claude": "yellow", + "codex": "cyan", + "shell": "green", + "unknown": "yellow", +} + + +class Poller: + """Collects snapshots off the keyboard's path. + + A snapshot costs over a second. Collected in the loop it would freeze the + screen every five seconds; collected here the list simply becomes newer + between keystrokes. + """ + + def __init__(self, runner: Runner, seconds: int = REFRESH_SECONDS) -> None: + self.runner = runner + self.seconds = seconds + self.results: queue.Queue = queue.Queue() + self.stopped = threading.Event() + self.thread = threading.Thread(target=self._loop, daemon=True) + + def start(self) -> None: + self.thread.start() + + def stop(self) -> None: + self.stopped.set() + + def _loop(self) -> None: + while not self.stopped.is_set(): + inventory = self.runner.snapshot() + if inventory is not None: + self.results.put(inventory) + self.stopped.wait(self.seconds) + + def drain(self) -> Inventory | None: + newest = None + while True: + try: + newest = self.results.get_nowait() + except queue.Empty: + return newest + + +def color_wanted(environ) -> bool: + """Colour unless a person has actually asked for none. + + `NO_COLOR` counts as set whatever its value, which is the convention the + rest of the kit already follows; an unset variable is not a preference. + """ + + return "NO_COLOR" not in environ and "SESSION_KIT_NO_COLOR" not in environ + + +def _style(name: str, colors: Mapping[str, int] | None = None) -> int: + """One place deciding what a named run looks like. + + Without colour the same runs stay distinguishable by weight, so a screen + on a colourless terminal is still readable rather than merely uniform. + """ + + colors = colors or {} + pair = curses.color_pair(colors.get(name, 0)) if colors.get(name) else 0 + if name == framemod.BOLD: + return curses.A_BOLD | pair + if name == framemod.DIM: + return curses.A_DIM + if name == framemod.ATTENTION: + return curses.A_BOLD | pair + if name == framemod.MARK: + return curses.A_BOLD | pair + if name == framemod.NUMBER: + return pair or curses.A_BOLD + if name == framemod.KEY: + return curses.A_BOLD | pair + if name.startswith(framemod.PROVIDER_STYLE_PREFIX): + return curses.A_BOLD | pair + if name.startswith(framemod.TITLE_STYLE_PREFIX): + return pair + return pair or curses.A_NORMAL + + +def paint(window, drawn: Frame, *, colors: Mapping[str, int] | None = None) -> None: + """One frame, one update. Nothing is drawn twice, so nothing flickers.""" + + height, width = window.getmaxyx() + window.erase() + for index, line in enumerate(drawn.lines): + if index >= height: + break + text = line.text[:width] + if text: + try: + # insstr, not addstr: writing the last cell of the last line + # advances the cursor off the screen and curses calls that an + # error, which would drop the row entirely. + window.insstr(index, 0, text) + except curses.error: + continue + # The highlight is painted first and the runs on top of it, so a + # highlighted row keeps its colours instead of going flat. + base = curses.A_REVERSE if drawn.cursor_line == index else 0 + if base: + try: + window.chgat(index, 0, width, base) + except curses.error: + pass + for span in line.spans: + if span.start >= len(text): + continue + length = min(span.length, len(text) - span.start) + if length <= 0: + continue + try: + window.chgat(index, span.start, length, _style(span.style, colors) | base) + except curses.error: + continue + window.noutrefresh() + curses.doupdate() + + +class Application: + def __init__(self, runner: Runner, picker: Picker) -> None: + self.runner = runner + self.picker = picker + self.poller = Poller(runner) + self.frame: Frame | None = None + self.colors: dict[str, int] = {} + self.background = curses.COLOR_BLACK + self.custom_colors = False + + def run(self, window) -> int: + curses.curs_set(0) + window.keypad(True) + window.timeout(INPUT_TIMEOUT_MS) + try: + curses.set_escdelay(ESCAPE_DELAY_MS) + except (AttributeError, curses.error): + pass + self.colors = self._start_color() + try: + curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) + except curses.error: + pass + self.poller.start() + try: + return self._loop(window) + finally: + self.poller.stop() + + def _install_palette(self) -> None: + if not self.custom_colors: + return + for name, (red, green, blue) in SESSION_PALETTE.items(): + curses.init_color( + PALETTE_INDICES[name], + (red * 1000 + 254) // 255, + (green * 1000 + 254) // 255, + (blue * 1000 + 254) // 255, + ) + + def _start_color(self) -> dict[str, int]: + """Colour, if this terminal has it and nobody asked for none. + + `use_default_colors` runs either way. Without it the default pair is + white-on-black, and ncurses then paints every cell with an explicit + colour, the exact opposite of what NO_COLOR asks for. + """ + + try: + curses.start_color() + except curses.error: + return {} + self.background = curses.COLOR_BLACK + try: + curses.use_default_colors() + self.background = -1 + except curses.error: + pass + if not color_wanted(self.runner.environ): + return {} + if not curses.has_colors(): + return {} + try: + self.custom_colors = bool( + curses.COLORS >= 256 and curses.can_change_color() + ) + self._install_palette() + pairs: dict[str, int] = {} + for offset, name in enumerate(SESSION_PALETTE, start=10): + foreground = ( + PALETTE_INDICES[name] + if self.custom_colors + else FALLBACK_16[name] + ) + if curses.COLORS < 16: + foreground %= 8 + curses.init_pair(offset, foreground, self.background) + pairs[f"{framemod.TITLE_STYLE_PREFIX}{name}"] = offset + pairs[f"palette:{name}"] = offset + pairs[framemod.BOLD] = pairs["palette:cyan"] + pairs[framemod.ATTENTION] = pairs["palette:yellow"] + pairs[framemod.MARK] = pairs["palette:green"] + pairs[framemod.NUMBER] = pairs["palette:green"] + pairs[framemod.KEY] = pairs["palette:green"] + for provider, palette in PROVIDER_PALETTE.items(): + pairs[f"{framemod.PROVIDER_STYLE_PREFIX}{provider}"] = pairs[ + f"palette:{palette}" + ] + except curses.error: + return {} + return pairs + + def _draw(self, window) -> None: + height, width = window.getmaxyx() + self.frame = self.picker.frame(width=width, height=height) + paint(window, self.frame, colors=self.colors) + + def _loop(self, window) -> int: + self._draw(window) + while True: + fresh = self.poller.drain() + if fresh is not None: + self.picker.set_inventory(fresh) + self.picker.set_closed(self.runner.closed_sessions()) + self._draw(window) + try: + key = window.getch() + except KeyboardInterrupt: + self._draw(window) + continue + if key == -1: + continue + batch = self._handle(window, key) + if self.picker.leaving: + return EXIT_LEAVE + if batch: + self._act(window, batch) + if self.picker.leaving: + return EXIT_LEAVE + self._draw(window) + + def _handle(self, window, key: int): + picker = self.picker + if key == curses.KEY_RESIZE: + return None + if key == curses.KEY_UP: + picker.move(-1) + return None + if key == curses.KEY_DOWN: + picker.move(1) + return None + if key == curses.KEY_PPAGE: + picker.move(-max(1, picker.body_height - 1)) + return None + if key == curses.KEY_NPAGE: + picker.move(max(1, picker.body_height - 1)) + return None + if key == curses.KEY_HOME: + picker.move(-10**6) + return None + if key == curses.KEY_END: + picker.move(10**6) + return None + if key in (curses.KEY_ENTER, 10, 13): + return picker.enter() + if key == 27: + picker.escape() + return None + if key in (curses.KEY_BACKSPACE, 127, 8): + picker.backspace() + return None + if key == CTRL_D: + picker.leave() + return None + if key == QUIT_KEY and picker.quit_key(): + return None + if key == BACK_KEY and picker.back_key(): + return None + if key == curses.KEY_MOUSE: + return self._mouse(picker) + if 32 <= key < 127: + picker.type_text(chr(key)) + return None + return None + + def _mouse(self, picker: Picker): + try: + _, column, line, _, state = curses.getmouse() + except curses.error: + return None + if state & getattr(curses, "BUTTON4_PRESSED", 0): + picker.wheel(-3) + return None + if state & getattr(curses, "BUTTON5_PRESSED", 0): + picker.wheel(3) + return None + if not state & curses.BUTTON1_CLICKED and not state & curses.BUTTON1_PRESSED: + return None + if self.frame is None: + return None + if self._footer_click(line, column): + return picker.enter() + return picker.click(line, column, self.frame) + + def _footer_click(self, line: int, column: int) -> bool: + """The footer is not decoration: its middle hint is the Enter key.""" + + if self.frame is None or line != len(self.frame.lines) - 1: + return False + text = self.frame.lines[line].text + start = text.find("↵") + if start < 0: + return False + return start <= column <= start + len("↵ open") + + def _act(self, window, batch) -> None: + def suspend(): + curses.def_prog_mode() + curses.endwin() + + def resume(): + window.clear() + curses.reset_prog_mode() + self._install_palette() + window.refresh() + + ok, note = run_batch( + self.runner, + self.picker.inventory, + batch.plans, + suspend=suspend, + resume=resume, + ) + self.picker.action_finished(batch, ok=ok, note=note) + fresh = self.runner.snapshot() + if fresh is not None: + self.picker.set_inventory(fresh) + self.picker.set_closed(self.runner.closed_sessions()) + + +def build(script_dir: Path, environ=None) -> tuple[Runner, Picker]: + environ = dict(os.environ if environ is None else environ) + runner = Runner.from_environment(script_dir, environ) + inventory = runner.snapshot() + picker = Picker( + inventory=inventory if inventory is not None else Inventory(source="cache", stale=True), + closed=runner.closed_sessions(), + projects=runner.projects(), + account_source=runner.accounts, + model_source=runner.models, + self_session_id=environ.get("SHPOOL_SESSION_NAME") or None, + ) + return runner, picker + + +def main() -> int: + script_dir = Path(__file__).resolve().parents[2] / "bin" + environ = os.environ + if not sys.stdin.isatty() or not sys.stdout.isatty(): + print(voice.error("the picker needs a terminal"), file=sys.stderr) + return EXIT_CANNOT_RUN + if environ.get("SESSION_KIT_NONINTERACTIVE") == "1": + print(voice.error("the picker needs a terminal"), file=sys.stderr) + return EXIT_CANNOT_RUN + try: + runner, picker = build(script_dir, environ) + if not Path(runner.sp_cmd).exists() or not Path(runner.status_cmd).exists(): + print( + voice.error("the picker's helpers are missing from this release"), + file=sys.stderr, + ) + return EXIT_CANNOT_RUN + application = Application(runner, picker) + return curses.wrapper(application.run) + except (curses.error, CollectionError) as failure: + print(voice.error(f"the screen could not be drawn: {failure}"), file=sys.stderr) + return EXIT_CANNOT_RUN + + +__all__ = ["Application", "Poller", "build", "main", "paint"] diff --git a/lib/sessionkit_tui/frame.py b/lib/sessionkit_tui/frame.py new file mode 100644 index 0000000..3877a63 --- /dev/null +++ b/lib/sessionkit_tui/frame.py @@ -0,0 +1,683 @@ +"""Turning what is true into the lines that get painted. + +A frame is data: numbered lines, the styled runs inside them, and which line +the highlight is on. The curses shell paints frames and nothing else, so every +question about what a person sees is answered here, in a test, without a +terminal. + +Columns are decided here too. Every session row puts its provider at the same +column, whatever its title is worth, because a title long enough to push its +own columns right turns a list into a ragged wall. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Sequence +import unicodedata + +from . import voice +from .rows import Row + +BOLD = "bold" +DIM = "dim" +ATTENTION = "attention" +MARK = "mark" +NUMBER = "number" +PROVIDER = "provider" +TITLE = "title" +KEY = "key" + +TICK = "✓" +ELLIPSIS = "…" + +TITLE_LINE = "Session Kit" + +# The fixed left edge of every session row: two columns of mark, three of +# number, two of gap. +MARK_WIDTH = 2 +NUMBER_WIDTH = 3 +GAP = 2 +ROW_PREFIX = MARK_WIDTH + NUMBER_WIDTH + GAP + +# A title never gets less than this many columns, whatever the details beside +# it want. There used to be a second floor here -- a third of the room, +# whatever the details needed -- and it is gone: it let the title push the +# state and the time off the end of the line. See `label_width`. +MIN_LABEL = 12 +RESPONSIVE_MIN_LABEL = 4 +PLACEHOLDER_CELL_FLOOR = 3 + +DETAIL_SEPARATOR = " | " +TITLE_STYLE_PREFIX = "title:" +PROVIDER_STYLE_PREFIX = "provider:" + + +@dataclass(frozen=True) +class Span: + start: int + length: int + style: str + + +@dataclass(frozen=True) +class Line: + text: str + spans: tuple[Span, ...] = () + row_key: str | None = None + + +@dataclass(frozen=True) +class PanelItem: + key: str + label: str + detail: str = "" + style: str = "" + + +@dataclass(frozen=True) +class Panel: + """The list of things that can happen to what is marked. + + It opens with the choice a person came for already highlighted, and Enter + takes it. Nothing here asks a second time. + """ + + title: str + items: tuple[PanelItem, ...] + index: int = 0 + prompt: str | None = None + entry: str = "" + identity_color: str = "" + subtitle: str = "" + subtitle_style: str = DIM + subtitle_provider: str = "" + + def moved(self, delta: int) -> "Panel": + if not self.items: + return self + index = max(0, min(len(self.items) - 1, self.index + delta)) + return Panel( + self.title, self.items, index, self.prompt, self.entry, + self.identity_color, self.subtitle, self.subtitle_style, + self.subtitle_provider, + ) + + @property + def current(self) -> PanelItem | None: + if not self.items or not 0 <= self.index < len(self.items): + return None + return self.items[self.index] + + +@dataclass(frozen=True) +class Screen: + """Everything the frame builder needs, and nothing it does not.""" + + rows: tuple[Row, ...] = () + cursor_index: int = 0 + input_text: str = "" + input_is_filter: bool = False + status: str = "" + stale: bool = False + heading: str = "" + panel: Panel | None = None + help_open: bool = False + empty_note: str = "" + session_total: int = 0 + ready_total: int = 0 + elsewhere_total: int = 0 + view_kind: str = "list" + + +@dataclass(frozen=True) +class Frame: + lines: tuple[Line, ...] = () + cursor_line: int | None = None + top: int = 0 + row_lines: dict[int, str] = field(default_factory=dict, repr=False) + + def text(self) -> tuple[str, ...]: + return tuple(line.text for line in self.lines) + + def joined(self) -> str: + return "\n".join(line.text for line in self.lines) + + +def _cells(text: str) -> int: + """How many terminal columns this text occupies. + + Everything here is measured in columns, not characters, for one reason: + `curses.chgat` positions a styled run by COLUMN. A row measured in + characters puts both its columns and its colours in the wrong place the + moment a name contains a wide character, and a session name is whatever + the person typed. One CJK character is two columns and one `len()`. + """ + return sum( + 0 + if unicodedata.combining(character) + else 2 + if unicodedata.east_asian_width(character) in {"W", "F"} + else 1 + for character in text + ) + + +def _pad(text: str, width: int) -> str: + """`str.ljust`, in columns. See `_cells`.""" + return text + " " * max(0, width - _cells(text)) + + +def _fit(text: str, width: int) -> str: + if width <= 0: + return "" + if _cells(text) <= width: + return text + if width == 1: + return ELLIPSIS + kept: list[str] = [] + used = 0 + for character in text: + size = _cells(character) + if used + size > width - 1: + break + kept.append(character) + used += size + return "".join(kept) + ELLIPSIS + + +def _placeholder_floor_width(value: str) -> int | None: + if value == voice.MISSING: + return PLACEHOLDER_CELL_FLOOR + return None + + +def _real_value_width(values: Sequence[str], *, default: int = 1) -> int: + """Let real cells earn width; placeholders merely occupy what they earn.""" + + real: list[int] = [] + placeholder_floors: list[int] = [] + for value in values: + floor = _placeholder_floor_width(value) + if floor is None: + real.append(_cells(value)) + else: + placeholder_floors.append(floor) + if real: + return max(real) + if placeholder_floors: + return max(placeholder_floors) + return default + + +@dataclass(frozen=True) +class DetailWidths: + provider: int = 1 + account: int = 1 + model: int = 1 + state: int = 1 + subagents: int = 0 + age: int = 1 + + @property + def fixed(self) -> int: + fields = self.provider + self.account + self.model + self.state + self.age + if self.subagents: + fields += self.subagents + len(DETAIL_SEPARATOR) + return fields + 15 + + +def detail_widths(rows: Sequence[Row], *, compact_age: bool = False) -> DetailWidths: + sessions = [row for row in rows if row.kind == "session"] + return DetailWidths( + max((_cells(row.provider) for row in sessions), default=1), + _real_value_width([row.account for row in sessions]), + _real_value_width([row.model for row in sessions]), + max((_cells(row.state) for row in sessions), default=1), + max((_cells(row.subagents) for row in sessions), default=0), + max( + (_cells(row.compact_age if compact_age else row.age) for row in sessions), + default=1, + ), + ) + + +def _responsive_session_layout( + rows: Sequence[Row], width: int +) -> tuple[int, DetailWidths, bool, bool]: + """Return title/detail widths, compact-time use, and status-only fallback.""" + + sessions = [row for row in rows if row.kind == "session"] + full = detail_widths(rows) + compact = detail_widths(rows, compact_age=True) + longest = max((_cells(row.label) for row in sessions), default=MIN_LABEL) + + def squeeze(details: DetailWidths) -> DetailWidths: + account, model = details.account, details.model + excess = ROW_PREFIX + MIN_LABEL + len(DETAIL_SEPARATOR) + details.fixed - width + take = min(max(0, excess), max(0, model - 1)) + model -= take + excess -= take + take = min(max(0, excess), max(0, account - 1)) + account -= take + return DetailWidths( + details.provider, + account, + model, + details.state, + details.subagents, + details.age, + ) + + # Use the long age only when it fits without taking cells back from a title + # or another metadata field. Preferring it as soon as it technically fit + # made a one-column resize shorten every title on the page at once. + full_available = width - ROW_PREFIX - len(DETAIL_SEPARATOR) - full.fixed + if full_available >= longest: + return longest, full, False, False + + fitted = squeeze(compact) + available = width - ROW_PREFIX - len(DETAIL_SEPARATOR) - fitted.fixed + responsive_floor = RESPONSIVE_MIN_LABEL if width >= 60 else MIN_LABEL + if available >= responsive_floor: + return min(longest, available), fitted, True, False + + # Below the full-column floor, keep the row's purpose in one detail cell: + # state, nonzero subagent count, then time. At 50 columns the time may be + # the one token omitted; waiting-on-you still comes first. + return MIN_LABEL, compact, True, True + + +def label_width(rows: Sequence[Row], width: int) -> int: + """The one title column every session row shares. + + Wide enough for the longest title that fits, and no wider: one very long + title used to take the whole line and leave every other row's provider, + model, and state cut off. The details get the room they ask for and the + column is the same for every row either way. + + The "a third of the room" floor that used to sit in this ceiling is gone. + It let the title claim a third of the line whatever the details needed, so + on a narrow window the row was assembled wider than the terminal and the + frame then hard-cut the tail, which is the state and the time, the two + facts the row is for. A title that cannot fit beside them is truncated; + they are not. + """ + + room = width - ROW_PREFIX + if room <= MIN_LABEL: + return max(1, room) + longest_label = max((_cells(row.label) for row in rows if row.detail), default=0) + details = detail_widths(rows) + ceiling = max(MIN_LABEL, room - details.fixed) + return max(MIN_LABEL, min(ceiling, longest_label)) + + +def _word_fit(text: str, width: int) -> str: + if _cells(text) <= width: + return text + if width <= 1: + return ELLIPSIS if width == 1 else "" + cut = _fit(text, width).removesuffix(ELLIPSIS).rstrip() + whole, separator, _ = cut.rpartition(" ") + return (whole if separator and whole else cut) + ELLIPSIS + + +def _row_text( + row: Row, column: int, details: DetailWidths, width: int, + *, compact_age: bool = False, status_only: bool = False, +) -> tuple[str, tuple[Span, ...]]: + tick = f"{TICK} " if row.marked else " " + number = f"{row.number:>{NUMBER_WIDTH}}" if row.number is not None else " " * NUMBER_WIDTH + spans = [] + if row.marked: + spans.append(Span(0, 1, MARK)) + if row.number is not None: + spans.append(Span(MARK_WIDTH, NUMBER_WIDTH, NUMBER)) + + if not row.detail: + text = f"{tick}{number}{' ' * GAP}{_fit(row.label, max(1, width - ROW_PREFIX))}" + spans.append(Span(ROW_PREFIX, max(0, _cells(text) - ROW_PREFIX), DIM)) + return text, tuple(spans) + + # The title is cut to the shared column rather than allowed to push the + # detail right: one ragged row makes the whole list unreadable. + fitted_label = _fit(row.label, column) + label = _pad(fitted_label, column) + title_style = TITLE_STYLE_PREFIX + (row.session.display_color if row.session else "") + spans.append(Span(ROW_PREFIX, _cells(fitted_label), title_style)) + detail_start = ROW_PREFIX + column + len(DETAIL_SEPARATOR) + age = row.compact_age if compact_age else row.age + if status_only: + room = max(1, width - ROW_PREFIX - column - len(DETAIL_SEPARATOR)) + parts = [row.state] + if row.subagents: + parts.append(row.subagents) + with_age = DETAIL_SEPARATOR.join((*parts, age)) if age else DETAIL_SEPARATOR.join(parts) + without_age = DETAIL_SEPARATOR.join(parts) + detail = with_age if _cells(with_age) <= room else without_age + text = f"{tick}{number}{' ' * GAP}{label}{DETAIL_SEPARATOR}{_fit(detail, room)}" + rest_start = detail_start + spans.append( + Span( + rest_start, + max(0, min(_cells(text), width) - rest_start), + ATTENTION if row.needs_you else DIM, + ) + ) + return text, tuple(spans) + + fixed_parts = [ + _pad(row.provider, details.provider), + _pad(_fit(row.account, details.account), details.account), + _pad(_fit(row.model, details.model), details.model), + _pad(row.state, details.state), + ] + if details.subagents: + fixed_parts.append(_pad(row.subagents, details.subagents)) + fixed_parts.append(_pad(age, details.age)) + fixed = DETAIL_SEPARATOR.join( + fixed_parts + ) + text = f"{tick}{number}{' ' * GAP}{label}{DETAIL_SEPARATOR}{fixed}" + provider = row.session.provider.casefold() if row.session else "unknown" + provider_style = PROVIDER_STYLE_PREFIX + ( + provider if provider in {"claude", "codex", "shell"} else "unknown" + ) + spans.append(Span(detail_start, _cells(row.provider), provider_style)) + rest_start = detail_start + details.provider + spans.append( + Span( + rest_start, + max(0, min(_cells(text), width) - rest_start), + ATTENTION if row.needs_you else DIM, + ) + ) + return text, tuple(spans) + + +def _key_spans(text: str, markers: Sequence[tuple[str, str]], *, offset: int = 0) -> list[Span]: + spans = [] + for phrase, token in markers: + phrase_start = text.find(phrase) + start = phrase_start + phrase.rfind(token) if phrase_start >= 0 else -1 + if start >= 0: + spans.append(Span(offset + start, len(token), KEY)) + return spans + + +def _footer_line(width: int, footer: str = voice.FOOTER) -> Line: + """The footer, with every key in it findable by eye.""" + + text = _fit(f" {footer}", width) + spans = [Span(2, max(0, len(text) - 2), DIM)] + markers: tuple[tuple[str, str], ...] = ( + ("↵ open", "↵"), + ("↵ choose", "↵"), + ("↵ actions", "↵"), + ("↵ back", "↵"), + ("↵ save", "↵"), + ("open #", "#"), + ("kill k #", "k #"), + ("history h #", "h #"), + ("new n", "n"), + ("more m", "m"), + ("help ?", "?"), + ("b back", "b"), + ("esc quit", "esc"), + ("esc back", "esc"), + ("ctrl-d quit", "ctrl-d"), + ) + spans.extend(_key_spans(text, markers)) + return Line(text, tuple(spans)) + + +def _panel_lines(panel: Panel, width: int) -> tuple[list[Line], int]: + lines: list[Line] = [] + if panel.subtitle: + text = _fit(f" {panel.subtitle}", width) + spans = [Span(2, len(panel.subtitle), panel.subtitle_style)] + if panel.subtitle_provider: + start = text.find("[") + end = text.find("]", start + 1) + if start >= 0 and end > start + 1: + spans.append( + Span( + start + 1, + end - start - 1, + PROVIDER_STYLE_PREFIX + panel.subtitle_provider.casefold(), + ) + ) + lines.append(Line(text, tuple(spans))) + cursor_line = len(lines) + if panel.prompt is not None: + prompt = panel.prompt + text = _fit(f" {prompt} {panel.entry}", width) + spans = (Span(2, len(prompt), KEY),) + lines.append(Line(text, spans)) + return lines, cursor_line + label_width = max((_cells(item.label) for item in panel.items), default=1) + for index, item in enumerate(panel.items): + number = f"{index + 1:>2}" + text = f" {number} {item.label}" + spans: list[Span] = [Span(2, len(number), NUMBER)] + if item.style: + spans.append(Span(6, _cells(item.label), item.style)) + if item.detail: + start = 6 + label_width + len(DETAIL_SEPARATOR) + text = ( + f" {number} {_pad(item.label, label_width)}" + f"{DETAIL_SEPARATOR}{item.detail}" + ) + spans.append(Span(start, _cells(item.detail), DIM)) + if index == panel.index: + cursor_line = len(lines) + lines.append(Line(_fit(text, width), tuple(spans), row_key=f"panel:{item.key}")) + return lines, cursor_line + + +def _help_lines(width: int) -> list[Line]: + lines: list[Line] = [] + previous = "" + for section, key, meaning in voice.HELP_ROWS: + if section != previous: + if lines: + lines.append(Line("")) + lines.append(Line(f" {section}", (Span(2, len(section), BOLD),))) + previous = section + key_text = key.ljust(13) + text = _fit(f" {key_text} {meaning}", width) + lines.append(Line(text, (Span(2, len(key), KEY), Span(16, max(0, len(text) - 16), DIM)))) + lines.append(Line("")) + lines.extend(Line(_fit(f" {note}", width), (Span(2, min(len(note), max(0, width - 2)), DIM),)) for note in voice.HELP_NOTES) + return lines + + +def closed_widths(rows: Sequence[Row]) -> tuple[int, int]: + """The provider and title columns a closed-session list shares. + + The live list has had column discipline for a long time; this one, the same + shape one keypress away, was still writing each row at whatever width its + own title happened to be. + """ + closed = [row for row in rows if row.kind == "closed-session"] + return ( + max((_cells(row.provider or voice.MISSING) for row in closed), default=1), + max((_cells(row.label) for row in closed), default=1), + ) + + +def _closed_row_text( + row: Row, width: int, columns: tuple[int, int] | None = None +) -> tuple[str, tuple[Span, ...]]: + provider_width, label_column = columns or ( + _cells(row.provider or voice.MISSING), + _cells(row.label), + ) + number = f"{row.number:>2}" if row.number is not None else " " + provider = row.provider or voice.MISSING + # The title column is capped so one very long name cannot push every other + # row's age and state off the right edge. + room = max(4, width - 8 - provider_width - 4) + label_column = min(label_column, room) + label = _pad(_fit(row.label, label_column), label_column) + prefix = f" {number} [" + text = f"{prefix}{_pad(provider, provider_width)}] {label}" + if row.age: + text += f" [{row.age}]" + if row.state: + text += f", {row.state}" + # Measured from the prefix rather than searched for: a provider word can + # also occur inside a session's own title, and `index()` finds that first. + provider_start = _cells(prefix) + provider_key = row.closed.provider.casefold() if row.closed else "unknown" + spans = [ + Span(2, len(number), NUMBER), + Span(provider_start, _cells(provider), PROVIDER_STYLE_PREFIX + provider_key), + ] + suffix = provider_start + provider_width + 2 + label_column + if row.age or row.state: + spans.append(Span(suffix, max(0, _cells(text) - suffix), DIM)) + return _fit(text, width), tuple(spans) + + +def build_frame(screen: Screen, *, width: int = 80, height: int = 24, top: int = 0) -> Frame: + """One frame, complete. Nothing about it depends on what was drawn last.""" + + width = max(20, width) + height = max(6, height) + if screen.panel is not None: + title = screen.panel.title + elif screen.heading or screen.help_open: + title = screen.heading or TITLE_LINE + else: + count = screen.session_total or sum(row.kind == "session" for row in screen.rows) + ready = screen.ready_total or sum( + row.kind == "session" and row.session is not None and not row.session.is_attached + for row in screen.rows + ) + elsewhere = screen.elsewhere_total or sum( + row.kind == "session" and row.session is not None and row.session.is_attached + for row in screen.rows + ) + title = f"{count} {'session' if count == 1 else 'sessions'} · {ready} ready · {elsewhere} open elsewhere" + title_style = ( + TITLE_STYLE_PREFIX + screen.panel.identity_color + if screen.panel is not None and screen.panel.identity_color + else BOLD + ) + lines: list[Line] = [Line(_fit(f" {title}", width), (Span(2, len(title), title_style),))] + + if screen.stale: + note = "Showing the last confirmed list. Actions wait for a refresh." + lines.append(Line(_fit(f" {note}", width), (Span(2, len(note), DIM),))) + tail: list[Line] = [] + if screen.input_text: + prompt = "Filter:" if screen.input_is_filter else "Mark:" + entry = f" {prompt} {screen.input_text}" + tail.append(Line(_fit(entry, width), (Span(2, len(prompt), KEY),))) + if screen.status: + status = _fit(f" {screen.status}", width) + folded = screen.status.casefold() + style = ( + ATTENTION + if any(word in folded for word in ("refused", "nothing changed", "there is no", "unavailable", "failed", "could not")) + else MARK + ) + tail.append(Line(status, (Span(2, max(0, len(status) - 2), style),))) + footer = voice.FOOTER + if screen.help_open: + footer = voice.HELP_FOOTER + elif screen.panel is not None: + # A panel asking for typed text cannot offer `b` as a key: `b` is the + # first letter of the name being typed. + footer = ( + voice.NAME_PROMPT_FOOTER + if screen.panel.prompt is not None + else voice.BACK_FOOTER + ) + elif screen.view_kind == "closed": + footer = voice.CLOSED_FOOTER + tail.append(_footer_line(width, footer)) + + body_height = max(1, height - len(lines) - len(tail)) + cursor_line: int | None = None + row_lines: dict[int, str] = {} + + if screen.help_open: + body = _help_lines(width) + lines.extend(body[:body_height]) + elif screen.panel is not None: + body, panel_cursor = _panel_lines(screen.panel, width) + body = body[:body_height] + offset = len(lines) + lines.extend(body) + if panel_cursor < len(body): + cursor_line = offset + panel_cursor + for index, line in enumerate(body): + if line.row_key: + row_lines[offset + index] = line.row_key + elif not screen.rows: + note = screen.empty_note or voice.empty("Sessions") + lines.append(Line(_fit(f" {note}", width))) + else: + cursor = max(0, min(len(screen.rows) - 1, screen.cursor_index)) + top = max(0, min(top, max(0, len(screen.rows) - body_height))) + if cursor < top: + top = cursor + elif cursor >= top + body_height: + top = cursor - body_height + 1 + column, details, compact_age, status_only = _responsive_session_layout( + screen.rows, width + ) + closed_columns = closed_widths(screen.rows) + + def projected(start: int) -> list[tuple[Row | None, Line]]: + result: list[tuple[Row | None, Line]] = [] + previous_group = "" + for row in screen.rows[start:]: + if row.kind == "closed-session": + text, spans = _closed_row_text(row, width, closed_columns) + result.append((row, Line(text, spans, row_key=row.key))) + if len(result) >= body_height: + break + continue + if row.kind == "session" and row.session is not None: + group = "Open elsewhere" if row.session.is_attached else "Ready" + if group != previous_group and len(result) + 1 < body_height: + result.append((None, Line(f" {group}", (Span(2, len(group), BOLD),)))) + previous_group = group + text, spans = _row_text( + row, + column, + details, + width, + compact_age=compact_age, + status_only=status_only, + ) + result.append((row, Line(_fit(text, width), spans, row_key=row.key))) + if len(result) >= body_height: + break + return result[:body_height] + + window = projected(top) + while cursor >= top and not any( + row is not None and screen.rows[cursor].key == row.key for row, _ in window + ): + top += 1 + window = projected(top) + for row, line in window: + line_number = len(lines) + lines.append(line) + if row is not None: + row_lines[line_number] = row.key + if row.key == screen.rows[cursor].key: + cursor_line = line_number + + # The footer sits in the same place on every frame. A screen whose last + # line moves as the list shortens reads as flicker even when nothing was + # repainted twice. + while len(lines) + len(tail) < height: + lines.append(Line("")) + lines.extend(tail) + return Frame(tuple(lines), cursor_line, top, row_lines) diff --git a/lib/sessionkit_tui/model.py b/lib/sessionkit_tui/model.py new file mode 100644 index 0000000..cb9d15e --- /dev/null +++ b/lib/sessionkit_tui/model.py @@ -0,0 +1,391 @@ +"""The session snapshot, read once and turned into something the screen can +reason about. + +The kit already produces this document (`shpool_status --json`); nothing here +collects state or asks the session manager anything. Two fields the row schema +is growing, `model` and `origin`, may be absent in a snapshot written by an +older release, so both have an answer when they are missing: the model renders +as an em dash, and a session with no recorded origin is one a person started. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import os +from typing import Any, Callable, Mapping, Sequence + +from . import voice +from sessionkit_inventory import labels +from sessionkit_inventory.common import stall_threshold_seconds +from sessionkit_inventory.model import ( + classify_top_level_sessions, + session_is_unavailable, +) + +# A session the model has been quiet in for this long has stopped working, +# whatever the provider calls its own state. Mirrors the renderer's threshold +# so two surfaces can never disagree about the same session. +DEFAULT_STALL_SECONDS = labels.STALL_DEFAULT_SECONDS + +# One vocabulary on the screen, whatever the collector called it -- and it is +# the SAME table the text picker and `sp list` read, not a second copy of it. +# This file used to keep its own, which is exactly how the two surfaces last +# drifted: the old notification-derived `idle` was retired in labels.py. The +# new transcript-aged state lives there too, so it cannot drift here again. +STATE_WORDS = labels.STATE_WORDS + +PROVIDER_LABELS = { + "claude": "CLD", + "codex": "CDX", + "shell": "SHL", +} + +PROVIDER_NAMES = { + "claude": "Claude", + "codex": "Codex", + "shell": "shell", +} + +HUMAN = "human" +MACHINE = "machine" + + +def stall_seconds(environ: Mapping[str, str] | None = None) -> int: + return stall_threshold_seconds(environ) + + +def _text(value: Any, limit: int = 200) -> str: + if value is None: + return "" + return " ".join(str(value).split())[:limit] + + +def _whole(value: Any) -> int | None: + if isinstance(value, bool) or not isinstance(value, int): + return None + return value + + +def format_age(seconds: int | None) -> str: + """When a session last did something, in the words every surface uses. + + One phrase, one shape, on every row. It used to be `opened 3 hr ago` here + and `3 hr ago` on a row that happened to know its last reply, so the + column measured two different things and lined up as neither. + """ + + return labels.last_active(seconds) + + +@dataclass(frozen=True) +class Session: + """One managed session, as the screen needs it. + + `key` is the identity the highlight is glued to. It is never printed: no + screen prints a shpool ID or a conversation UUID. + """ + + key: str + number: int | None + title: str + provider: str + account_alias: str | None + model: str | None + origin: str + availability: str + agent_status: str + needs_you: bool + subagent_count: int + age_seconds: int | None + quiet_seconds: int | None + cwd: str + uuid: str | None + shpool_id: str | None + mutation_allowed: bool + display_color: str = "" + raw: Mapping[str, Any] = field(default_factory=dict, repr=False) + + @property + def provider_label(self) -> str: + return PROVIDER_LABELS.get(self.provider.casefold(), "UNK") + + @property + def account_text(self) -> str: + return self.account_alias or voice.MISSING + + @property + def model_text(self) -> str: + """Read through the SAME function the text picker uses. + + This used to guess from the provider alone and never looked at the + reason the collector recorded, so a brand-new conversation that simply + had not answered yet was reported as `unreadable` -- the kit claiming a + broken transcript with nothing wrong, on one screen and not the other. + """ + return labels.model_cell(self.raw) + + def last_active_text(self, now_ms: int) -> str: + """The one time this row shows, from the same function as the picker. + + Formatting the snapshot's frozen age here while the picker recomputed + from the recorded moment made the two screens disagree across a minute + boundary on one snapshot. + """ + return labels.row_last_active(self.raw, now_ms) + + @property + def is_machine(self) -> bool: + return self.origin == MACHINE + + @property + def is_attached(self) -> bool: + return self.availability.casefold() == "attached" + + @property + def restorable(self) -> bool: + return bool(self.uuid) and self.provider.casefold() in {"claude", "codex"} + + def state_word(self, *, seen: bool = False, stall: int | None = None) -> str: + """The shared state word, with question and aged-idle precedence. + + The WHOLE row goes to `session_state`, not three fields copied out of + it. Passing a synthetic dict meant `setup_incomplete` -- which that + function tests first -- was a key it never received, so a session the + text picker called `pending` was described here as whatever + its status happened to map to, and a row with no status at all + defaulted differently on each screen. A session seen in this window is + the one thing this screen knows that the other does not. + """ + + threshold = stall if stall is not None else stall_threshold_seconds() + row = dict(self.raw) + if seen: + row["needs_you"] = False + return labels.session_state(row, stall_seconds=threshold) + + def haystack(self) -> str: + """What the filter searches. Identifiers are deliberately absent: a + person cannot see them, so they cannot type them.""" + + project = os.path.basename(self.cwd.rstrip("/")) if self.cwd else "" + parts = ( + self.title, + self.provider_label, + PROVIDER_NAMES.get(self.provider.casefold(), self.provider), + self.account_alias or "", + project, + self.cwd, + self.agent_status, + ) + return " ".join(part for part in parts if part).casefold() + + +@dataclass(frozen=True) +class Inventory: + """The snapshot as a whole, plus the two facts that gate actions.""" + + sessions: tuple[Session, ...] = () + source: str = "live" + stale: bool = False + daemon: Mapping[str, Any] = field(default_factory=dict, repr=False) + unavailable_total: int = 0 + + @property + def live(self) -> bool: + return self.source == "live" and not self.stale + + def by_key(self, key: str) -> Session | None: + for session in self.sessions: + if session.key == key: + return session + return None + + def by_number(self, number: int) -> Session | None: + for session in self.sessions: + if session.number == number: + return session + return None + + +def _origin(row: Mapping[str, Any]) -> str: + """A session with no recorded origin is one a person started. + + The stamp is written at creation. Until every launcher writes it, the safe + reading of silence is the one that keeps a person's own session visible. + """ + + raw = _text(row.get("origin"), 20).casefold() + return MACHINE if raw == MACHINE else HUMAN + + +def _model(row: Mapping[str, Any]) -> str | None: + """The model's product name if the collector resolved one, else its id. + + `display_model` first: `model` is the raw identifier a machine reads, and + a person reading `Opus 5` should not have to read `claude-opus-5`. + """ + for name in ("display_model", "model", "model_id"): + value = _text(row.get(name), 40) + if value: + return value + return None + + +def parse_session(row: Mapping[str, Any]) -> Session | None: + if not isinstance(row, Mapping): + return None + identity = row.get("identity") if isinstance(row.get("identity"), Mapping) else {} + shpool_id = _text(row.get("shpool_id_raw") or row.get("shpool_id"), 96) or None + uuid = _text(identity.get("uuid"), 64) or None + number = _whole(row.get("terminal_number")) + if number is not None and number <= 0: + number = None + key = shpool_id or uuid or f"row-{row.get('row')}" + subagents = row.get("subagents") + subagents = subagents if isinstance(subagents, Sequence) else () + count = _whole(row.get("active_subagent_count")) + if count is None: + count = len(subagents) + return Session( + key=key, + number=number, + title=_text(row.get("display_title") or row.get("title"), 120) or "Untitled", + provider=_text(row.get("display_provider") or row.get("provider"), 20).casefold() + or "shell", + account_alias=_text(row.get("account_alias"), 20) or None, + model=_model(row), + origin=_origin(row), + availability=_text(row.get("availability"), 20).casefold() or "ready", + # No default of its own: `session_state` decides what an absent status + # means, and it must decide it once for both screens. + agent_status=_text(row.get("agent_status"), 60), + needs_you=bool(row.get("needs_you")), + subagent_count=max(0, int(count)), + age_seconds=_whole(row.get("process_age_seconds")), + quiet_seconds=_whole(row.get("recent_output_age_seconds")), + cwd=_text(row.get("cwd"), 4096), + uuid=uuid, + shpool_id=shpool_id, + mutation_allowed=row.get("mutation_allowed") is True, + display_color=_text(row.get("display_color") or row.get("color"), 20).casefold(), + raw=dict(row), + ) + + +def parse_inventory(document: Any) -> Inventory: + """Read the snapshot document. A shape this cannot read is an empty + estate, never a crash: the screen still draws, and says the list is + empty.""" + + if not isinstance(document, Mapping): + return Inventory(sessions=(), source="cache", stale=True) + human_rows, machine_rows, _ = classify_top_level_sessions( + document.get("sessions", ()) or () + ) + unavailable_total = sum( + session_is_unavailable(row) for row in (*human_rows, *machine_rows) + ) + sessions = [] + for row in (*human_rows, *machine_rows): + if session_is_unavailable(row): + continue + parsed = parse_session(row) + if parsed is not None: + sessions.append(parsed) + daemon = document.get("daemon_generation") + return Inventory( + sessions=tuple(sessions), + source=_text(document.get("source"), 20) or "cache", + stale=bool(document.get("stale")), + daemon=dict(daemon) if isinstance(daemon, Mapping) else {}, + unavailable_total=unavailable_total, + ) + + +@dataclass(frozen=True) +class ClosedSession: + """A conversation that was closed on purpose and can come back. + + Written by the close path into a small ledger; absent until that ledger + exists, which the screen states rather than hides. + """ + + key: str + title: str + provider: str + uuid: str + cwd: str + account_alias: str | None + closed_at_unix_ms: int | None + # What the ledger itself says, when it says anything. A conversation whose + # transcript this machine can no longer read has a provider and a uuid and + # still cannot come back, so the shape of the record does not answer this. + recorded_restorable: bool | None = None + + @property + def provider_label(self) -> str: + return PROVIDER_LABELS.get(self.provider.casefold(), "shell") + + @property + def restorable(self) -> bool: + if self.recorded_restorable is not None: + return self.recorded_restorable + return bool(self.uuid) and self.provider.casefold() in {"claude", "codex"} + + def haystack(self) -> str: + project = os.path.basename(self.cwd.rstrip("/")) if self.cwd else "" + parts = (self.title, self.provider_label, self.account_alias or "", project) + return " ".join(part for part in parts if part).casefold() + + +def parse_closed( + records: Any, + *, + still_readable: Callable[[str, str], bool] | None = None, +) -> tuple[ClosedSession, ...]: + """Newest first, one row per conversation, the newest close winning. + + ``still_readable`` is the one question that decides whether a conversation + can actually come back, and every surface has to ask it the same way. This + screen never asked: it read the ledger straight off disk, so a + conversation whose transcript is gone was listed with no mark and offered + a Restore that cannot work. + """ + + if not isinstance(records, Sequence): + return () + seen: dict[str, ClosedSession] = {} + for record in records: + if not isinstance(record, Mapping): + continue + uuid = _text(record.get("uuid"), 64) + provider = _text(record.get("provider"), 20).casefold() + if not provider: + continue + key = uuid or _text(record.get("shpool_id"), 96) + if not key: + continue + closed_at = _whole(record.get("closed_at_unix_ms")) + existing = seen.get(key) + if existing is not None and (existing.closed_at_unix_ms or 0) >= (closed_at or 0): + continue + recorded = record.get("restorable") + if isinstance(recorded, bool): + pass + elif provider == "shell" or not uuid: + recorded = False + elif still_readable is not None: + recorded = bool(still_readable(provider, uuid)) + seen[key] = ClosedSession( + key=key, + title=_text(record.get("title"), 120) or "Untitled", + provider=provider, + uuid=uuid, + cwd=_text(record.get("cwd"), 4096), + account_alias=_text(record.get("account_alias"), 20) or None, + closed_at_unix_ms=closed_at, + recorded_restorable=bool(recorded) if isinstance(recorded, bool) else None, + ) + return tuple( + sorted(seen.values(), key=lambda item: -(item.closed_at_unix_ms or 0)) + ) diff --git a/lib/sessionkit_tui/rows.py b/lib/sessionkit_tui/rows.py new file mode 100644 index 0000000..48e8079 --- /dev/null +++ b/lib/sessionkit_tui/rows.py @@ -0,0 +1,314 @@ +"""What the screen shows, in the order it shows it. + +One list. Sessions that need you, then the ones openable here, then the ones +open in another window; one counted row for machine sessions; then the +ordinary rows, New session, Projects, Closed sessions, Help. Marking, +filtering, and the attention rules all live here, where they can be tested +without a terminal. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import re +from typing import Iterable, Sequence + +from . import voice +from sessionkit_inventory import labels +from .model import ClosedSession, Session +from sessionkit_inventory.model import canonical_session_order_key + +SESSION = "session" +MACHINE_COUNT = "machine-count" +NEW_SESSION = "new-session" +PROJECTS = "projects" +CLOSED = "closed" +HELP = "help" +CLOSED_SESSION = "closed-session" + +# How long the list holds still after the highlight moves. Re-sorting under a +# moving cursor moves the row out from under the person reading it. +SORT_HOLD_MS = 3000 + +_RANGE = re.compile(r"^(\d+)-(\d+)$") + + +def is_filter(text: str) -> bool: + """Digits, commas, and dashes mark. One letter and the whole input is a + filter; there is no half-and-half state to explain.""" + + return any(character.isalpha() for character in text) + + +def parse_marks(text: str) -> tuple[tuple[int, ...], tuple[str, ...]]: + """`1,4,7-9` becomes the numbers it names. + + Returns the numbers in the order they were named and the fragments that + named nothing, so the screen can say which one it could not use. + """ + + numbers: list[int] = [] + unusable: list[str] = [] + for fragment in text.replace(" ", "").split(","): + if not fragment: + continue + match = _RANGE.match(fragment) + if match: + first, last = int(match.group(1)), int(match.group(2)) + step = 1 if last >= first else -1 + for number in range(first, last + step, step): + if number not in numbers: + numbers.append(number) + continue + if fragment.isdigit(): + number = int(fragment) + if number not in numbers: + numbers.append(number) + continue + unusable.append(fragment) + return tuple(numbers), tuple(unusable) + + +def matches(needle: str, haystack: str) -> bool: + """Loose, in-order matching: `lsa` finds `Ledger Sync Audit`.""" + + needle = needle.casefold().replace(" ", "") + if not needle: + return True + position = 0 + for character in needle: + position = haystack.find(character, position) + if position < 0: + return False + position += 1 + return True + + +def is_seen(session: Session, seen: Iterable[str]) -> bool: + return session.key in set(seen) + + +# The words that mean a person is being waited on. `idle` is one of them: it +# is a needs-you session whose transcript has stopped moving, not a state of +# its own. +ATTENTION_STATES = frozenset({labels.QUESTION, labels.WAITING_ON_YOU, labels.IDLE}) + + +def needs_you(session: Session, seen: Iterable[str]) -> bool: + """A session a person is looking at is seen, never counted as waiting. + + Asked of the state word this row displays, not of the raw `needs_you` and + `blocking_question` flags underneath it. Those answer a narrower question: + a provider turn that ended normally reports `agent_status: idle` with + `needs_you: false`, so the row read `needs you` on screen while this + predicate said no, and the count beside it disagreed with the rows it was + counting. + """ + + if is_seen(session, seen): + return False + return session.state_word() in ATTENTION_STATES + + +def _natural_key(session: Session) -> tuple: + return canonical_session_order_key(session.raw) + + +def order_sessions( + sessions: Sequence[Session], + *, + seen: Iterable[str] = (), + pinned: Sequence[str] = (), +) -> tuple[Session, ...]: + """The list order, or the order it was already in. + + While `pinned` holds, a session keeps the place it had when the highlight + last moved; anything new lands after it in natural order. + """ + + # ``seen`` changes the attention wording in this window, never the order. + # Otherwise glancing at one row would make the TUI disagree with the shell + # picker even though both read the same snapshot. + natural = sorted(sessions, key=_natural_key) + if not pinned: + return tuple(natural) + places = {key: index for index, key in enumerate(pinned)} + limit = len(places) + return tuple( + sorted( + natural, + key=lambda item: ( + places.get(item.key, limit), + natural.index(item), + ), + ) + ) + + +@dataclass(frozen=True) +class Row: + """One line a person can move to. Every row on the screen is one of + these, so the highlight, the mouse, and Enter share one meaning.""" + + key: str + kind: str + label: str + detail: str = "" + number: int | None = None + needs_you: bool = False + marked: bool = False + provider: str = "" + account: str = "" + model: str = "" + state: str = "" + subagents: str = "" + age: str = "" + compact_age: str = "" + extras: tuple[str, ...] = () + session: Session | None = field(default=None, repr=False) + closed: ClosedSession | None = field(default=None, repr=False) + + @property + def markable(self) -> bool: + return self.number is not None + + +def build_rows( + sessions: Sequence[Session], + *, + query: str = "", + marks: Iterable[int] = (), + seen: Iterable[str] = (), + pinned: Sequence[str] = (), + machine_expanded: bool = False, + stall: int | None = None, + age_text=None, + now_ms: int | None = None, +) -> tuple[Row, ...]: + """The whole list, top to bottom.""" + + seen_set = frozenset(seen) + marked = set(marks) + needle = query.casefold().strip() + + def keep(session: Session) -> bool: + return matches(needle, session.haystack()) if needle else True + + people = [s for s in sessions if not s.is_machine and keep(s)] + machines = [s for s in sessions if s.is_machine and keep(s)] + + def row_for(session: Session) -> Row: + seen_here = session.key in seen_set + state = session.state_word(seen=seen_here, stall=stall) + # One time on every row, saying the same thing in the same words: when + # this session last did something. A row that needed the person used to + # fold its wait into the state cell and show no time at all, so no two + # rows on the screen were measuring the same quantity. + # + # With a clock, the row is read through the SAME function the text + # picker uses, from the recorded moment; the two screens disagreed + # across a minute boundary while this one formatted a frozen age. + if now_ms is not None: + age = session.last_active_text(now_ms) + compact_age = labels.row_last_active_compact(session.raw, now_ms) + elif age_text is not None: + age = age_text(session.quiet_seconds) + compact_age = labels.brief_age(session.quiet_seconds) if session.quiet_seconds is not None else "unknown" + else: + age = "" + compact_age = "" + subagents = ( + voice.plural(session.subagent_count, "subagent") + if session.subagent_count + else "" + ) + detail_parts = [ + session.provider_label, + session.account_text, + session.model_text, + state, + subagents, + age, + ] + return Row( + key=session.key, + kind=SESSION, + label=session.title, + detail=" | ".join(part for part in detail_parts if part), + number=session.number, + needs_you=needs_you(session, seen_set), + marked=session.number in marked, + provider=session.provider_label, + account=session.account_text, + model=session.model_text, + state=state, + subagents=subagents, + age=age, + compact_age=compact_age, + session=session, + ) + + result = [row_for(session) for session in order_sessions(people, seen=seen_set, pinned=pinned)] + + if machines: + needing = sum(1 for session in machines if needs_you(session, seen_set)) + result.append( + Row( + key=MACHINE_COUNT, + kind=MACHINE_COUNT, + label=voice.machine_row(len(machines), needing), + ) + ) + if machine_expanded: + result.extend( + row_for(session) + for session in order_sessions(machines, seen=seen_set, pinned=pinned) + ) + + for key, label in ( + (NEW_SESSION, voice.NEW_SESSION), + (PROJECTS, voice.PROJECTS), + (CLOSED, voice.CLOSED_SESSIONS), + (HELP, voice.HELP), + ): + if needle and not matches(needle, label.casefold()): + continue + result.append(Row(key=key, kind=key, label=label)) + return tuple(result) + + +def build_closed_rows( + closed: Sequence[ClosedSession], + *, + query: str = "", + age_text=None, +) -> tuple[Row, ...]: + needle = query.casefold().strip() + result = [] + for index, item in enumerate(closed, start=1): + if needle and not matches(needle, item.haystack()): + continue + state = "" if item.restorable else "history only" + age = "login time unknown" + result.append( + Row( + key=item.key, + kind=CLOSED_SESSION, + label=item.title, + detail=item.provider_label, + number=index, + provider=item.provider_label, + account=item.account_alias or voice.MISSING, + state=state, + age=age, + closed=item, + ) + ) + return tuple(result) + + +def attention_count(rows: Sequence[Row]) -> int: + """Every count on the screen is drillable: this one counts exactly the + rows a person can move to and open.""" + + return sum(1 for row in rows if row.needs_you) diff --git a/lib/sessionkit_tui/runner.py b/lib/sessionkit_tui/runner.py new file mode 100644 index 0000000..69c3f6b --- /dev/null +++ b/lib/sessionkit_tui/runner.py @@ -0,0 +1,339 @@ +"""The only part of the screen that starts a process. + +The snapshot comes from `shpool_status --json`, exactly the document the rest +of the kit reads. Actions go to `sp`, with a proof file naming the exact +session, exactly as the picker has always called them. Nothing here decides +what should happen; it carries out what `state` already decided. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import os +from pathlib import Path +import stat +import subprocess +import tempfile +from typing import Any, Callable, Sequence + +from sessionkit_inventory import closed_sessions as closed_ledger + +from . import voice +from .actions import PROOF, Plan +from .model import ClosedSession, Inventory, Session, parse_closed, parse_inventory + +# What a picker verb answers with when it refuses, and when the attach itself +# failed after the action was allowed. `sp help exit-codes` owns both. +REFUSED = 74 +ATTACH_FAILED = 75 + +SNAPSHOT_TIMEOUT_SECONDS = 30 + + +def _state_dir(environ) -> Path: + override = environ.get("SESSION_KIT_STATE_DIR") + if override: + return Path(override) + base = environ.get("XDG_STATE_HOME") or os.path.join( + environ.get("HOME", "/tmp"), ".local/state" + ) + return Path(base) / "session-kit" + + +@dataclass +class Runner: + """Where the release keeps its helpers, and how to reach them.""" + + sp_cmd: str + status_cmd: str + state_dir: Path + environ: dict + + @classmethod + def from_environment(cls, script_dir: Path, environ=None) -> "Runner": + environ = dict(os.environ if environ is None else environ) + return cls( + sp_cmd=environ.get("SESSION_KIT_SP_CMD") or str(script_dir / "sp"), + status_cmd=environ.get("SESSION_KIT_STATUS_CMD") + or str(script_dir / "shpool_status"), + state_dir=_state_dir(environ), + environ=environ, + ) + + # ---- reading --------------------------------------------------------- + + def snapshot(self, *, timeout: int = SNAPSHOT_TIMEOUT_SECONDS) -> Inventory | None: + """The estate as the session manager reports it, or nothing at all. + + A refusal to answer is not an empty estate: the caller keeps the last + list it had and the screen says it is showing one. + """ + + try: + done = subprocess.run( + [self.status_cmd, "--json"], + capture_output=True, + text=True, + timeout=timeout, + env=self.environ, + ) + except (OSError, subprocess.SubprocessError): + return None + if done.returncode != 0: + return None + try: + return parse_inventory(json.loads(done.stdout)) + except ValueError: + return None + + def closed_sessions(self) -> tuple[ClosedSession, ...]: + """The ledger of deliberate closes, newest first. + + Written by the close path. Until that ledger exists the screen says + the list is empty, which is the truth about what it can offer. + """ + + # The TUI used to have a second, unlocked reader which looked in the + # state directory, skipped malformed rows, and turned every read error + # into an empty screen. Use the durable ledger's one transaction and + # let its read error stay distinguishable from a genuinely empty list. + # The same readability rule the recovery list applies, asked of the + # same ledger: this screen offers a Restore, so it may not offer one + # for a conversation whose transcript this machine cannot read. + from sessionkit_inventory.transcripts import locate_transcript + + return parse_closed( + closed_ledger.load_closed( + environ=self.environ, + still_readable=lambda provider, uuid: locate_transcript( + provider, uuid, environ=self.environ + ) + is not None, + ) + ) + + def projects(self) -> tuple[tuple[str, str], ...]: + path = self.environ.get("SESSION_KIT_PROJECTS_FILE") or str( + Path(self.environ.get("HOME", "/tmp")) / ".config/session-kit/projects.tsv" + ) + found: list[tuple[str, str]] = [] + try: + with open(path, encoding="utf-8") as handle: + for line in handle: + parts = line.rstrip("\n").split("\t") + if len(parts) < 3 or not parts[0] or not parts[2].startswith("/"): + continue + found.append((parts[0], parts[2])) + except OSError: + return () + seen = set() + unique = [] + for alias, path_text in found: + if alias in seen: + continue + seen.add(alias) + unique.append((alias, path_text)) + return tuple(unique) + + def accounts(self, provider: str) -> tuple[str, ...]: + """The accounts this provider can actually switch to. + + `sp account choices` alone decides selectability; nothing here + re-derives it, so an account this screen offers is one that command + already called selectable. + """ + + try: + done = subprocess.run( + [self.sp_cmd, "account", "choices", provider], + capture_output=True, + text=True, + timeout=20, + env=self.environ, + ) + except (OSError, subprocess.SubprocessError): + return () + if done.returncode != 0: + return () + try: + payload = json.loads(done.stdout) + except ValueError: + return () + choices = payload.get("choices") if isinstance(payload, dict) else None + if not isinstance(choices, list): + return () + return tuple( + str(row.get("alias")) + for row in choices + if isinstance(row, dict) and row.get("eligible") is True and row.get("alias") + ) + + def models(self, provider: str) -> tuple[str, ...]: + """The models this machine offers for a provider. + + There is no catalog inside the kit, so the list is configuration: one + `provider<TAB>model` line per model. With no configuration the panel + says the list is empty instead of guessing a model name. + """ + + from sessionkit_inventory.worker_model import configured_models + + return configured_models(provider, self.environ) + + # ---- proofs ---------------------------------------------------------- + + def write_proof(self, session: Session, inventory: Inventory) -> str | None: + """Name the exact session, the way every picker action always has. + + A proof carries the process identities the snapshot saw. A repaint + that moves the row cannot move the action with it. + """ + + if not inventory.live or not session.mutation_allowed: + return None + row = session.raw + identity = row.get("identity") or {} + shell = row.get("shpool_shell") or {} + daemon = inventory.daemon or {} + proof = { + "daemon_pid": daemon.get("pid"), + "daemon_process_start_ticks": daemon.get("process_start_ticks"), + "proof_type": "session-kit-picker-session-v1", + "provider": row.get("provider"), + "provider_pid": identity.get("pid"), + "provider_process_start_ticks": identity.get("process_start_ticks"), + "schema_version": 1, + "shell_pid": shell.get("pid"), + "shell_process_start_ticks": shell.get("process_start_ticks"), + "shpool_id": row.get("shpool_id_raw"), + "started_at_unix_ms": row.get("started_at_unix_ms"), + "uuid": identity.get("uuid") or "", + } + payload = (json.dumps(proof, sort_keys=True, separators=(",", ":")) + "\n").encode() + try: + self.state_dir.mkdir(parents=True, exist_ok=True) + descriptor, name = tempfile.mkstemp( + prefix="picker-proof.", suffix=".json", dir=str(self.state_dir) + ) + except OSError: + return None + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + metadata = os.lstat(name) + if ( + not stat.S_ISREG(metadata.st_mode) + or stat.S_IMODE(metadata.st_mode) != 0o600 + or metadata.st_uid != os.geteuid() + ): + raise OSError("unsafe proof") + except OSError: + try: + os.unlink(name) + except OSError: + pass + return None + return name + + # ---- running --------------------------------------------------------- + + def argv_for(self, plan: Plan, proof_path: str | None) -> list[str]: + argv = [] + for word in plan.argv: + if word == "sp": + argv.append(self.sp_cmd) + elif word == PROOF: + argv.append(proof_path or "") + else: + argv.append(word) + return argv + + def execute( + self, + plan: Plan, + inventory: Inventory, + *, + session: Session | None = None, + suspend: Callable[[], Any] | None = None, + resume: Callable[[], Any] | None = None, + ) -> tuple[bool, str]: + """Run one plan. Returns whether it worked and anything it said.""" + + proof_path = None + if plan.wants_proof: + if session is None: + return False, voice.NOTHING_CHANGED + proof_path = self.write_proof(session, inventory) + if proof_path is None: + return False, voice.NOTHING_CHANGED + environ = dict(self.environ) + environ.update(plan.env) + if plan.confirm_id: + environ["SESSION_KIT_CONFIRM_ID"] = plan.confirm_id + argv = self.argv_for(plan, proof_path) + capture = not plan.needs_terminal + try: + if plan.needs_terminal and suspend is not None: + suspend() + try: + done = subprocess.run( + argv, + env=environ, + capture_output=capture, + text=True, + ) + finally: + if plan.needs_terminal and resume is not None: + resume() + except (OSError, subprocess.SubprocessError): + return False, voice.action_failed("The action") + finally: + if proof_path: + try: + os.unlink(proof_path) + except OSError: + pass + if done.returncode == 0: + return True, "" + # A verb the release may not carry yet answers in its own words. Those + # are the words shown: a friendlier sentence would hide which release + # is installed. Every other refusal falls back to the plan's line. + note = "" + if capture and plan.quote_stderr: + note = _first_line(done.stderr) or _first_line(done.stdout) + return False, note + + +def _first_line(text: str | None) -> str: + for line in (text or "").splitlines(): + line = line.strip() + if line: + return line + return "" + + +def run_batch( + runner: Runner, + inventory: Inventory, + plans: Sequence[Plan], + *, + suspend: Callable[[], Any] | None = None, + resume: Callable[[], Any] | None = None, +) -> tuple[bool, str]: + """Every plan in one Enter. The first refusal is the sentence shown.""" + + everything_worked = True + note = "" + for plan in plans: + session = inventory.by_key(plan.proof_for) if plan.proof_for else None + ok, said = runner.execute( + plan, inventory, session=session, suspend=suspend, resume=resume + ) + if not ok: + everything_worked = False + note = note or said + return everything_worked, note diff --git a/lib/sessionkit_tui/state.py b/lib/sessionkit_tui/state.py new file mode 100644 index 0000000..51017b3 --- /dev/null +++ b/lib/sessionkit_tui/state.py @@ -0,0 +1,730 @@ +"""The screen's answers to keys, clicks, and the clock. + +Everything a person can do arrives here as an event and leaves as either a new +state or a command to run. No curses, no subprocess, no clock of its own: the +caller supplies the time, so a test can hold the list still for three seconds +without waiting three seconds. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +import time +from typing import Callable, Iterable, Mapping, Sequence + +from . import actions, rows as rowmod, voice +from .actions import Plan +from .frame import Frame, Panel, PanelItem, Screen, build_frame +from .model import ClosedSession, Inventory, Session, format_age, stall_seconds + +LIST = "list" +CLOSED = "closed" +HELP = "help" + +PROVIDERS = (("claude", "Claude"), ("codex", "Codex"), ("shell", "shell")) + +HERE = " here" + + +@dataclass(frozen=True) +class Batch: + """One Enter, one sentence. A batch that half-runs still says what it did.""" + + plans: tuple[Plan, ...] + success: str = "" + failure: str = "" + # The session this Enter puts in front of the person. It counts as seen + # once the open actually worked, a refused open showed them nothing. + seen_key: str = "" + + def __bool__(self) -> bool: + return bool(self.plans) + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +@dataclass +class Picker: + """The whole screen, as state plus the events that change it.""" + + inventory: Inventory = field(default_factory=Inventory) + closed: tuple[ClosedSession, ...] = () + projects: tuple[tuple[str, str], ...] = () + accounts: tuple[str, ...] = () + models: tuple[str, ...] = () + # Where the two lists come from when they are not passed in whole: the + # runner asks the release for them the moment a person opens the panel, so + # a screen that never opens it never pays for the answer. + account_source: Callable[[str], tuple[str, ...]] | None = None + model_source: Callable[[str], tuple[str, ...]] | None = None + self_session_id: str | None = None + clock: Callable[[], int] = _now_ms + stall: int | None = None + + view: str = LIST + input_text: str = "" + cursor_key: str | None = None + cursor_fallback: int = 0 + machine_expanded: bool = False + panel: Panel | None = None + status: str = "" + top: int = 0 + pinned: tuple[str, ...] = () + cursor_moved_at_ms: int = 0 + seen_at: dict[str, int] = field(default_factory=dict) + panel_targets: tuple[str, ...] = () + panel_kind: str = "" + pending_provider: str = "" + pending_project: str = "" + body_height: int = 10 + # Set the moment a person asks to leave. The shell reads it and returns + # the deliberate-leave status; nothing else in here acts on it. + leaving: bool = False + + def __post_init__(self) -> None: + if self.stall is None: + self.stall = stall_seconds() + + # ---- what is true right now ------------------------------------------ + + def seen_keys(self) -> frozenset[str]: + """Sessions a person has already looked at. + + The session attached in this window is seen by definition. One a + person opened from here stays seen until it says something new; that + is what makes the attention count trustworthy instead of merely large. + """ + + now = self.clock() + keys = set() + for session in self.inventory.sessions: + if self.self_session_id and session.shpool_id == self.self_session_id: + keys.add(session.key) + continue + at = self.seen_at.get(session.key) + if at is None: + continue + if session.quiet_seconds is None: + keys.add(session.key) + continue + if at >= now - session.quiet_seconds * 1000: + keys.add(session.key) + return frozenset(keys) + + @property + def is_filter(self) -> bool: + return rowmod.is_filter(self.input_text) + + @property + def marks(self) -> tuple[int, ...]: + if not self.input_text or self.is_filter: + return () + numbers, _ = rowmod.parse_marks(self.input_text) + on_screen = {row.number for row in self._session_rows() if row.number is not None} + return tuple(number for number in numbers if number in on_screen) + + def _active_pinned(self) -> tuple[str, ...]: + if not self.pinned: + return () + if self.clock() - self.cursor_moved_at_ms >= rowmod.SORT_HOLD_MS: + return () + return self.pinned + + def _session_rows(self) -> tuple[rowmod.Row, ...]: + numbers: tuple[int, ...] = () + if not self.is_filter: + numbers, _ = rowmod.parse_marks(self.input_text) + return rowmod.build_rows( + self.inventory.sessions, + query=self.input_text if self.is_filter else "", + marks=numbers, + seen=self.seen_keys(), + pinned=self._active_pinned(), + machine_expanded=self.machine_expanded, + stall=self.stall, + age_text=format_age, + now_ms=self.clock(), + ) + + def visible_rows(self) -> tuple[rowmod.Row, ...]: + if self.view == CLOSED: + return rowmod.build_closed_rows( + self.closed, query=self.input_text if self.is_filter else "" + ) + if self.view == HELP: + return () + return self._session_rows() + + def cursor_index(self) -> int: + visible = self.visible_rows() + if not visible: + return 0 + if self.cursor_key is not None: + for index, row in enumerate(visible): + if row.key == self.cursor_key: + return index + return max(0, min(len(visible) - 1, self.cursor_fallback)) + + def current_row(self) -> rowmod.Row | None: + visible = self.visible_rows() + if not visible: + return None + return visible[self.cursor_index()] + + def screen(self) -> Screen: + visible = self.visible_rows() + heading = "" + empty_note = voice.empty("Sessions") + if self.view == CLOSED: + heading = voice.CLOSED_SESSIONS + empty_note = voice.empty(voice.CLOSED_SESSIONS) + elif self.view == HELP: + heading = voice.PICKER_HELP + query = self.input_text if self.is_filter else "" + matching = tuple( + session + for session in self.inventory.sessions + if not query or rowmod.matches(query.casefold(), session.haystack()) + ) + return Screen( + rows=visible, + cursor_index=self.cursor_index(), + input_text="" if self.panel is not None else self.input_text, + input_is_filter=self.is_filter, + status=self.status, + stale=not self.inventory.live, + heading=heading, + panel=self.panel, + help_open=self.view == HELP, + empty_note=empty_note, + session_total=len(matching) if self.view == LIST else 0, + ready_total=sum(not session.is_attached for session in matching) if self.view == LIST else 0, + elsewhere_total=sum(session.is_attached for session in matching) if self.view == LIST else 0, + view_kind=self.view, + ) + + def frame(self, *, width: int = 80, height: int = 24) -> Frame: + drawn = build_frame(self.screen(), width=width, height=height, top=self.top) + self.top = drawn.top + if drawn.row_lines: + self.body_height = max(1, len(drawn.row_lines)) + return drawn + + # ---- events ---------------------------------------------------------- + + def set_inventory(self, inventory: Inventory) -> None: + """A refresh replaces the facts and never the place a person is in.""" + + self.inventory = inventory + if self.cursor_key is not None and self.view == LIST: + self.cursor_fallback = self.cursor_index() + + def set_closed(self, closed: Sequence[ClosedSession]) -> None: + self.closed = tuple(closed) + + def move(self, delta: int) -> None: + if self.panel is not None: + self.panel = self.panel.moved(delta) + return + visible = self.visible_rows() + if not visible: + return + index = max(0, min(len(visible) - 1, self.cursor_index() + delta)) + self.cursor_key = visible[index].key + self.cursor_fallback = index + self.pinned = tuple(row.key for row in visible if row.kind == rowmod.SESSION) + self.cursor_moved_at_ms = self.clock() + + def wheel(self, delta: int) -> None: + visible = self.visible_rows() + if not visible: + return + self.top = max(0, min(max(0, len(visible) - self.body_height), self.top + delta)) + index = self.cursor_index() + if index < self.top: + self.move(self.top - index) + elif index >= self.top + self.body_height: + self.move(self.top + self.body_height - 1 - index) + + def type_text(self, text: str) -> None: + if self.panel is not None and self.panel.prompt is not None: + self.panel = replace(self.panel, entry=self.panel.entry + text) + return + self.input_text += text + self.status = self._input_note() + self.top = 0 + + def backspace(self) -> None: + if self.panel is not None and self.panel.prompt is not None: + self.panel = replace(self.panel, entry=self.panel.entry[:-1]) + return + self.input_text = self.input_text[:-1] + self.status = self._input_note() + + def escape(self) -> None: + """Esc steps back out of wherever a person is. + + A panel closes, then typed input clears, then a second screen returns + to the list. With nothing left to step out of, Esc leaves the picker + the way out has to be reachable from the footer that names it. + """ + + if self.panel is not None: + self.panel = None + self.panel_targets = () + self.panel_kind = "" + self.pending_provider = "" + self.pending_project = "" + self.status = voice.NOTHING_CHANGED + return + if self.input_text: + self.input_text = "" + self.status = "" + return + if self.view != LIST: + self.view = LIST + self.status = "" + return + self.leave() + + def back_key(self) -> bool: + """`b` goes back, on every screen where `b` is a key and not a letter. + + One grammar across the product (operator ruling, 2026-08-15). The two screens that + refuse it are the session list and the closed list, where letters are + the filter: taking `b` there would cost a person the ability to search + for anything beginning with it. Unlike Esc this never leaves the + picker, back from the top is still the top. + """ + + if self.panel is not None and self.panel.prompt is None: + self.escape() + return True + if self.panel is None and self.view == HELP: + self.view = LIST + self.status = "" + return True + return False + + def leave(self) -> None: + """Leaving on purpose. Ctrl-D asks for this from anywhere.""" + + self.leaving = True + + def quit_key(self) -> bool: + """`q` leaves, but only when there is nothing typed for it to join. + + With a filter under way it is an ordinary letter, because a person + narrowing a list by name must be able to type the letter q. + """ + + if self.panel is None and not self.input_text and self.view == LIST: + self.leave() + return True + return False + + def _input_note(self) -> str: + if not self.input_text or self.is_filter: + return "" + numbers, unusable = rowmod.parse_marks(self.input_text) + on_screen = {row.number for row in self._session_rows() if row.number is not None} + for number in numbers: + if number not in on_screen: + return voice.no_such_session(number) + for fragment in unusable: + return voice.no_such_session(fragment) + return "" + + # ---- Enter ----------------------------------------------------------- + + def enter(self) -> Batch | None: + if self.panel is not None: + return self._panel_enter() + if self.view == HELP: + self.view = LIST + return None + # A typed number that names no row is refused, and Enter stops there. + # Falling through to the highlight would open a session nobody named. + if self.view == LIST and self.input_text and not self.is_filter: + note = self._input_note() + if note: + self.status = note + return None + marked = self.marks + if self.view == LIST and marked: + self._open_action_panel( + [self._session_for_number(number) for number in marked], default="close" + ) + return None + row = self.current_row() + if row is None: + # Enter never leaves. The old picker left on an empty line because + # it had a text prompt; this screen always has a row under the + # highlight, so an Enter that sometimes dropped a person into a + # shell would be the same key meaning two things. Esc, q, and + # Ctrl-D leave, and the footer names the first of them. + return None + return self._activate(row) + + def _session_for_number(self, number: int) -> Session | None: + return self.inventory.by_number(number) + + def _activate(self, row: rowmod.Row) -> Batch | None: + if row.kind == rowmod.SESSION and row.session is not None: + if ( + row.session.raw.get("is_subagent") is True + or isinstance(row.session.raw.get("parent_session"), Mapping) + ): + self.status = voice.SUBAGENT_STAYS_WITH_PARENT + return None + if not self.inventory.live: + self.status = voice.NOTHING_CHANGED + return None + return self._run(actions.open_session(row.session), row.session.key) + if row.kind == rowmod.MACHINE_COUNT: + self.machine_expanded = not self.machine_expanded + self.status = "" + return None + if row.kind == rowmod.NEW_SESSION: + self._open_provider_panel() + return None + if row.kind == rowmod.PROJECTS: + self._open_project_panel() + return None + if row.kind == rowmod.CLOSED: + self.view = CLOSED + self.cursor_key = None + self.cursor_fallback = 0 + self.top = 0 + self.status = "" + return None + if row.kind == rowmod.HELP: + self.view = HELP + self.status = "" + return None + if row.kind == rowmod.CLOSED_SESSION and row.closed is not None: + self._open_closed_panel(row.closed) + return None + return None + + def _run(self, plan: Plan, seen_key: str | None = None) -> Batch: + return Batch((plan,), plan.success, plan.failure, seen_key or "") + + # ---- panels ---------------------------------------------------------- + + def _targets(self) -> list[Session]: + found = [] + for key in self.panel_targets: + session = self.inventory.by_key(key) + if session is not None: + found.append(session) + return found + + def _open_action_panel(self, sessions: Iterable[Session | None], *, default: str) -> None: + chosen = [session for session in sessions if session is not None] + if not chosen: + self.status = voice.NOTHING_CHANGED + return + self.panel_targets = tuple(session.key for session in chosen) + self.panel_kind = "actions" + if len(chosen) == 1: + session = chosen[0] + open_label = "Move it here" if session.is_attached else voice.ACTION_OPEN + open_detail = "the other window disconnects" if session.is_attached else "" + items = ( + PanelItem("open", open_label, open_detail), + PanelItem("history", voice.ACTION_HISTORY), + PanelItem("close", voice.ACTION_CLOSE, "the shell and everything inside will end", "attention"), + PanelItem("account", voice.ACTION_ACCOUNT, "keeps this exact conversation"), + PanelItem("model", voice.ACTION_MODEL), + PanelItem("rename", voice.ACTION_RENAME), + PanelItem("color", voice.ACTION_COLOR), + ) + title = chosen[0].title + else: + items = (PanelItem("close", voice.ACTION_CLOSE, "every shell and everything inside will end", "attention"),) + title = voice.plural(len(chosen), "session") + " marked" + index = next( + (position for position, item in enumerate(items) if item.key == default), 0 + ) + identity = chosen[0].display_color if len(chosen) == 1 else "" + subtitle = "Open elsewhere." if len(chosen) == 1 and chosen[0].is_attached else "" + self.panel = Panel( + title, items, index, identity_color=identity, subtitle=subtitle, + subtitle_style="attention", + ) + self.status = "" + + def _open_closed_panel(self, item: ClosedSession) -> None: + self.panel_targets = (item.key,) + self.panel_kind = "closed" + entries = [] + if item.restorable: + entries.append(PanelItem("restore", voice.ACTION_RESTORE)) + entries.append( + PanelItem( + "history", + voice.ACTION_HISTORY, + # Two ways to be history only, and they are not the same + # sentence: a shell never had a conversation, and a + # conversation whose transcript is gone had one this machine + # can no longer read. Calling the second a shell is how the + # list itself used to lie about that row. + "" + if item.restorable + else "a shell session keeps its history and nothing else" + if item.provider.casefold() == "shell" + else "its transcript is no longer on this machine, so only its" + " history is kept", + ) + ) + self.panel = Panel( + item.title, tuple(entries), 0, + subtitle=f"[{item.provider_label}] login time unknown", + subtitle_provider=item.provider, + ) + self.status = "" + + def _open_provider_panel(self) -> None: + self.panel_kind = "provider" + self.panel = Panel( + voice.NEW_SESSION, + tuple( + PanelItem( + key, + "Claude Code" if key == "claude" else ("Shell" if key == "shell" else label), + style=f"provider:{key}", + ) + for key, label in PROVIDERS + ), + 0, + ) + self.status = "" + + def _open_project_panel(self) -> None: + self.panel_kind = "project" + items = [PanelItem(HERE, "This directory")] + items.extend(PanelItem(alias, alias, path) for alias, path in self.projects) + self.panel = Panel("Project", tuple(items), 0) + self.status = "" + + def _panel_enter(self) -> Batch | None: + panel = self.panel + if panel is None: + return None + if panel.prompt is not None: + title = panel.entry.strip() + targets = self._targets() + self.panel = None + self.panel_kind = "" + if not title or not targets: + self.status = voice.NOTHING_CHANGED + return None + self.input_text = "" + return self._run(actions.rename(targets[0], title)) + item = panel.current + if item is None: + self.status = voice.NOTHING_CHANGED + return None + kind, key = self.panel_kind, item.key + + if kind == "provider": + self.pending_provider = key + if self.pending_project: + return self._new_session_batch() + self._open_project_panel() + return None + if kind == "project": + self.pending_project = "" if key == HERE else key + if self.pending_provider: + return self._new_session_batch() + self._open_provider_panel() + return None + if kind == "closed": + item_key = self.panel_targets[0] if self.panel_targets else "" + record = next((entry for entry in self.closed if entry.key == item_key), None) + self.panel = None + self.panel_kind = "" + if record is None: + self.status = voice.NOTHING_CHANGED + return None + if key == "restore": + return Batch( + (actions.restore(record),), + voice.restored(record.title), + voice.action_failed(voice.ACTION_RESTORE), + ) + self.status = voice.NOTHING_CHANGED + return None + + targets = self._targets() + if not targets: + self.panel = None + self.status = voice.NOTHING_CHANGED + return None + + if kind == "actions": + return self._action_enter(key, targets) + if kind == "account": + self.panel = None + self.panel_kind = "" + self.input_text = "" + return self._run(actions.change_account(targets[0], key)) + if kind == "model": + self.panel = None + self.panel_kind = "" + self.input_text = "" + return self._run(actions.change_model(targets[0], key)) + if kind == "color": + self.panel = None + self.panel_kind = "" + self.input_text = "" + return self._run(actions.color(targets[0], key)) + self.panel = None + self.status = voice.NOTHING_CHANGED + return None + + def _new_session_batch(self) -> Batch: + provider, project = self.pending_provider, self.pending_project + self.panel = None + self.panel_kind = "" + self.pending_provider = "" + self.pending_project = "" + plan = actions.new_session(provider, project or None) + return Batch((plan,), plan.success, plan.failure) + + def _action_enter(self, key: str, targets: list[Session]) -> Batch | None: + if key == "close": + plans = tuple(actions.close(session) for session in targets) + self.panel = None + self.panel_kind = "" + self.input_text = "" + success = ( + voice.closed_one(targets[0].title) + if len(targets) == 1 + else voice.closed_many(len(targets)) + ) + return Batch(plans, success, voice.action_failed(voice.ACTION_CLOSE)) + session = targets[0] + if key == "open": + self.panel = None + self.panel_kind = "" + self.input_text = "" + return self._run(actions.open_session(session), session.key) + if key == "history": + self.panel = None + self.panel_kind = "" + self.input_text = "" + return self._run(actions.history(session)) + if key == "rename": + self.panel = Panel( + session.title, (), 0, prompt="New name (↵ back) ❯", entry="", + identity_color=session.display_color, + ) + return None + if key == "account": + aliases = self.accounts or ( + self.account_source(session.provider) if self.account_source else () + ) + if not aliases: + self.panel = None + self.panel_kind = "" + self.status = voice.empty("Accounts") + return None + self.panel_kind = "account" + self.panel = Panel( + "Change to account", tuple(PanelItem(a, a) for a in aliases), 0, + identity_color=session.display_color, + ) + return None + if key == "model": + names = self.models or ( + self.model_source(session.provider) if self.model_source else () + ) + if not names: + self.panel = None + self.panel_kind = "" + self.status = voice.empty("Models") + return None + self.panel_kind = "model" + self.panel = Panel( + voice.ACTION_MODEL, tuple(PanelItem(n, n) for n in names), 0, + identity_color=session.display_color, + ) + return None + if key == "color": + self.panel_kind = "color" + names = actions.colors_for(session.provider) + self.panel = Panel( + voice.ACTION_COLOR, + tuple(PanelItem(n, n, style=f"title:{n}") for n in names), + 0, identity_color=session.display_color, + ) + return None + self.panel = None + self.status = voice.NOTHING_CHANGED + return None + + # ---- results --------------------------------------------------------- + + def action_finished(self, batch: Batch, *, ok: bool, note: str = "") -> None: + """One line, after the fact. Never a question, never silence.""" + + self.panel = None + self.panel_kind = "" + self.panel_targets = () + self.input_text = "" + if ok and batch.seen_key: + self.seen_at[batch.seen_key] = self.clock() + if note: + self.status = note + elif ok: + self.status = batch.success + else: + self.status = batch.failure + + # ---- mouse ----------------------------------------------------------- + + def click(self, line: int, column: int, frame: Frame) -> Batch | None: + key = frame.row_lines.get(line) + if key is None: + return None + if key.startswith("panel:") and self.panel is not None: + wanted = key[len("panel:") :] + for index, item in enumerate(self.panel.items): + if item.key != wanted: + continue + if index == self.panel.index: + return self._panel_enter() + self.panel = replace(self.panel, index=index) + return None + return None + visible = self.visible_rows() + for index, row in enumerate(visible): + if row.key != key: + continue + if column <= 1 and row.markable: + self.toggle_mark(row.number) + return None + if self.cursor_key == key: + return self._activate(row) + self.cursor_key = key + self.cursor_fallback = index + self.pinned = tuple(item.key for item in visible if item.kind == rowmod.SESSION) + self.cursor_moved_at_ms = self.clock() + return None + return None + + def toggle_mark(self, number: int | None) -> None: + if number is None: + return + if self.is_filter: + self.input_text = "" + numbers, _ = rowmod.parse_marks(self.input_text) + chosen = [value for value in numbers if value != number] + if len(chosen) == len(numbers): + chosen.append(number) + self.input_text = ",".join(str(value) for value in chosen) + self.status = self._input_note() diff --git a/lib/sessionkit_tui/voice.py b/lib/sessionkit_tui/voice.py new file mode 100644 index 0000000..e1076bb --- /dev/null +++ b/lib/sessionkit_tui/voice.py @@ -0,0 +1,184 @@ +"""Every string this screen can show a person. + +One file so the words can be read in one sitting and compared against +docs/voice.md, which governs them. Nothing here builds a sentence out of +fragments at the call site: a caller asks for the line it wants and gets the +whole line. +""" + +from __future__ import annotations + +# One grammar on every screen (operator ruling, 2026-08-15): every footer opens by saying +# what Enter does HERE, in this screen's own words, and every screen that can +# take `b` says `b back`. On the session list and the closed list, letters are +# the filter, so those screens name `esc` as the way back instead of stealing +# a letter. +# +# `needs you a` stayed out on purpose (found in review, 2026-08-15): on this +# door every printable key, `a` included, types into the filter box, and the +# derived needs_you field is not in that filter's haystack -- so following the +# hint HID the waiting session it claimed to list. The footer names only keys +# that do what they say. The waiting rows still sort to the top and still say +# "needs you" in their own row. +# Priority order to match the key-driven picker's footer (operator ruling, +# 2026-08-16): a narrow window truncates from the end, so position is +# survival, history is the first luxury, more is a door. esc stays last on +# this surface because it is named nowhere else. +FOOTER = ( + "↵ open · open # · kill k # · new n · more m · " + "help ? · history h # · esc quit" +) + +# The parts of the footer that name a key. Coloured so the eye finds them +# without the footer growing brackets or a glossary. +FOOTER_KEYS = ("↵", "#", "k #", "h #", "n", "m", "?", "esc") + +BACK_FOOTER = "↵ choose · b back · esc back · ctrl-d quit" +HELP_FOOTER = "↵ back · b back · esc back · ctrl-d quit" +CLOSED_FOOTER = "↵ actions · esc back · ctrl-d quit" +# The rename prompt is the one panel where `b` is a name, not a key. +NAME_PROMPT_FOOTER = "↵ save · esc back · ctrl-d quit" + +# The one cancel wording. Nothing else means "nothing happened". +NOTHING_CHANGED = "Nothing changed." +SUBAGENT_STAYS_WITH_PARENT = ( + "Subagent sessions stay with their parent. Nothing changed." +) + +# The one error prefix. Errors go to stderr; screen copy never carries it. +ERROR_PREFIX = "session-kit" + +# Row labels that are not sessions. +NEW_SESSION = "New session" +PROJECTS = "Projects" +CLOSED_SESSIONS = "Closed sessions" +HELP = "Help" + +# The action panel, in the order a person reads it. +ACTION_OPEN = "Open" +ACTION_HISTORY = "History" +ACTION_CLOSE = "Close" +ACTION_RESTORE = "Restore" +ACTION_ACCOUNT = "Change account" +ACTION_MODEL = "Change model" +ACTION_RENAME = "Rename" +ACTION_COLOR = "Color" + +# What an absent field renders as. Never "unknown", never a blank column. +MISSING = "pending" + +PICKER_HELP = "Picker help" + +# The old picker's key table, narrowed only where this cursor-driven screen +# deliberately gives the same task a different key. The stable two-column +# shape matters more than wrapping prose around it. +HELP_ROWS = ( + ("Sessions", "↑ / ↓", "Move the highlight"), + ("Sessions", "Enter", "Open the highlighted session"), + ("Sessions", "number", "Mark a session; commas and ranges mark several"), + ("Sessions", "h", "Read history from the highlighted session's actions"), + ("Sessions", "n", "Choose New session from the list"), + ("Sessions", "k", "Choose Close from the marked sessions' actions"), + ("Sessions", "name", "Choose Rename from a session's actions"), + ("Needs you", "a", "Filter the list as you type"), + ("The list", "text", "Type to filter names, providers, and projects"), + ("The list", "Esc", "Clear typed text, go back, then quit"), + ("The list", "mouse", "Wheel to scroll; click to highlight or open"), + ("The list", "?", "Choose Help from the list"), + ("Going back", "Enter", "Takes the choice under the cursor on any screen"), + ("Going back", "b", "Back, on every screen that is not filtering by name"), + ("Quitting", "Esc", "Quit when there is nothing to clear or go back from"), + ("Quitting", "q", "Quit from the main list"), + ("Quitting", "Ctrl-D", "Quit the picker from anywhere"), +) + +HELP_NOTES = ( + "Nothing on this screen asks you to confirm. An action runs, then says what it did.", + "Inside a session: Ctrl-Q leaves it running, Ctrl-D or bye closes it.", + "History is an action on every session row.", +) + + +def plural(count: int, singular: str, many: str | None = None) -> str: + """`1 session`, `3 sessions`, the count and its noun, never apart.""" + + word = singular if count == 1 else (many or singular + "s") + return f"{count} {word}" + + +# There was a needs_you_line() here that printed "needs you: N · N sessions" +# above the footer. An attention summary above the footer is out, by operator +# ruling (2026-08-15) -- the same ruling that removed the older picker's +# version of it, which had grown a repair-failure part and was announcing fifty +# failed repairs that had never been attempted. Both doors are kept identical +# on purpose: the two of them drifting apart is its own recurring bug in this +# tree. The rows still say "needs you" and still sort to the top; on the older +# picker the `a` screen still lists them, which is NOT true on this door and +# was wrongly claimed here. + + +def machine_row(count: int, needing: int = 0) -> str: + """The counted row machine sessions live behind.""" + + label = plural(count, "machine session") + if needing > 0: + return f"{label} · {needing} need you" if needing != 1 else f"{label} · 1 needs you" + return label + + +def no_such_session(number: int | str) -> str: + """The one refusal grammar: the fact, then the way forward.""" + + return ( + f"There is no session {number} on this screen. Numbers shown here work." + ) + + +def empty(thing: str) -> str: + """The one empty state.""" + + return f"{thing}: none." + + +def closed_one(name: str) -> str: + return f"Closed {name}." + + +def closed_many(count: int) -> str: + return f"Closed {plural(count, 'session')}." + + +def opened(name: str) -> str: + return f"Opened {name}." + + +def restored(name: str) -> str: + return f"Restored {name}." + + +def renamed(name: str) -> str: + return f"Renamed {name}." + + +def account_changed(name: str, alias: str) -> str: + return f"{name} now uses account {alias}." + + +def model_changed(name: str, model: str) -> str: + return f"{name} now runs {model}." + + +def color_changed(name: str, color: str) -> str: + return f"{name} is now {color}." + + +def action_failed(name: str) -> str: + """A refused action changed nothing, and says so in the cancel wording.""" + + return f"{name} was refused. {NOTHING_CHANGED}" + + +def error(fact: str) -> str: + """The stderr line for a picker that cannot run. One prefix, one period.""" + + return f"{ERROR_PREFIX}: {fact.rstrip('.')}." diff --git a/lib/sh/session_kit_checks.sh b/lib/sh/session_kit_checks.sh new file mode 100644 index 0000000..277a0bd --- /dev/null +++ b/lib/sh/session_kit_checks.sh @@ -0,0 +1,566 @@ +#!/usr/bin/env bash +# Session Kit preflight and safety checks. Source this file; do not execute it. +# +# Source order: bin/session-kit sources this module after it assigns the +# lifecycle roots (install_root, bin_dir, config_root, state_root, +# service_root), the shell integration paths, receipt_path, helpers, +# common_required_commands, and inventory_files, and after it defines die(), +# platform(), and require_arg(). These functions also call +# transaction_targets() and current_release_id() from +# lib/sh/session_kit_lifecycle.sh, which only has to be sourced before the +# dispatcher runs. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +# One row, one layout, for every report the kit prints. `session-kit doctor` +# and `session-kit check` are the two reports a person runs when something is +# wrong, so they render the same way and name each check the same way. +render_check_row() { + printf '%-5s %-18s %s\n' "${1^^}" "$2" "$3" +} + +systemd_user_manager_transport() { + if systemctl --user show-environment >/dev/null 2>&1; then + printf '%s\n' direct + return 0 + fi + if systemctl --user --machine=@.host show-environment \ + >/dev/null 2>&1; then + printf '%s\n' machine + return 0 + fi + return 1 +} + +# Report the installed shpool against the release the kit targets. +# +# Every place the kit names a shpool version names 0.11.0: install.sh, the +# macOS install path (which refuses anything else), docs/install.md, +# macos/README.md, extras/build-static-binary.md, and the shpool-patch series, +# which applies to upstream v0.11.0 only. No other release is tested against +# the kit's `shpool list --json` use or the config keys it installs, so an +# older binary is a failure and a newer one an untested warning rather than a +# silent pass. +# +# Prints one "<status>\t<detail>" line; status is ok, warn, or fail. +shpool_version_report() { + python3 - 0.11.0 <<'PY' +import re +import shutil +import subprocess +import sys + +minimum = sys.argv[1] +floor = tuple(int(part) for part in minimum.split(".")) +fix = f"cargo install shpool --version {minimum} --locked" + + +def emit(status: str, detail: str) -> None: + print(status, " ".join(detail.split()), sep="\t") + + +executable = shutil.which("shpool") +if executable is None: + emit("fail", f"shpool is not on PATH; install it with: {fix}") + raise SystemExit(0) +text = "" +for argv in ([executable, "version"], [executable, "--version"]): + try: + done = subprocess.run( + argv, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=5, + ) + except (OSError, subprocess.SubprocessError): + continue + candidate = done.stdout.decode("utf-8", "replace")[:200] + if re.search(r"\d+\.\d+\.\d+", candidate): + text = candidate + break +match = re.search(r"(\d+)\.(\d+)\.(\d+)", text) +if match is None: + emit( + "warn", + f"the installed shpool did not report a version; the kit targets {minimum}: {fix}", + ) + raise SystemExit(0) +found = tuple(int(part) for part in match.groups()) +version = ".".join(str(part) for part in found) +if found < floor: + emit("fail", f"shpool {version} is older than the required {minimum}; upgrade with: {fix}") +elif found > floor: + emit( + "warn", + f"shpool {version} is newer than the tested {minimum}; pin the tested release with: {fix}", + ) +else: + emit("ok", f"shpool {version} matches the tested release") +PY +} + +# Report systemd-logind lingering for the current user. Without it logind +# stops the user manager at the last logout, which takes the shpool daemon and +# every managed session with it, so an install that looks complete still loses +# every session the first time the operator disconnects. +# +# Prints one "<status>\t<detail>" line; status is ok, warn, or fail. +logind_linger_report() { + local user=${USER:-} state= + [[ -n $user ]] || user=$(id -un 2>/dev/null) || user= + if [[ -z $user ]]; then + printf 'warn\t%s\n' \ + "the current user name could not be read, so lingering was not checked" + return 0 + fi + if ! command -v loginctl >/dev/null 2>&1; then + printf 'warn\t%s\n' \ + "loginctl is unavailable, so lingering could not be checked; without it every managed session ends at logout" + return 0 + fi + state=$(loginctl show-user "$user" --property=Linger --value 2>/dev/null) || + state= + state=${state#Linger=} + case $state in + yes) + printf 'ok\t%s\n' \ + "logind lingering is enabled for $user, so managed sessions survive logout" + ;; + no) + printf 'fail\t%s\n' \ + "logind lingering is disabled for $user, so every managed session ends at logout; enable it with: loginctl enable-linger $user" + ;; + *) + printf 'warn\t%s\n' \ + "logind reported no lingering state for $user; check it with: loginctl show-user $user --property=Linger" + ;; + esac +} + +validate_roots() { + python3 - "$HOME" "$install_root" "$bin_dir" "$config_root" "$state_root" \ + "$service_root" "$bashrc_path" "$bash_profile_path" "$zshrc_path" \ + "${SESSION_KIT_TESTING:-0}" <<'PY' +import os +import pathlib +import sys + +home, *raw, testing = sys.argv[1:] +home_path = pathlib.Path(home).resolve(strict=False) +labels = ( + "install root", "binary directory", "config root", "state root", + "service root", "Bash file", "Bash login file", "zsh file", +) +paths = [pathlib.Path(value).resolve(strict=False) for value in raw] +raw_paths = [pathlib.Path(value) for value in raw] +for label, raw_path, path in zip(labels, raw_paths, paths): + if not raw_path.is_absolute(): + raise SystemExit(f"session-kit: {label} must be absolute: {raw_path}") + normalized = pathlib.Path(os.path.abspath(raw_path)) + if normalized != path: + raise SystemExit(f"session-kit: refusing symlinked {label}: {raw_path}") + if path == pathlib.Path("/") or path == home_path: + raise SystemExit(f"session-kit: refusing broad {label}: {path}") + if len(path.parts) < 4 and testing != "1": + raise SystemExit(f"session-kit: refusing broad {label}: {path}") +install, binary, config, state, service, bashrc, bash_profile, zshrc = paths +for label, path in (("install root", install), ("config root", config), ("state root", state)): + if path in { + home_path / ".local", + home_path / ".local" / "lib", + home_path / ".local" / "state", + home_path / ".config", + }: + raise SystemExit(f"session-kit: refusing broad {label}: {path}") +for shell in (bashrc, bash_profile, zshrc): + if shell.parent == pathlib.Path("/") or shell == home_path: + raise SystemExit(f"session-kit: refusing unsafe shell file: {shell}") +if len({bashrc, bash_profile, zshrc}) != 3: + raise SystemExit("session-kit: shell integration files must differ") +if len({install, binary, config, state, service}) != 5: + raise SystemExit("session-kit: lifecycle roots must not overlap exactly") +roots = (install, binary, config, state, service) +for index, left in enumerate(roots): + for right in roots[index + 1:]: + if left in right.parents or right in left.parents: + raise SystemExit(f"session-kit: lifecycle roots must not contain one another: {left}, {right}") +PY +} + +stat_mode() { + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null +} + +release_id_from_source() { + local source=$1 release_id=${SESSION_KIT_RELEASE_ID:-} + if [[ -n $release_id && ${SESSION_KIT_TESTING:-0} != 1 ]]; then + die "SESSION_KIT_RELEASE_ID is reserved for isolated tests" + fi + if [[ -z $release_id ]] && source_is_git_root "$source"; then + release_id=$(git -C "$source" rev-parse HEAD 2>/dev/null || true) + elif [[ -z $release_id ]]; then + release_id=$(source_record_commit "$source") || + die "install source needs an exact Git root or valid SOURCE.json artifact" + fi + [[ $release_id =~ ^[0-9a-f]{40}$ ]] || + die "install source must identify one full 40-character commit" + printf '%s\n' "$release_id" +} + +source_is_git_root() { + local source=$1 top= + command -v git >/dev/null 2>&1 || return 1 + top=$(git -C "$source" rev-parse --show-toplevel 2>/dev/null) || return 1 + [[ $(cd -- "$top" && pwd -P) == "$source" ]] +} + +source_record_commit() { + python3 - "$1/SOURCE.json" <<'PY' +import json +import os +import pathlib +import re +import stat +import sys + +path = pathlib.Path(sys.argv[1]) +try: + info = path.lstat() + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_size > 4096: + raise ValueError("unsafe SOURCE.json") + value = json.loads(path.read_text(encoding="utf-8")) +except (OSError, UnicodeError, ValueError): + raise SystemExit(1) +if ( + not isinstance(value, dict) + or value.get("schema_version") != 1 + or set(value) != {"schema_version", "source_commit", "exported_files"} + or not re.fullmatch(r"[0-9a-f]{40}", str(value.get("source_commit", ""))) + or not isinstance(value.get("exported_files"), int) + or isinstance(value.get("exported_files"), bool) + or value["exported_files"] < 1 +): + raise SystemExit(1) +print(value["source_commit"]) +PY +} + +check_source() { + local source=$1 failed=0 current_platform command required + local -a missing_commands=() missing_files=() + # Same renderer, same check names as `session-kit doctor`. + check_row() { + render_check_row "$1" "$2" "$3" + [[ $1 != fail ]] || failed=1 + } + current_platform=$(platform) + if [[ $current_platform != unsupported ]]; then + check_row ok platform "$current_platform" + else + check_row fail platform "unsupported operating system; Session Kit needs Linux or macOS" + fi + local -a required_commands=("${common_required_commands[@]}") + if [[ $current_platform == linux ]]; then + required_commands+=(flock journalctl sha256sum systemctl) + elif [[ $current_platform == macos ]]; then + required_commands+=(launchctl plutil sw_vers) + fi + for command in "${required_commands[@]}"; do + command -v "$command" >/dev/null 2>&1 || missing_commands+=("$command") + done + if ((${#missing_commands[@]} == 0)); then + check_row ok prerequisites "all required commands are available" + else + check_row fail prerequisites "commands unavailable: ${missing_commands[*]}" + fi + if (( BASH_VERSINFO[0] < 4 )); then + check_row fail bash "Bash 4 or newer is required" + else + check_row ok bash "$BASH_VERSION" + fi + local python_floor=10 python_version= + [[ $current_platform != macos ]] || python_floor=11 + python_version=$(python3 -c 'import sys; print("%d.%d.%d" % sys.version_info[:3])' 2>/dev/null) || + python_version= + if python3 -c "import sys; raise SystemExit(0 if sys.version_info >= (3, $python_floor) else 1)"; then + check_row ok python "${python_version:-available}" + else + check_row fail python "Python 3.$python_floor or newer is required on $current_platform" + fi + local shpool_status= shpool_detail= + IFS=$'\t' read -r shpool_status shpool_detail < <(shpool_version_report) + check_row "$shpool_status" shpool-version "$shpool_detail" + if [[ $current_platform == linux ]]; then + if [[ ! -d /proc && ${SESSION_KIT_TESTING:-0} != 1 ]]; then + check_row fail process "/proc is unavailable" + else + check_row ok process "Linux /proc is available" + fi + local manager_transport= + if [[ ${SESSION_KIT_TESTING:-0} == 1 ]]; then + check_row ok services "systemd user manager is available" + elif manager_transport=$(systemd_user_manager_transport); then + if [[ $manager_transport == direct ]]; then + check_row ok services "systemd user manager is available" + else + check_row warn services "systemd user manager is available through local-machine transport; direct user socket is unavailable" + fi + else + check_row fail services "systemd user manager is unavailable" + fi + # Lingering is a documented install step the installer cannot perform for + # the operator, so preflight reports it without blocking the install. The + # post-install doctor is the gate that fails on it. + local linger_status= linger_detail= + IFS=$'\t' read -r linger_status linger_detail < <(logind_linger_report) + if [[ $linger_status == ok ]]; then + check_row ok linger "$linger_detail" + else + check_row warn linger "$linger_detail" + fi + elif [[ $current_platform == macos ]]; then + if [[ ${SESSION_KIT_TESTING:-0} != 1 ]]; then + local mac_major mac_arch + mac_major=$(sw_vers -productVersion | cut -d. -f1) + mac_arch=$(uname -m) + if [[ $mac_major =~ ^[0-9]+$ && $mac_major -ge 14 ]]; then + check_row ok macos-version "macOS $mac_major" + else + check_row fail macos-version "macOS 14 or newer is required" + fi + if [[ $mac_arch == arm64 || $mac_arch == x86_64 ]]; then + check_row ok architecture "$mac_arch" + else + check_row fail architecture "unsupported Mac architecture: $mac_arch" + fi + fi + if [[ ${SESSION_KIT_TESTING:-0} == 1 ]] || + python3 "$source/lib/session_inventory.py" platform boot-id >/dev/null 2>&1; then + check_row ok process "Darwin native process adapter is available" + else + check_row fail process "Darwin native process adapter is unavailable" + fi + check_row ok services "launchd is available" + fi + if command -v claude >/dev/null 2>&1 || command -v codex >/dev/null 2>&1; then + check_row ok provider "Claude Code or Codex is available" + else + check_row fail provider "no provider is installed; install Claude Code, Codex, or both" + fi + # The theme layout applies its own stricter rule to the Codex home and runs + # at the start of the install transaction. Reporting its refusal here, in its + # own words, keeps it from stopping an install the preflight called + # installable. + local codex_detail= + if codex_detail=$(codex_theme_layout validate 2>&1); then + check_row ok codex-home "the theme layout is owner-controlled" + else + check_row fail codex-home "${codex_detail#session-kit: }" + fi + for required in \ + bin/sp bin/shpool_login bin/shpool_status bin/shpool_reaper \ + bin/codex_resume_here bin/session_kit_common bin/session-kit \ + "${inventory_files[@]}" "${shell_modules[@]}" bashrc/shpool.bashrc \ + deploy/session-kit-launcher config/projects.example.tsv \ + config/session-inventory.example.json config/shpool.example.toml; do + [[ -f $source/$required ]] || missing_files+=("$required") + done + if ((${#missing_files[@]} == 0)); then + check_row ok source-files "every required file is present" + else + for required in "${missing_files[@]}"; do + check_row fail source-files "missing from the source: $required" + done + fi + if source_is_git_root "$source"; then + if [[ ${SESSION_KIT_TESTING:-0} != 1 ]] && + [[ -n $(git -C "$source" status --porcelain --untracked-files=all) ]]; then + check_row fail provenance "the source worktree has uncommitted or untracked files" + else + check_row ok provenance "clean Git root" + fi + elif source_record_commit "$source" >/dev/null 2>&1; then + check_row ok provenance "release artifact" + else + check_row fail provenance "the source has neither an exact Git root nor a valid SOURCE.json" + fi + (( failed == 0 )) || return 1 + validate_roots + release_id_from_source "$source" >/dev/null + check_row ok source "installable" +} + +validate_purge_ownership() { + local kind=$1 root=$2 marker=$3 + [[ -r $receipt_path && -f $receipt_path && ! -L $receipt_path ]] || + die "purge requires a valid Session Kit install receipt" + [[ -r $marker && -f $marker && ! -L $marker ]] || + die "purge requires an ownership marker for $root" + python3 - "$receipt_path" "$marker" "$kind" "$root" <<'PY' +import json +import pathlib +import sys + +receipt_path, marker_path, kind, root_raw = sys.argv[1:] +root = pathlib.Path(root_raw).resolve(strict=False) +try: + receipt = json.loads(pathlib.Path(receipt_path).read_text(encoding="utf-8")) + marker = json.loads(pathlib.Path(marker_path).read_text(encoding="utf-8")) +except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"session-kit: invalid purge ownership evidence: {exc}") +if receipt.get("schema_version") != 2: + raise SystemExit("session-kit: purge requires a current ownership receipt; run update first") +roots = receipt.get("roots") +if not isinstance(roots, dict) or pathlib.Path(str(roots.get(kind, ""))).resolve(strict=False) != root: + raise SystemExit(f"session-kit: receipt does not own {kind} root: {root}") +if ( + marker.get("schema_version") != 1 + or marker.get("owner") != "session-kit" + or marker.get("kind") != kind + or pathlib.Path(str(marker.get("root", ""))).resolve(strict=False) != root +): + raise SystemExit(f"session-kit: ownership marker does not match {kind} root: {root}") +PY +} + +validate_mutable_targets() { + local target resolved + while IFS= read -r target; do + [[ ! -e $target && ! -L $target ]] && continue + if [[ -L $target ]]; then + [[ $target == "$install_root/current" ]] && continue + # Pre-receipt layouts linked helpers into the install root itself. A + # symlink resolving inside $install_root is the kit's own legacy + # launcher; install(1) replaces the link, never its target. + resolved=$(python3 - "$target" <<'PY' +import pathlib,sys +try: + print(pathlib.Path(sys.argv[1]).resolve(strict=True)) +except OSError: + raise SystemExit(1) +PY + ) || resolved="" + [[ -n $resolved && $resolved == "$install_root"/* ]] || + die "refusing symlinked lifecycle target: $target" + continue + fi + [[ -f $target ]] || + die "refusing incompatible lifecycle target: $target" + done < <(transaction_targets) +} + +validate_uninstall_launchers() { + local release_id previous_release= helper target expected matched + release_id=$(current_release_id) + [[ $release_id =~ ^[0-9a-f]{40}$ ]] || + die "uninstall requires a valid active Session Kit release" + verify_release "$install_root/releases/$release_id" "$release_id" + if [[ -r $receipt_path ]]; then + previous_release=$(python3 - "$receipt_path" <<'PY' +import json +import re +import sys +try: + value = json.load(open(sys.argv[1], encoding="utf-8")).get("previous_release", "") +except (OSError, ValueError): + value = "" +print(value if isinstance(value, str) and re.fullmatch(r"[0-9a-f]{40}", value) else "") +PY + ) + fi + if [[ -n $previous_release ]]; then + verify_release "$install_root/releases/$previous_release" "$previous_release" + fi + for helper in "${helpers[@]}"; do + target=$bin_dir/$helper + [[ ! -e $target && ! -L $target ]] && continue + [[ -f $target && ! -L $target ]] || + die "refusing to remove unsafe launcher: $target" + matched=0 + for expected in \ + "$install_root/releases/$release_id/deploy/session-kit-launcher" \ + ${previous_release:+"$install_root/releases/$previous_release/deploy/session-kit-launcher"}; do + [[ -f $expected && ! -L $expected ]] && cmp -s -- "$target" "$expected" && matched=1 + done + (( matched )) || + die "refusing to remove launcher with unowned content: $target" + done + target=$bin_dir/session-kit + if [[ -e $target || -L $target ]]; then + [[ -f $target && ! -L $target ]] || + die "refusing to remove unsafe command: $target" + matched=0 + for expected in \ + "$install_root/releases/$release_id/deploy/session-kit-launcher" \ + ${previous_release:+"$install_root/releases/$previous_release/deploy/session-kit-launcher"}; do + [[ -f $expected && ! -L $expected ]] && cmp -s -- "$target" "$expected" && matched=1 + done + (( matched )) || + die "refusing to remove command with unowned content: $target" + fi +} + +verify_release() { + python3 - "$1" "${2:-}" <<'PY' +import hashlib +import json +import pathlib +import re +import stat +import sys + +root = pathlib.Path(sys.argv[1]) +expected_commit = sys.argv[2] +if not root.is_dir() or root.is_symlink(): + raise SystemExit(f"session-kit: release is not a real directory: {root}") +try: + meta = json.loads((root / "RELEASE.json").read_text(encoding="utf-8")) +except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"session-kit: invalid release metadata: {exc}") +if ( + meta.get("schema_version") != 1 + or meta.get("lifecycle_schema_version") != 2 + or not isinstance(meta.get("commit"), str) +): + raise SystemExit("session-kit: incompatible release metadata") +commit = meta["commit"] +if not re.fullmatch(r"[0-9a-f]{40}", commit) or root.name != commit: + raise SystemExit("session-kit: invalid release identity") +if expected_commit and commit != expected_commit: + raise SystemExit("session-kit: release identity does not match requested commit") +manifest = root / "MANIFEST.sha256" +try: + lines = manifest.read_text(encoding="utf-8").splitlines() +except OSError as exc: + raise SystemExit(f"session-kit: missing release manifest: {exc}") +expected = {} +for number, line in enumerate(lines, 1): + match = re.fullmatch(r"([0-9a-f]{64}) ([^\0]+)", line) + if not match: + raise SystemExit(f"session-kit: malformed release manifest line {number}") + rel = pathlib.PurePosixPath(match.group(2)) + if rel.is_absolute() or ".." in rel.parts or str(rel) in expected: + raise SystemExit("session-kit: unsafe release manifest path") + expected[str(rel)] = match.group(1) +actual = {} +for path in root.rglob("*"): + rel = path.relative_to(root).as_posix() + if path.is_symlink(): + raise SystemExit(f"session-kit: release contains symlink: {rel}") + if path.is_file() and rel != "MANIFEST.sha256": + actual[rel] = hashlib.sha256(path.read_bytes()).hexdigest() + if path.is_dir() and stat.S_IMODE(path.stat().st_mode) & 0o222: + raise SystemExit(f"session-kit: writable immutable release directory: {rel}") +if expected != actual: + differing = sorted(set(expected) ^ set(actual)) + if not differing: + differing = sorted(name for name in expected if expected[name] != actual[name]) + raise SystemExit(f"session-kit: release verification failed: {', '.join(differing[:3])}") +for rel in ("bin/session-kit", "deploy/session-kit-launcher"): + path = root / rel + if not path.is_file() or path.is_symlink() or not stat.S_IMODE(path.stat().st_mode) & 0o111: + raise SystemExit(f"session-kit: release executable is invalid: {rel}") +PY +} diff --git a/lib/sh/session_kit_doctor.sh b/lib/sh/session_kit_doctor.sh new file mode 100644 index 0000000..47bc79a --- /dev/null +++ b/lib/sh/session_kit_doctor.sh @@ -0,0 +1,1949 @@ +#!/usr/bin/env bash +# Session Kit doctor command: the read-only installation report. +# Source this file; do not execute it. +# +# Source order: bin/session-kit sources this module after it assigns the +# lifecycle roots, helpers, common_required_commands, launchd_units, +# launchd_template_root, transaction_path, and integration_marker, and after it +# defines die() and platform(). doctor_command() also calls +# current_release_id(), verify_release(), stat_mode(), +# systemd_user_manager_transport(), and validate_purge_ownership() from the +# checks and lifecycle modules. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +# Run one read-only systemctl user query over whichever transport the manager +# actually answers on. The caller resolves the transport once so a report never +# probes for it per unit. +doctor_systemctl_user() { + local transport=$1 tool=${SESSION_KIT_SYSTEMCTL_CMD:-systemctl} + shift + if [[ $transport == machine ]]; then + "$tool" --user --machine=@.host "$@" + else + "$tool" --user "$@" + fi +} + +# Which release a live process is actually executing. Bash keeps the script it +# exec'd open on descriptor 255, and that descriptor resolves through the +# `current` symlink to the release directory as it was at exec time -- which is +# exactly the question `current` itself can no longer answer. The exported +# release directory is the fallback for a process whose descriptors are not +# readable. +doctor_running_release() { + local pid=$1 path= + [[ $pid =~ ^[0-9]+$ && $pid -gt 0 ]] || return 1 + path=$(readlink "/proc/$pid/fd/255" 2>/dev/null) || path= + if [[ $path != */releases/* ]]; then + path=$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | + sed -n 's|^SESSION_KIT_RELEASE_DIR=||p' | head -n 1) || path= + fi + [[ $path == */releases/* ]] || return 1 + path=${path#*/releases/} + path=${path%%/*} + [[ $path =~ ^[0-9a-f]{40}$ ]] || return 1 + printf '%s\n' "$path" +} + +# Report what systemd says about the Session Kit units, not what the file +# system says. `session-kit services enable` installs unit files for every +# unit, so a file-exists check passes on a machine where the watchdog has +# never run and broken sessions are never restored. +# +# Called from doctor_command(), which owns add_check(), the checks array, and +# release_id. +# shellcheck disable=SC2154 +doctor_linux_service_truth() { + local transport=$1 unit= probe=1 show= key= value= + local file_state= active_state= detail= summary= degraded=0 + local watchdog=session-kit-watchdog.service + local fix="session-kit services enable" + # Live state is only read when the user manager answers on its own socket. + # A manager reachable only through the local-machine transport is already + # degraded, and logind usually has no session record for the user in that + # state, so both readings would be guesses. Test installs point $service_root + # at a fixture directory the real manager has never read, so probing there + # would report this developer machine instead of the fixture. + if [[ -n ${SESSION_KIT_SYSTEMCTL_CMD:-} ]]; then + probe=1 + elif [[ ${SESSION_KIT_TESTING:-0} == 1 || $transport != direct ]]; then + probe=0 + fi + read_unit_state() { + file_state= + active_state= + show=$(doctor_systemctl_user "$transport" show \ + --property=UnitFileState --property=ActiveState -- "$1" 2>/dev/null) || + show= + [[ -n $show ]] || return 1 + while IFS='=' read -r key value; do + case $key in + UnitFileState) file_state=$value ;; + ActiveState) active_state=$value ;; + esac + done <<<"$show" + return 0 + } + if [[ ! -f $service_root/$watchdog || -L $service_root/$watchdog ]]; then + add_check fail watchdog \ + "unit file is missing from $service_root; reinstall the units with: session-kit update" + elif (( probe == 0 )); then + add_check warn watchdog \ + "unit file is installed; the live unit state was not probed here" + elif ! read_unit_state "$watchdog"; then + add_check warn watchdog \ + "unit file is installed; systemd did not answer a state query, so it could not be confirmed running" + elif [[ $file_state != enabled ]]; then + add_check fail watchdog \ + "installed but not enabled (${file_state:-unknown}), so broken sessions are never restored; enable it with: $fix" + elif [[ $active_state != active ]]; then + add_check fail watchdog \ + "enabled but not running ($active_state), so broken sessions are never restored; start it with: $fix" + else + add_check ok watchdog "enabled and running" + fi + # The socket and the timers carry the daemon, the reaper, and the sub-agent + # sweep. The units they activate are reported for context and never required + # to be running. The sweep timer is checked like the others because the + # fifteen-minute close rule is only true while it runs: a timer that quietly + # never got enabled turns that rule back into the hourly one. + for unit in shpool.socket shpool-reaper.timer session-kit-subagent-sweep.timer \ + shpool.service shpool-reaper.service session-kit-subagent-sweep.service; do + if [[ ! -f $service_root/$unit || -L $service_root/$unit ]]; then + summary+="${summary:+; }$unit missing from $service_root" + degraded=2 + continue + fi + if (( probe == 0 )) || ! read_unit_state "$unit"; then + summary+="${summary:+; }$unit installed, state unread" + (( degraded >= 1 )) || degraded=1 + continue + fi + detail="$unit ${file_state:-unknown}/${active_state:-unknown}" + case $unit in + shpool.service) detail+=" (socket-activated)" ;; + shpool-reaper.service) detail+=" (timer-activated)" ;; + session-kit-subagent-sweep.service) detail+=" (timer-activated)" ;; + *) + if [[ $file_state != enabled || $active_state != active ]]; then + degraded=2 + fi + ;; + esac + summary+="${summary:+; }$detail" + done + case $degraded in + 0) add_check ok units "$summary" ;; + 1) add_check warn units "$summary" ;; + *) add_check fail units "$summary; enable the units with: $fix" ;; + esac + # Is what is running what we shipped? Everything above reports on FILES: the + # unit exists, it is enabled, something is active. None of that can see a + # process still executing the release it started with, or a systemd that has + # not read the unit files this install wrote. Ten activations passed over one + # watchdog process while every check above stayed green, so the fix it shipped + # ran nowhere. These two checks are the question those cannot ask. + local shown= reload= main_pid= running= + if (( probe == 0 )); then + add_check warn release-running \ + "the running units were not compared with the installed release here" + else + shown=$(doctor_systemctl_user "$transport" show \ + --property=NeedDaemonReload --property=MainPID -- "$watchdog" 2>/dev/null) || + shown= + reload= + main_pid= + while IFS='=' read -r key value; do + case $key in + NeedDaemonReload) reload=$value ;; + MainPID) main_pid=$value ;; + esac + done <<<"$shown" + if [[ $reload == yes ]]; then + add_check fail units-loaded \ + "systemd is serving unit definitions this release replaced; load them with: systemctl --user daemon-reload" + elif [[ -z $reload ]]; then + add_check warn units-loaded \ + "systemd did not answer whether the unit files on disk have been loaded" + else + add_check ok units-loaded "systemd holds the installed unit definitions" + fi + if [[ ! $main_pid =~ ^[0-9]+$ ]] || (( main_pid == 0 )); then + add_check warn release-running \ + "the watchdog is not running, so there is nothing to compare with the installed release" + elif ! running=$(doctor_running_release "$main_pid"); then + add_check warn release-running \ + "the running watchdog's release could not be read from the process table" + elif [[ $running != "$release_id" ]]; then + add_check fail release-running \ + "the watchdog is running release ${running:0:12}, not the installed ${release_id:0:12}, so every fix since then is inert; restart it with: systemctl --user restart $watchdog" + else + add_check ok release-running "the watchdog is running the installed release" + fi + fi + local linger_status= linger_detail= + if (( probe == 0 )); then + add_check warn linger \ + "lingering was not checked here; without it logind stops the user manager at logout and every managed session ends with it" + else + IFS=$'\t' read -r linger_status linger_detail < <(logind_linger_report) + add_check "$linger_status" linger "$linger_detail" + fi +} + +# Can every session this machine recorded still be read by the tools that read +# transcripts? The measured defect this answers: a live session running under a +# rotated account profile had its transcript on this disk and the tool asking +# for it reported none, because one resolver knew a root the other did not. +# Nothing warned; the blindness was visible only to whoever happened to ask +# that session for its history. +# +# The resolver is the kit's own, so this check goes blind only if the resolver +# does -- and then it says so. Read-only: the recorded list is read, never +# built. +# +# Prints one "status<TAB>detail" line. Called from doctor_command(). +doctor_transcript_report() { + local core=$install_root/current/lib/session_inventory.py + if [[ ! -f $core || -L $core ]]; then + printf 'warn\t%s\n' \ + "transcript reachability was not checked: the release library is missing at $core" + return 0 + fi + local report= + report=$(SESSION_KIT_STATE_DIR=$state_root python3 "$core" transcript reachability 2>/dev/null) || { + printf 'warn\ttranscript reachability could not be checked\n' + return 0 + } + printf '%s' "$report" | python3 -c ' +import json +import sys + +try: + value = json.load(sys.stdin) + status = value["status"] + detail = " ".join(str(value["detail"]).replace("\t", " ").split())[:500] +except (ValueError, KeyError, TypeError): + print("warn\ttranscript reachability could not be read") +else: + print(f"{status}\t{detail}") +' || printf 'warn\ttranscript reachability could not be read\n' +} + +doctor_command() { + local json=0 + [[ ${1:-} != --json ]] || { json=1; shift; } + (($# == 0)) || die "unknown doctor option: $1" + local current_platform release_id= failed=0 helper required row status name detail + current_platform=$(platform) + release_id=$(current_release_id) + local -a checks=() + add_check() { + checks+=("$1"$'\t'"$2"$'\t'"$3") + [[ $1 != fail ]] || failed=1 + } + if [[ $current_platform != unsupported ]]; then + add_check ok platform "$current_platform" + else + add_check fail platform "unsupported operating system; Session Kit needs Linux or macOS" + fi + if [[ $release_id =~ ^[0-9a-f]{40}$ && + -d $install_root/releases/$release_id ]]; then + local release_error= + if release_error=$(verify_release "$install_root/releases/$release_id" "$release_id" 2>&1); then + add_check ok release "$release_id" + else + add_check fail release "${release_error#session-kit: }" + fi + else + add_check fail release "no valid active release" + fi + local manager_id= manager_dir= manager_error= + if [[ -L $manager_link ]] && + manager_dir=$(cd -P -- "$manager_link" 2>/dev/null && pwd) && + manager_id=${manager_dir##*/} && + [[ $manager_id =~ ^[0-9a-f]{40}$ ]] && + [[ $manager_dir == "$install_root/releases/$manager_id" ]] && + manager_error=$(verify_release "$manager_dir" "$manager_id" 2>&1); then + add_check ok manager "$manager_id" + if [[ -f $bin_dir/session-kit && ! -L $bin_dir/session-kit && + -f $manager_dir/deploy/session-kit-launcher && + ! -L $manager_dir/deploy/session-kit-launcher ]] && + cmp -s -- "$bin_dir/session-kit" "$manager_dir/deploy/session-kit-launcher"; then + add_check ok manager-launcher "stable launcher matches the management release" + else + add_check fail manager-launcher "stable launcher does not match the management release" + fi + else + add_check fail manager "management release anchor is missing, unsafe, or invalid${manager_error:+: ${manager_error#session-kit: }}" + add_check fail manager-launcher "management release could not be checked" + fi + local -a required_commands=("${common_required_commands[@]}") missing_commands=() + if [[ $current_platform == linux ]]; then + required_commands+=(flock journalctl sha256sum systemctl) + elif [[ $current_platform == macos ]]; then + required_commands+=(launchctl plutil sw_vers) + fi + for required in "${required_commands[@]}"; do + command -v "$required" >/dev/null 2>&1 || missing_commands+=("$required") + done + if ((${#missing_commands[@]} == 0)); then + add_check ok prerequisites "all required commands are available" + else + add_check fail prerequisites "commands unavailable: ${missing_commands[*]}" + fi + local shpool_status= shpool_detail= + IFS=$'\t' read -r shpool_status shpool_detail < <(shpool_version_report) + add_check "$shpool_status" shpool-version "$shpool_detail" + if [[ $current_platform == linux ]]; then + if [[ -d /proc || ${SESSION_KIT_TESTING:-0} == 1 ]]; then + add_check ok process "Linux /proc is available" + else + add_check fail process "/proc is unavailable" + fi + local manager_transport= + if [[ ${SESSION_KIT_TESTING:-0} == 1 ]]; then + add_check ok services "systemd user manager is available" + elif manager_transport=$(systemd_user_manager_transport); then + if [[ $manager_transport == direct ]]; then + add_check ok services "systemd user manager is available" + else + add_check warn services "systemd user manager is available through local-machine transport; direct user socket is unavailable" + fi + else + add_check fail services "systemd user manager is unavailable" + fi + doctor_linux_service_truth "$manager_transport" + elif [[ $current_platform == macos ]]; then + if [[ ${SESSION_KIT_TESTING:-0} == 1 ]] || + python3 "$install_root/current/lib/session_inventory.py" platform boot-id >/dev/null 2>&1; then + add_check ok process "Darwin native process adapter is available" + else + add_check fail process "Darwin native process adapter is unavailable" + fi + local loaded=0 unit label + for unit in "${launchd_units[@]}"; do + [[ -f $launchd_template_root/$unit && ! -L $launchd_template_root/$unit ]] || { + add_check fail services "missing LaunchAgent: $unit" + continue + } + plutil -lint "$launchd_template_root/$unit" >/dev/null 2>&1 || { + add_check fail services "invalid LaunchAgent: $unit" + continue + } + label=${unit%.plist} + launchctl print "gui/$UID/$label" >/dev/null 2>&1 && loaded=$((loaded + 1)) + done + if (( loaded == ${#launchd_units[@]} )); then + add_check ok services "all Session Kit LaunchAgents are loaded" + elif (( loaded == 0 )); then + add_check warn services "LaunchAgents are installed but unloaded" + else + add_check warn services "$loaded of ${#launchd_units[@]} LaunchAgents are loaded" + fi + fi + if command -v claude >/dev/null 2>&1 || command -v codex >/dev/null 2>&1; then + add_check ok provider "Claude Code or Codex is available" + else + add_check fail provider "no provider is installed; install Claude Code, Codex, or both" + fi + local transcript_status= transcript_detail= transcript_line= + # Tab is IFS whitespace: an empty first field would collapse and put the + # detail in the status. Translate to \034 before splitting (the husk rule). + IFS= read -r transcript_line < <(doctor_transcript_report) + transcript_line=${transcript_line//$'\t'/$'\034'} + IFS=$'\034' read -r transcript_status transcript_detail <<<"$transcript_line" + add_check "${transcript_status:-warn}" transcripts \ + "${transcript_detail:-transcript reachability could not be checked}" + local project_dir_status= project_dir_detail= project_dir_line= project_dir_helper + project_dir_helper=$install_root/current/lib/sessionkit_inventory/project_dir_name.py + if [[ -f $project_dir_helper && ! -L $project_dir_helper ]]; then + IFS= read -r project_dir_line < <(python3 "$project_dir_helper" --doctor 2>/dev/null) + project_dir_line=${project_dir_line//$'\t'/$'\034'} + IFS=$'\034' read -r project_dir_status project_dir_detail <<<"$project_dir_line" + fi + add_check "${project_dir_status:-warn}" project-dir-names \ + "${project_dir_detail:-project directory naming could not be checked}" + local audit_output audit_ok=1 audit_name + local -a audit_names=( + claude-version codex-version codex-themes naming-instructions rules-parity + naming-hook + statusline claude-integration-ledger claude-statusline-backups + tab-title codex-titles attention-hook internal-formats + kill-switches subagent-sweep acceptance shpool-binary + ) + declare -A audit_expected=() audit_seen=() + for audit_name in "${audit_names[@]}"; do audit_expected[$audit_name]=1; done + if audit_output=$(python3 - "$HOME" "$config_root" \ + "${SESSION_KIT_CODEX_HOME:-${CODEX_HOME:-$HOME/.codex}}" \ + "$release_id" "$current_platform" "$install_root" <<'PY' +import datetime +import hashlib +import json +import os +from pathlib import Path +import re +import select +import shutil +import signal +import stat +import subprocess +import sys +import tempfile +import time + +home, config_root, codex_home = map(Path, sys.argv[1:4]) +active_release, active_platform = sys.argv[4:6] +install_root = Path(sys.argv[6]) + + +def emit(status: str, name: str, detail: str) -> None: + safe = " ".join(str(detail).replace("\t", " ").split())[:500] + print(status, name, safe, sep="\t") + + +def terminate_group(child: subprocess.Popen[bytes]) -> bool: + cleanup_failed = False + try: + os.killpg(child.pid, signal.SIGTERM) + except ProcessLookupError: + pass + except OSError: + cleanup_failed = True + try: + child.wait(timeout=0.25) + except subprocess.TimeoutExpired: + pass + except OSError: + cleanup_failed = True + time.sleep(0.05) + try: + os.killpg(child.pid, signal.SIGKILL) + except ProcessLookupError: + pass + except OSError: + cleanup_failed = True + if child.poll() is None: + try: + child.wait(timeout=0.5) + except (subprocess.TimeoutExpired, OSError): + cleanup_failed = True + return not cleanup_failed + + +def provider_version(command: str) -> tuple[str, str]: + executable = shutil.which(command) + if not executable: + return "ok", f"{command} is not installed" + try: + child = subprocess.Popen( + [executable, "--version"], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + except OSError: + return "warn", f"{command} version could not be read" + payload = bytearray() + deadline = time.monotonic() + 3.0 + overflow = timed_out = read_failed = False + cleanup_ok = True + try: + assert child.stdout is not None + descriptor = child.stdout.fileno() + while time.monotonic() < deadline: + ready, _, _ = select.select([descriptor], [], [], 0.1) + if ready: + block = os.read(descriptor, 257 - len(payload)) + if not block: + break + payload.extend(block) + if len(payload) > 256: + overflow = True + break + elif child.poll() is not None: + break + else: + timed_out = True + except (OSError, ValueError): + read_failed = True + finally: + cleanup_ok = terminate_group(child) + if child.stdout is not None: + child.stdout.close() + if not cleanup_ok: + return "warn", f"{command} version process could not be reaped" + if read_failed: + return "warn", f"{command} version could not be read" + if timed_out: + return "warn", f"{command} --version exceeded 3 seconds" + if overflow: + return "warn", f"{command} --version exceeded 256 bytes" + try: + text = " ".join(payload.decode("utf-8", "strict").split()) + except UnicodeError: + return "warn", f"{command} reported an unrecognized version" + if child.returncode != 0 or not text: + return "warn", f"{command} version was unavailable" + patterns = { + "claude": r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+._A-Za-z0-9]*)? \(Claude Code\)$", + "codex": r"^codex-cli [0-9]+\.[0-9]+\.[0-9]+(?:[-+._A-Za-z0-9]*)?$", + } + if re.fullmatch(patterns[command], text) is None: + return "warn", f"{command} reported an unrecognized version" + return "ok", text + + +provider_observed = {} +provider_ready = {} +for provider in ("claude", "codex"): + installed = shutil.which(provider) is not None + status, detail = provider_version(provider) + emit(status, f"{provider}-version", detail) + provider_ready[provider] = not installed or status == "ok" + provider_observed[provider] = detail if installed and status == "ok" else None + +theme_names = ( + "red", "blue", "green", "yellow", "purple", "orange", "pink", "cyan", + "lime", "magenta", "silver", "sand", "sky", "sea", +) +def secure_regular_bytes( + path: Path, + limit: int, + *, + exact_mode: int | None = None, + executable: bool = False, +) -> bytes | None: + flags = os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW + try: + descriptor = os.open(path, flags) + except OSError: + return None + try: + before = os.fstat(descriptor) + mode = stat.S_IMODE(before.st_mode) + if ( + not stat.S_ISREG(before.st_mode) + or before.st_uid != os.geteuid() + or before.st_nlink != 1 + or before.st_size > limit + or (exact_mode is not None and mode != exact_mode) + or (executable and mode & 0o111 == 0) + ): + return None + current = os.lstat(path) + if (current.st_dev, current.st_ino) != (before.st_dev, before.st_ino): + return None + payload = bytearray() + while len(payload) <= limit: + block = os.read(descriptor, min(65_536, limit + 1 - len(payload))) + if not block: + break + payload.extend(block) + after = os.fstat(descriptor) + if ( + len(payload) > limit + or (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns) + != (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) + ): + return None + return bytes(payload) + except OSError: + return None + finally: + os.close(descriptor) + + +def bounded_text(path: Path, limit: int = 1_048_576) -> str | None: + payload = secure_regular_bytes(path, limit) + if payload is None: + return None + try: + return payload.decode("utf-8", "strict") + except UnicodeError: + return None + + +def safe_directory_chain(path: Path, *, final_owner: bool = False) -> bool: + if not path.is_absolute() or ".." in path.parts: + return False + try: + relative = path.relative_to(home) + except ValueError: + current = Path(path.anchor) + relative = path.relative_to(current) + else: + current = home + components = [current] + for part in relative.parts: + current = current / part + components.append(current) + try: + for index, component in enumerate(components): + info = component.lstat() + if ( + not stat.S_ISDIR(info.st_mode) + or stat.S_ISLNK(info.st_mode) + or info.st_uid not in {0, os.geteuid()} + or stat.S_IMODE(info.st_mode) & 0o022 + or (index == len(components) - 1 and final_owner and info.st_uid != os.geteuid()) + ): + return False + return True + except OSError: + return False + + +# The watchdog compares the running shpool daemon against a fingerprint +# recorded in private state. Two states leave that check present but no longer +# protecting anything, and both are silent: an absent fingerprint skips the +# check entirely, and a stale one reports a change on every pass until the +# report stops being read. Report each distinctly rather than as one failure. +state_root = Path(os.environ.get("XDG_STATE_HOME") or (home / ".local/state")) +kit_state_root = Path( + os.environ.get("SESSION_KIT_STATE_DIR") or (state_root / "session-kit") +) +fingerprint_bytes = secure_regular_bytes( + state_root / "session-kit" / "shpool-binary.sha256", 4096 +) +recorded_fingerprint = None +if fingerprint_bytes is not None: + candidate = fingerprint_bytes.decode("utf-8", "replace").strip() + if re.fullmatch(r"[0-9a-f]{64}", candidate): + recorded_fingerprint = candidate +if fingerprint_bytes is None: + emit( + "warn", + "shpool-binary", + "no fingerprint recorded, so the watchdog binary check is inactive", + ) +elif recorded_fingerprint is None: + emit( + "warn", + "shpool-binary", + "recorded fingerprint is not a sha256 digest, so the check is inactive", + ) +else: + shpool_path = shutil.which("shpool") + if not shpool_path: + emit("warn", "shpool-binary", "shpool is not on PATH, so it cannot be compared") + else: + installed_bytes = secure_regular_bytes(Path(shpool_path), 512 * 1_048_576) + if installed_bytes is None: + emit("warn", "shpool-binary", "installed shpool binary could not be read") + elif hashlib.sha256(installed_bytes).hexdigest() == recorded_fingerprint: + emit("ok", "shpool-binary", "recorded fingerprint matches the installed shpool") + else: + emit( + "warn", + "shpool-binary", + "recorded fingerprint no longer matches " + + shpool_path + + "; re-record it after a deliberate rebuild", + ) + +theme_errors = [] +codex_root_safe = safe_directory_chain(codex_home, final_owner=True) +themes_root = codex_home / "themes" +themes_layout_safe = codex_root_safe and safe_directory_chain( + themes_root, final_owner=True +) +for color in theme_names: + path = themes_root / f"sk-{color}.tmTheme" + if not themes_layout_safe or secure_regular_bytes( + path, 1_048_576, exact_mode=0o600 + ) is None: + theme_errors.append(color) +if theme_errors: + emit("warn", "codex-themes", "missing or unsafe themes: " + ", ".join(theme_errors)) +else: + emit( + "ok", + "codex-themes", + f"all {len(theme_names)} themes are private regular readable files", + ) + +instruction_errors = [] +for label, path in ( + ("Codex", codex_home / "AGENTS.md"), + ("Claude", home / ".claude" / "CLAUDE.md"), +): + text = None if label == "Codex" and not codex_root_safe else bounded_text(path) + if text is None or "sp self-name" not in text: + instruction_errors.append(label) +if instruction_errors: + emit("warn", "naming-instructions", "self-name instruction missing for: " + ", ".join(instruction_errors)) +else: + emit("ok", "naming-instructions", "Codex and Claude self-name instructions are present") + +# One rulebook per provider per profile drifts apart silently: each copy lives +# in its own config root, so an instruction added in one place is simply absent +# everywhere else. The rendered block carries a short digest of the rules file it +# came from, which is all this needs to compare - no registry, no imports. +rules_file = os.environ.get("SESSION_KIT_RULES_FILE", "").strip() +if rules_file: + rules_path = Path(rules_file).expanduser() +else: + config_home = os.environ.get("XDG_CONFIG_HOME", "").strip() + rules_base = Path(config_home).expanduser() if config_home else home / ".config" + rules_path = rules_base / "agent-rules" / "universal-rules.md" +rules_body = bounded_text(rules_path) +if rules_body is None: + emit( + "ok", + "rules-parity", + "no shared rules file is configured; each rulebook stands on its own", + ) +else: + want = hashlib.sha256(rules_body.encode("utf-8")).hexdigest()[:12] + data_home = Path(os.environ.get("XDG_DATA_HOME") or (home / ".local/share")) + profiles_root = data_home / "session-kit" / "accounts" + rulebooks = [ + ("codex:default", codex_home / "AGENTS.md"), + ("claude:default", home / ".claude" / "CLAUDE.md"), + ] + for provider, filename in (("claude", "CLAUDE.md"), ("codex", "AGENTS.md")): + provider_root = profiles_root / provider + if not provider_root.is_dir(): + continue + for profile in sorted(provider_root.iterdir()): + if profile.is_dir(): + rulebooks.append((f"{provider}:{profile.name}", profile / filename)) + stale = [] + for label, path in rulebooks: + text = bounded_text(path) + if text is None: + continue + marker = re.search(r"<!-- BEGIN UNIVERSAL RULES.*?-->", text, re.DOTALL) + if marker is None or f"sha256:{want}" not in marker.group(0): + stale.append(label) + if stale: + emit( + "warn", + "rules-parity", + "rulebook(s) out of date, run `sp account sync-rules`: " + ", ".join(stale), + ) + else: + emit( + "ok", + "rules-parity", + f"all {len(rulebooks)} rulebooks carry the current rules", + ) + +hook_errors = [] +statusline_errors = [] +claude_root = Path(os.environ.get("SESSION_KIT_CLAUDE_HOME") or (home / ".claude")) +hook_path = claude_root / "hooks" / "nameintent_title.sh" +statusline_path = claude_root / "statusline.sh" +hook_payload = secure_regular_bytes( + hook_path, 1_048_576, executable=True, exact_mode=0o700 +) +if hook_payload is None: + hook_errors.append("hook file") +statusline_payload = secure_regular_bytes( + statusline_path, 1_048_576, executable=True, exact_mode=0o700 +) +if statusline_payload is None: + statusline_errors.append("status line file") +release_claude = install_root / "releases" / active_release / "config" / "claude" +ledger_path = kit_state_root / "claude-integration.json" +ledger_info = None +ledger_exists = False +ledger_reason = None +try: + ledger_info = ledger_path.lstat() + ledger_exists = True +except FileNotFoundError: + pass +except OSError as exc: + ledger_exists = True + ledger_reason = f"could not be inspected: {exc.strerror or exc}" +ledger_bytes = secure_regular_bytes(ledger_path, 1_048_576) +ledger_files = None +if ledger_exists and ledger_bytes is None: + if ledger_reason is None and ledger_info is not None: + if stat.S_ISLNK(ledger_info.st_mode): + ledger_reason = "is a symbolic link" + elif not stat.S_ISREG(ledger_info.st_mode): + ledger_reason = "is not a regular file" + elif ledger_info.st_uid != os.geteuid(): + ledger_reason = "is not owned by the current user" + elif ledger_info.st_nlink != 1: + ledger_reason = "has multiple hard links" + elif ledger_info.st_size > 1_048_576: + ledger_reason = "is larger than 1 MiB" + else: + ledger_reason = "could not be read safely" +elif ledger_bytes is not None: + try: + ledger = json.loads(ledger_bytes.decode("utf-8", "strict")) + files = ledger.get("files") if isinstance(ledger, dict) else None + if ( + ledger.get("schema_version") != 1 + or not isinstance(ledger.get("release_id"), str) + or re.fullmatch(r"[0-9a-f]{40}", ledger["release_id"]) is None + or not isinstance(files, dict) + or set(files) != {str(hook_path), str(statusline_path)} + or any( + not isinstance(digest, str) + or re.fullmatch(r"[0-9a-f]{64}", digest) is None + for digest in files.values() + ) + ): + raise ValueError + ledger_files = files + except (UnicodeError, ValueError, AttributeError): + ledger_reason = "contains invalid data" + +if ledger_files is not None: + emit("ok", "claude-integration-ledger", "integration ledger is valid") +elif ledger_exists: + emit( + "warn", + "claude-integration-ledger", + "existing integration ledger " + (ledger_reason or "could not be used safely"), + ) + hook_errors.append("integration ledger") + statusline_errors.append("integration ledger") +else: + emit( + "ok", + "claude-integration-ledger", + "no integration ledger; using pre-ledger compatibility checks", + ) + +backups_path = kit_state_root / "claude-statusline-backups.json" +backups_info = None +backups_exists = False +backups_reason = None +try: + backups_info = backups_path.lstat() + backups_exists = True +except FileNotFoundError: + pass +except OSError as exc: + backups_exists = True + backups_reason = f"could not be inspected: {exc.strerror or exc}" +backups_bytes = secure_regular_bytes(backups_path, 1_048_576) +backups_valid = False +if backups_exists and backups_bytes is None: + if backups_reason is None and backups_info is not None: + if stat.S_ISLNK(backups_info.st_mode): + backups_reason = "is a symbolic link" + elif not stat.S_ISREG(backups_info.st_mode): + backups_reason = "is not a regular file" + elif backups_info.st_uid != os.geteuid(): + backups_reason = "is not owned by the current user" + elif backups_info.st_nlink != 1: + backups_reason = "has multiple hard links" + elif backups_info.st_size > 1_048_576: + backups_reason = "is larger than 1 MiB" + else: + backups_reason = "could not be read safely" +elif backups_bytes is not None: + try: + backups = json.loads(backups_bytes.decode("utf-8", "strict")) + entries = backups.get("entries") if isinstance(backups, dict) else None + if ( + not isinstance(backups, dict) + or backups.get("schema_version") != 1 + or not isinstance(entries, dict) + or any( + not isinstance(key, str) + or not Path(key).is_absolute() + or not isinstance(record, dict) + or not isinstance(record.get("present"), bool) + or set(record) - {"present", "value"} + or (record["present"] and "value" not in record) + for key, record in entries.items() + ) + ): + raise ValueError + backups_valid = True + except (UnicodeError, ValueError, AttributeError): + backups_reason = "contains invalid data" + +if backups_valid: + emit("ok", "claude-statusline-backups", "status-line backup ledger is valid") +elif backups_exists: + emit( + "warn", + "claude-statusline-backups", + "existing status-line backup ledger " + + (backups_reason or "could not be used safely"), + ) +else: + emit( + "ok", + "claude-statusline-backups", + "no status-line backup ledger; nothing is pending for restoration", + ) + +if ledger_files is not None: + if hook_payload is not None and ( + hashlib.sha256(hook_payload).hexdigest() != ledger_files[str(hook_path)] + ): + hook_errors.append("hook content") + if statusline_payload is not None and ( + hashlib.sha256(statusline_payload).hexdigest() + != ledger_files[str(statusline_path)] + ): + statusline_errors.append("status line content") +elif not ledger_exists: + # Compatibility with installations that predate the integration ledger. + expected_hook = secure_regular_bytes( + release_claude / "nameintent_title.sh", 1_048_576 + ) + expected_statusline = secure_regular_bytes( + release_claude / "statusline.sh", 1_048_576 + ) + if hook_payload is not None and ( + expected_hook is None or hook_payload != expected_hook + ): + hook_errors.append("hook content") + if statusline_payload is not None and ( + expected_statusline is None or statusline_payload != expected_statusline + ): + statusline_errors.append("status line content") + +if claude_root == home / ".claude": + expected_hook_command = "~/.claude/hooks/nameintent_title.sh" + expected_status_command = "~/.claude/statusline.sh" +else: + expected_hook_command = str(hook_path) + expected_status_command = str(statusline_path) + +settings_paths = [("default", claude_root / "settings.json")] +accounts_root = Path(os.environ.get("XDG_DATA_HOME") or (home / ".local/share")) / "session-kit" / "accounts" / "claude" +try: + account_entries = sorted(accounts_root.iterdir(), key=lambda path: path.name) +except OSError: + account_entries = [] +for account_path in account_entries: + if not re.fullmatch(r"[A-Za-z0-9._-]+", account_path.name): + continue + try: + account_info = account_path.lstat() + except OSError: + continue + if ( + not stat.S_ISDIR(account_info.st_mode) + or stat.S_ISLNK(account_info.st_mode) + or account_info.st_uid != os.geteuid() + or stat.S_IMODE(account_info.st_mode) & 0o002 + ): + hook_errors.append(f"{account_path.name} unsafe profile") + statusline_errors.append(f"{account_path.name} unsafe profile") + continue + settings_paths.append((account_path.name, account_path / "settings.json")) + +for profile, settings_path in settings_paths: + settings_text = bounded_text(settings_path) + try: + settings = json.loads(settings_text or "") + except ValueError: + settings = None + hooks = settings.get("hooks") if isinstance(settings, dict) else None + for event in ("SessionStart", "UserPromptSubmit", "Stop"): + entries = hooks.get(event) if isinstance(hooks, dict) else None + covered = False + if isinstance(entries, list): + for group in entries: + if not isinstance(group, dict) or not isinstance(group.get("hooks"), list): + continue + covered = covered or any( + isinstance(hook, dict) + and hook.get("type") == "command" + and hook.get("command") == expected_hook_command + for hook in group["hooks"] + ) + if not covered: + hook_errors.append(f"{profile} {event}") + expected_registration = { + "type": "command", + "command": expected_status_command, + "refreshInterval": 2, + } + if not isinstance(settings, dict) or settings.get("statusLine") != expected_registration: + statusline_errors.append(f"{profile} registration") + + +def hook_fixture_ok() -> bool: + if hook_payload is None: + return False + fixture_input = json.dumps( + {"session_id": "doctor-fixture", "hook_event_name": "SessionStart"} + ).encode() + with tempfile.TemporaryDirectory(prefix="session-kit-hook-doctor.") as temporary: + fixture_home = Path(temporary) + sessions = fixture_home / ".claude" / "sessions" + sessions.mkdir(parents=True, mode=0o700) + (sessions / "doctor-fixture.nameintent").write_text( + "Session Kit Doctor Fixture\n", encoding="utf-8" + ) + child = None + payload = bytearray() + failed = False + try: + child = subprocess.Popen( + [str(hook_path)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env={ + "HOME": str(fixture_home), + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "SESSION_KIT_AUTO_NAME": "0", + }, + start_new_session=True, + ) + assert child.stdin is not None and child.stdout is not None + child.stdin.write(fixture_input) + child.stdin.close() + descriptor = child.stdout.fileno() + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + ready, _, _ = select.select([descriptor], [], [], 0.1) + if ready: + block = os.read(descriptor, 4097 - len(payload)) + if not block: + break + payload.extend(block) + if len(payload) > 4096: + failed = True + break + elif child.poll() is not None: + break + else: + failed = True + except (OSError, ValueError): + failed = True + finally: + if child is not None: + failed = not terminate_group(child) or failed + if child.stdout is not None: + child.stdout.close() + if failed or child is None or child.returncode != 0: + return False + try: + record = json.loads(payload.decode("utf-8", "strict")) + except (UnicodeError, ValueError): + return False + return record == { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "sessionTitle": "Session Kit Doctor Fixture", + } + } + + +if not hook_fixture_ok(): + hook_errors.append("functional fixture") +if hook_errors: + emit("warn", "naming-hook", "Claude title hook coverage missing: " + ", ".join(hook_errors)) +else: + emit("ok", "naming-hook", "Claude title hook matches its installation record and covers every profile") +if statusline_errors: + emit("warn", "statusline", "Claude status line coverage missing: " + ", ".join(statusline_errors)) +else: + emit("ok", "statusline", "Claude status line matches its installation record and every profile is registered") + + +# Who owns the terminal tab name (K3). Two things can silence the kit's name +# there, and neither announces itself: the vendor's own off-switch, which the +# live drill proved silences the kit's hook title as well as Claude's own +# writes, and the kit's kill switch. +# Enumerated live against codex-cli 0.145.0, one id per run, with doctor's own +# title check as the oracle. cwd, account and none are deliberately absent: +# Codex rejects all three, and an earlier version of this set listed them +# because its probe read "no error printed" as acceptance. +CODEX_TITLE_ITEMS = { + "spinner", "activity", "thread", "thread-title", "project", "project-name", + "model", "model-with-reasoning", "git-branch", + "context-used", "weekly-limit", "five-hour-limit", +} +tab_notes = [] +if os.environ.get("CLAUDE_CODE_DISABLE_TERMINAL_TITLE", "").strip(): + tab_notes.append( + "CLAUDE_CODE_DISABLE_TERMINAL_TITLE is set, which silences the kit's " + "title in Claude windows as well as Claude's own" + ) +if os.environ.get("SESSION_KIT_TAB_TITLE", "").strip().casefold() == "off": + tab_notes.append("SESSION_KIT_TAB_TITLE=off; the kit writes no tab names") +codex_template = codex_home / "session-kit" / "terminal-title.toml" +try: + codex_home_info = codex_home.lstat() +except OSError: + codex_home_info = None +if codex_home_info is not None and stat.S_ISLNK(codex_home_info.st_mode): + # The installer refuses a symlinked Codex home, so "reinstall to deploy it" + # would be advice that can never succeed. Say what is actually wrong. + tab_notes.append( + f"the Codex home {codex_home} is a symlink, which the installer will " + "not write through; Codex windows keep the default title" + ) +elif codex_home_info is not None and stat.S_ISDIR(codex_home_info.st_mode): + # Parsed, not pattern-matched: this is the exact file the launcher reads, + # and it decides between the kit's items and the built-in fallback. + parsed = None + def _sk_parse_title_template(text): + # tomllib when it exists; else the template's own tiny dialect + # (one [table] level, JSON-compatible values). Python 3.10 has no tomllib. + try: + import tomllib + except ImportError: + tomllib = None + if tomllib is not None: + try: + return tomllib.loads(text) + except Exception: + return None + import json as _json + import re as _re + parsed = {} + current = None + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + header = _re.fullmatch(r"\[([A-Za-z0-9_-]+)\]", line) + if header: + current = parsed.setdefault(header.group(1), {}) + if not isinstance(current, dict): + return None + continue + pair = _re.fullmatch(r"([A-Za-z0-9_-]+)\s*=\s*(.+)", line) + if not pair: + return None + raw = pair.group(2).strip() + if raw.startswith("[") and raw.endswith("]"): + inner = raw[1:-1].strip() + value = [] + for part in inner.split(",") if inner else []: + part = part.strip() + quoted = ( + len(part) >= 2 + and part[0] == part[-1] + and part[0] in "\"'" + ) + if not quoted: + return None + value.append(part[1:-1]) + elif len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'": + value = raw[1:-1] + else: + return None + (current if current is not None else parsed)[pair.group(1)] = value + return parsed + + try: + template_bytes = codex_template.read_bytes() + except OSError: + template_bytes = b"" + if template_bytes: + try: + parsed = _sk_parse_title_template(template_bytes.decode("utf-8")) + except Exception: + parsed = None + if not template_bytes: + tab_notes.append( + "the Codex tab-title template is missing; Codex windows keep the " + "default title (reinstall to deploy it)" + ) + elif parsed is None: + tab_notes.append( + "the Codex tab-title template is not valid TOML, so kit sessions " + "fall back to the built-in title items" + ) + else: + tui = parsed.get("tui") + if not isinstance(tui, dict): + tab_notes.append( + "the Codex tab-title template is malformed: tui must be a table" + ) + else: + items = tui.get("terminal_title") + if not isinstance(items, list) or not items: + tab_notes.append("the Codex tab-title template names no items") + else: + unknown = [ + str(item) for item in items if str(item) not in CODEX_TITLE_ITEMS + ] + if unknown: + tab_notes.append( + "the Codex tab-title template names items Codex does not " + "know: " + ", ".join(sorted(unknown)) + ) + elif "thread" not in items and "thread-title" not in items: + tab_notes.append( + "the Codex tab-title template does not include the thread " + "name, so Codex tabs will not carry the session name" + ) +if tab_notes: + emit("warn", "tab-title", "; ".join(tab_notes)) +else: + emit("ok", "tab-title", "the kit owns the tab name on both providers") + + +# Whether Codex sessions can still be named at all. The titler runs before +# every human-facing inventory build, through a caller that sends both its +# streams to /dev/null, so a store it cannot read produced no name and no word +# about it. It leaves the reason here instead; a healthy pass removes the file. +titler_failure = ( + Path( + os.environ.get("SESSION_KIT_STATE_DIR") + or os.path.join( + os.environ.get("XDG_STATE_HOME") or os.path.join(str(home), ".local/state"), + "session-kit", + ) + ) + / "codex-autotitle-error.json" +) +try: + if titler_failure.is_symlink(): + raise OSError("refusing a symlinked record") + record = json.loads(titler_failure.read_text(encoding="utf-8")) +except (OSError, ValueError): + record = None +if isinstance(record, dict) and record.get("detail"): + when = str(record.get("at") or "") + emit( + "warn", + "codex-titles", + f"Codex sessions are not being named: {record['detail']}" + + (f" (last seen {when})" if when else ""), + ) +else: + emit("ok", "codex-titles", "Codex sessions are being named") + +# The attention hook is how the picker learns that a Claude session is waiting +# for a person at the moment it starts waiting. Not installed is not a failure +# -- the poll still finds waiting sessions on its own schedule -- but it has to +# be VISIBLE, because a hook that is registered and broken looks exactly like +# an estate where nobody is waiting. +attention_errors = [] +attention_path = home / ".claude" / "hooks" / "attention_hook.sh" +attention_payload = secure_regular_bytes(attention_path, 1_048_576, executable=True) +if attention_payload is None: + attention_errors.append("hook file") +attention_commands = { + "~/.claude/hooks/attention_hook.sh", + str(attention_path), +} +attention_events = [] +for event in ("Notification", "UserPromptSubmit", "SessionEnd"): + entries = hooks.get(event) if isinstance(hooks, dict) else None + covered = False + if isinstance(entries, list): + for group in entries: + if not isinstance(group, dict) or not isinstance(group.get("hooks"), list): + continue + for hook in group["hooks"]: + if ( + isinstance(hook, dict) + and hook.get("type") == "command" + and isinstance(hook.get("command"), str) + and hook["command"] in attention_commands + ): + covered = True + if not covered: + attention_events.append(event) + + +def attention_fixture_ok() -> bool: + """Feed it one real Notification and read the record back.""" + if attention_payload is None: + return False + session_id = "00000000-0000-4000-8000-00000000d0c7" + fixture_input = json.dumps( + { + "session_id": session_id, + "hook_event_name": "Notification", + "notification_type": "permission_prompt", + "message": "doctor fixture", + } + ).encode() + with tempfile.TemporaryDirectory(prefix="session-kit-attention-doctor.") as temporary: + fixture_home = Path(temporary) + state_dir = fixture_home / "state" + try: + child = subprocess.run( + [str(attention_path)], + input=fixture_input, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env={ + "HOME": str(fixture_home), + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "SESSION_KIT_STATE_DIR": str(state_dir), + }, + timeout=10, + ) + except (OSError, ValueError, subprocess.SubprocessError): + return False + if child.returncode != 0: + return False + record_path = state_dir / "attention" / "claude" / f"{session_id}.json" + try: + record = json.loads(record_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return False + return record.get("needs_you") is True and record.get("schema_version") == 1 + + +if attention_payload is not None and attention_events: + attention_errors.append("not registered for: " + ", ".join(attention_events)) +if attention_payload is not None and not attention_fixture_ok(): + attention_errors.append("functional fixture") +if attention_payload is None: + emit( + "warn", + "attention-hook", + "Claude attention hook is not installed; waiting sessions are found by the poll only", + ) +elif attention_errors: + emit("warn", "attention-hook", "Claude attention hook: " + "; ".join(attention_errors)) +else: + emit( + "ok", + "attention-hook", + "Claude attention hook covers Notification, UserPromptSubmit, and SessionEnd", + ) + + +active = [] +_off_words = {"0", "false", "no", "off"} +if os.environ.get("SESSION_KIT_AUTO_NAME", "").strip().casefold() in _off_words: + active.append("SESSION_KIT_AUTO_NAME") +# The picker's live channels default on; each is switched off by the same +# words (0/off/no/false, any case, spaces trimmed) its own reader honours, so +# doctor reports a disabled channel instead of an "all clear" over one. +for name in ( + "SESSION_KIT_PICKER_EVENTS", + "SESSION_KIT_PICKER_PULSE", + "SESSION_KIT_CLAUDE_SOCKET", +): + if os.environ.get(name, "").strip().casefold() in _off_words: + active.append(name) +for name in ("SESSION_KIT_NO_COLOR", "NO_COLOR"): + if name in os.environ: + active.append(name) +for name in (".no_shpool_reaper", ".no_shpool_watchdog"): + if (home / name).exists() or (home / name).is_symlink(): + active.append(name) +# The sub-agent sweep ends processes every five minutes and carries switches of +# its own -- one of them a FILE, because the systemd user manager inherits none +# of their shell environment, so the variable never reaches the pass that closes +# things. Reporting "no supported kill switch is active" while one of these was +# holding would be the same false all-clear the sentinel regression produced. +if os.environ.get("SESSION_KIT_SUBAGENT_SWEEP", "").strip().casefold() in _off_words: + active.append("SESSION_KIT_SUBAGENT_SWEEP") +_sweep_off_file = kit_state_root / "subagent-sweep-off" +if _sweep_off_file.exists() or _sweep_off_file.is_symlink(): + active.append("<state>/subagent-sweep-off") +if active: + emit("warn", "kill-switches", "active: " + ", ".join(active)) +else: + emit("ok", "kill-switches", "no supported kill switch is active") + + +# The sub-agent sweep, reported as a STATE rather than as a unit file. +# +# Doctor already checks that the timer is enabled and active, and that is not +# the same question as "is the rule being applied". A held state-directory +# lock, a crashed pass, a window nobody can read, or an off switch all retire +# the fifteen-minute rule while the timer stays green -- and a sweep that had +# gone quiet used to leave no evidence anywhere (lane B F9). The durable trace +# is `last_pass` in the sweep's own state file, which only a pass that ran to +# completion writes. +# +# CRUCIAL: this reports what the TIMER gets, not what this shell has. The +# systemd user manager inherits none of the operator's environment, so +# SESSION_KIT_SUBAGENT_IDLE_MINUTES reaches a hand-run pass and NOTHING ELSE. +# Reading the window out of `os.environ` would have printed +# "ok ... 120-minute window" on a machine whose timer was closing at fifteen -- +# a green line understating the closer by 8x. The variable is still reported, +# as the warning it is. +# +# The file reader mirrors subagent_sweep._env_idle_hours' file branch and the +# environment reader mirrors its variable branch; tests/test_subagent_sweep.py +# pins both against the same matrix so they cannot drift apart. +def sweep_usable_duration(raw, per_unit): + """A window in minutes, or None for anything that is not a duration.""" + try: + value = float(raw) + except ValueError: + return None + if value < 0 or value != value or value == float("inf"): + return None + return value * per_unit + + +def sweep_window_from_file(): + """What the TIMER resolves: the state-directory file, then the default.""" + window_file = kit_state_root / "subagent-sweep-minutes" + try: + if window_file.is_file() and not window_file.is_symlink(): + # Bytes, then a replacing decode. `read_text` decodes strictly, and + # a single non-UTF-8 byte in this operator-editable file raised + # UnicodeDecodeError -- a ValueError, not an OSError -- straight out + # of this program, which made the bash caller discard EVERY extended + # check, not just this one. One stray byte blinded the whole audit. + raw = window_file.read_bytes()[:64].decode("utf-8", "replace").strip() + if raw: + value = sweep_usable_duration(raw, 1.0) + if value is None: + return None, "<state>/subagent-sweep-minutes contains " + raw + return value, "<state>/subagent-sweep-minutes" + except OSError: + pass + return 15.0, "the built-in default" + + +def sweep_window_from_env(): + """What a HAND-RUN pass resolves, or (None, "") when no variable is set. + + Never what the timer resolves. That is the entire point of reporting it. + """ + for name, per_unit in ( + ("SESSION_KIT_SUBAGENT_IDLE_MINUTES", 1.0), + ("SESSION_KIT_SUBAGENT_IDLE_HOURS", 60.0), + ): + if name not in os.environ: + continue + raw = (os.environ.get(name) or "").strip() + if not raw: + return None, name + " is set but empty" + value = sweep_usable_duration(raw, per_unit) + if value is None: + return None, name + "=" + raw + " is not a usable duration" + return value, name + return None, "" + + +_sweep_state = kit_state_root / "subagent-sweep.state.json" +_sweep_minutes, _sweep_source = sweep_window_from_file() +_env_minutes, _env_source = sweep_window_from_env() +# The reaper resolves its brake as SESSION_KIT_REAPER_SENTINEL, falling back to +# ~/.no_shpool_reaper (bin/shpool_reaper:31). Hardcoding the fallback would +# report the brake ENGAGED while a redirected sentinel left the sweep running. +_sentinel = Path(os.environ.get("SESSION_KIT_REAPER_SENTINEL") or (home / ".no_shpool_reaper")) +_sentinel_name = ( + "SESSION_KIT_REAPER_SENTINEL (" + str(_sentinel) + ")" + if os.environ.get("SESSION_KIT_REAPER_SENTINEL") + else "~/.no_shpool_reaper" +) +_sweep_off_reason = "" +if _sentinel.exists() or _sentinel.is_symlink(): + _sweep_off_reason = _sentinel_name +elif "<state>/subagent-sweep-off" in active: + _sweep_off_reason = "<state>/subagent-sweep-off" +elif _sweep_minutes is None: + _sweep_off_reason = "a window it cannot read: " + _sweep_source +elif _sweep_minutes == 0: + _sweep_off_reason = "a window of 0 from " + _sweep_source +# An exported variable does not reach the timer, so it is a caveat on every +# verdict below rather than a source for any of them. +_env_note = "" +if _env_source and _env_minutes is None: + _env_note = ( + "; " + _env_source + ", which does not reach the timer anyway" + " -- put a number of minutes in <state>/subagent-sweep-minutes instead" + ) +elif _env_source and _env_minutes != _sweep_minutes: + _env_note = ( + "; %s=%g is set in this shell but does NOT reach the timer" + " -- put it in <state>/subagent-sweep-minutes instead" + % (_env_source, _env_minutes) + ) +if active_platform in ("macos", "Darwin"): + # `platform()` reports `macos`; `Darwin` is only ever seen through + # SESSION_KIT_TEST_PLATFORM. Both are accepted so this cannot report a + # permanently quiet sweep on a machine where it correctly never runs. + emit("ok", "subagent-sweep", "the sub-agent sweep does not run on macOS") +elif _sweep_off_reason: + # Off is a legitimate answer and must never read as an all-clear. + emit( + "warn", + "subagent-sweep", + "the sub-agent sweep is OFF, disabled by " + _sweep_off_reason + + "; finished sub-agent workers are not being closed" + _env_note, + ) +else: + _window_text = "%g-minute window from %s" % (_sweep_minutes, _sweep_source) + # Everything that reads the state file lives in one try: a hand-edited + # `last_pass` of -Infinity or a 400-digit integer would otherwise raise + # ValueError or OverflowError out of this program and discard the whole + # extended audit, exactly as a stray byte in the window file did. + try: + _sweep_payload = json.loads(_sweep_state.read_bytes().decode("utf-8", "replace")) + _last_pass = _sweep_payload.get("last_pass") + if isinstance(_last_pass, bool) or not isinstance(_last_pass, (int, float)): + _age = None + else: + _last_pass = float(_last_pass) + _age = time.time() - _last_pass + if _age != _age or _age in (float("inf"), float("-inf")): + _age = None + except (OSError, ValueError, AttributeError, OverflowError): + _age = None + if _age is None: + # Expected for the first couple of minutes after an install (the timer + # is OnBootSec=2min) and after an upgrade from a release that did not + # record it. Persisting past that means the pass is not running. + emit( + "warn", + "subagent-sweep", + _window_text + ", but no completed pass is recorded yet in " + + str(_sweep_state) + "; expected right after an install or " + "upgrade, and a sign the pass is not running if it persists" + + _env_note, + ) + # The timer is every five minutes with AccuracySec=30s. Four cadences of + # slack, so an ordinary overlapping or skipped pass never cries wolf and a + # sweep that has actually stopped is named within twenty minutes. + elif _age > 20 * 60: + emit( + "warn", + "subagent-sweep", + _window_text + ", but the last completed pass was " + + str(int(_age // 60)) + " minutes ago; the sweep has gone quiet" + " (a held state-directory lock, a crashed pass, or a timer that" + " is not firing)" + _env_note, + ) + elif _age < 0: + emit( + "warn", + "subagent-sweep", + _window_text + ", but the last completed pass is dated in the" + " future; the clock moved and the sweep will restart its clocks" + + _env_note, + ) + elif _env_note: + emit("warn", "subagent-sweep", _window_text + _env_note) + else: + emit( + "ok", + "subagent-sweep", + _window_text + "; last completed pass " + str(int(_age // 60)) + "m ago", + ) + +acceptance = config_root / "release-acceptance.json" +try: + acceptance_payload = secure_regular_bytes(acceptance, 16_384, exact_mode=0o600) + if acceptance_payload is None: + raise ValueError + record = json.loads(acceptance_payload.decode("utf-8", "strict")) + if not isinstance(record, dict) or set(record) != { + "schema_version", + "release_id", + "platform", + "provider_versions", + "accepted_on", + "evidence", + }: + raise ValueError + versions = record.get("provider_versions") + evidence = record.get("evidence") + accepted_on = record.get("accepted_on") + if ( + record.get("schema_version") != 1 + or record.get("release_id") != active_release + or record.get("platform") != active_platform + or not isinstance(versions, dict) + or set(versions) != {"claude", "codex"} + or not all(provider_ready.values()) + or versions != provider_observed + or not isinstance(accepted_on, str) + or not isinstance(evidence, dict) + or set(evidence) != {"unique_colors", "thread_titles", "resume_roundtrip"} + or not all( + isinstance(value, str) + and 1 <= len(value) <= 256 + and "\n" not in value + and "\r" not in value + for value in evidence.values() + ) + ): + raise ValueError + accepted_date = datetime.date.fromisoformat(accepted_on) + if accepted_date > datetime.date.today(): + raise ValueError +except (OSError, UnicodeError, ValueError): + emit( + "warn", + "acceptance", + "this release has no valid private acceptance record; follow the " + "release-acceptance.json schema in docs/troubleshooting.md", + ) +else: + emit("ok", "acceptance", "private release acceptance record is present") + +# Vendor internal formats the kit reads or writes. Both vendors document these +# files as internal and version-unstable, and the kit depends on five of them +# (docs/pinned-internal-formats.md names every reader). A format that changes +# shape does not announce itself: the reader simply finds nothing, and a +# session's title, history or colour quietly stops working. This check reads one +# live example of each and says which assumption it could not confirm. +# A home directory on slow or stalled storage must not turn doctor into a hang: +# the scan gets a wall-clock budget and every traversal below is bounded by it. +try: + format_budget = float(os.environ.get("SESSION_KIT_DOCTOR_FORMAT_SECONDS", "10")) +except ValueError: + format_budget = 10.0 +format_deadline = time.monotonic() + max(0.0, format_budget) +format_verdicts = [] + + +def out_of_time() -> bool: + return time.monotonic() >= format_deadline + + +def json_lines(path, limit: int = 262_144, cap: int = 50): + """(records, why-not) from the COMPLETE lines at the head of a file. + + A file with no complete line is not a moved format: it is a transcript a + session created a moment ago, or one whose first line is longer than the + window. Doctor's loudest verdict must never come from a file that is + simply still being written. + """ + try: + with open(path, "rb") as handle: + block = handle.read(limit) + except OSError: + return [], "unreadable" + if b"\n" not in block: + return [], "no complete line yet" + records = [] + for line in block.split(b"\n")[:-1]: + if not line.strip(): + continue + try: + records.append(json.loads(line.decode("utf-8", "strict"))) + except (UnicodeError, ValueError): + if not records: + return [], "its first line is no longer JSON" + break + if len(records) >= cap: + break + if not records: + return [], "no complete line yet" + return records, "" + + +def newest(paths, count=1): + ranked = [] + for path in paths: + if out_of_time(): + break + try: + ranked.append((path.stat().st_mtime, path)) + except OSError: + continue + ranked.sort(reverse=True) + return [path for _, path in ranked[:count]] + + +def bounded_glob(root, pattern, cap=2000): + """Materialise at most `cap` paths, and stop when the clock says stop.""" + found = [] + try: + for path in root.glob(pattern): + found.append(path) + if len(found) >= cap or out_of_time(): + break + except OSError: + return [] + return found + + +def record_verdict(name, state, detail): + format_verdicts.append((name, state, detail)) + + +def judge_fields(record, required, optional=()): + """(state, detail). The exact fields the kit reads, named as they go.""" + if not isinstance(record, dict): + return "moved", "is no longer a JSON object" + missing = [field for field in required if field not in record] + if missing: + return "moved", "lost " + ", ".join(missing) + thin = [field for field in optional if field not in record] + if thin: + return "thin", "carries no " + ", ".join(thin) + return "ok", "" + + +def check_json_object(name, path, required, optional=()): + """One live example of a whole-file JSON object, field by field.""" + text = bounded_text(path, 262_144) + if text is None: + # Unreadable, oversized or rewritten under us: no evidence either way. + record_verdict(name, "absent", "the live example could not be read whole") + return + try: + record = json.loads(text) + except ValueError: + record_verdict(name, "moved", "no longer parses as JSON") + return + record_verdict(name, *judge_fields(record, required, optional)) + + +def check_json_lines(name, path, required, optional=()): + """One live example of a JSON-lines file. These files carry several kinds + of line, so the verdict is the best any complete head line earns: only a + file where NO line still has the shape counts as moved.""" + records, why = json_lines(path) + if not records: + record_verdict(name, "absent" if why == "no complete line yet" else "moved", why) + return + objects = [record for record in records if isinstance(record, dict)] + if not objects: + record_verdict(name, "moved", "records are no longer JSON objects") + return + verdicts = [judge_fields(record, required, optional) for record in objects] + for state in ("ok", "thin"): + for candidate in verdicts: + if candidate[0] == state: + record_verdict(name, *candidate) + return + record_verdict(name, *verdicts[0]) + + +claude_sessions = home / ".claude" / "sessions" +records = newest( + [ + path + for path in bounded_glob(claude_sessions, "*.json") + if path.name[:-5].isdigit() + ] +) +if not records: + record_verdict("Claude session record", "absent", "no live example to check") +else: + # docs/pinned-internal-formats.md names these: sessionId identifies the + # conversation, messagingSocketPath is the whole of socket delivery, and + # statusUpdatedAt is the attention merge's only tie-breaker. The first two + # break a feature outright; the last two degrade it, so they warn. + check_json_object( + "Claude session record", + records[0], + required=("sessionId", "pid"), + optional=("messagingSocketPath", "statusUpdatedAt"), + ) + +transcripts = newest(bounded_glob(home / ".claude" / "projects", "*/*.jsonl"), 3) +if not transcripts: + record_verdict("Claude transcript", "absent", "no live example to check") +else: + for position, transcript in enumerate(transcripts): + before = len(format_verdicts) + # `type` is what every transcript reader dispatches on: ai-title, + # agent-name, agent-color. + check_json_lines("Claude transcript", transcript, required=("type",)) + if format_verdicts[before][1] != "absent" or position == len(transcripts) - 1: + break + del format_verdicts[before:] + +keys = newest(bounded_glob(claude_sessions, "*.key")) +if not keys: + record_verdict("Claude messaging key", "absent", "no live example to check") +else: + # claude_socket.read_peer_token sends nothing without peerToken. + check_json_object("Claude messaging key", keys[0], required=("peerToken",)) + +codex_sessions = codex_home / "sessions" +rollouts = newest(bounded_glob(codex_sessions, "**/rollout-*.jsonl")) +if not rollouts: + record_verdict("Codex rollout", "absent", "no live example to check") +else: + # providers_codex reads event.type and the payload mapping under it; + # transcript_text.render_rollout reads the same pair. + check_json_lines("Codex rollout", rollouts[0], required=("type", "payload")) + +index = codex_home / "session_index.jsonl" +if not index.is_file(): + record_verdict("Codex session index", "absent", "no live example to check") +else: + # read_codex_session_index keeps an entry only when it has both. + check_json_lines( + "Codex session index", index, required=("id",), optional=("thread_name",) + ) + +format_moved = [ + f"{name} {detail}" for name, state, detail in format_verdicts if state == "moved" +] +format_thin = [ + f"{name} {detail}" for name, state, detail in format_verdicts if state == "thin" +] +format_absent = [name for name, state, _ in format_verdicts if state == "absent"] +format_ok = [name for name, state, _ in format_verdicts if state == "ok"] +if format_moved: + # A shape that has already changed is not a warning: something a person + # depends on is broken right now, and every moved format is named. + emit("fail", "internal-formats", "; ".join(format_moved)) +elif format_thin: + # The file is still there and still readable; one field the kit reads is + # gone, so the feature that reads it degrades rather than breaks. + emit("warn", "internal-formats", "; ".join(format_thin)) +elif out_of_time(): + emit( + "warn", + "internal-formats", + "the vendor format check ran out of time before it finished", + ) +elif format_absent: + emit( + "warn", + "internal-formats", + f"{len(format_ok)} of {len(format_verdicts)} pinned vendor formats have the " + "shape the kit reads; no live example of: " + ", ".join(format_absent), + ) +else: + emit( + "ok", + "internal-formats", + f"all {len(format_ok)} pinned vendor formats still have the shape the kit reads", + ) +PY + ); then + while IFS=$'\t' read -r status name detail; do + if [[ ${audit_expected[$name]:-0} == 1 && -z ${audit_seen[$name]:-} && + $status =~ ^(ok|warn|fail)$ && -n $detail ]]; then + add_check "$status" "$name" "$detail" + audit_seen[$name]=1 + elif [[ -n $status || -n $name || -n $detail ]]; then + audit_ok=0 + fi + done <<< "$audit_output" + for audit_name in "${audit_names[@]}"; do + [[ ${audit_seen[$audit_name]:-0} == 1 ]] || audit_ok=0 + done + else + audit_ok=0 + fi + (( audit_ok == 1 )) || add_check warn migration-audit \ + "extended read-only audit failed or returned an incomplete check set" + for helper in "${helpers[@]}"; do + if [[ -x $bin_dir/$helper ]]; then + add_check ok "$helper" "$bin_dir/$helper" + else + add_check fail "$helper" "launcher unavailable" + fi + done + if [[ -f $config_root/inventory.json && ! -L $config_root/inventory.json ]] && + python3 -m json.tool "$config_root/inventory.json" >/dev/null 2>&1; then + add_check ok config "inventory.json is valid JSON" + else + add_check fail config "inventory.json is missing, unsafe, or invalid" + fi + if ( + validate_purge_ownership \ + install "$install_root" "$install_root/.session-kit-owned.json" && + validate_purge_ownership \ + config "$config_root" "$config_root/.session-kit-owned.json" + ) >/dev/null 2>&1; then + add_check ok ownership "receipt and root ownership markers match" + else + add_check fail ownership "receipt or root ownership marker is missing or incompatible" + fi + # Session recordings are the largest thing the kit writes and the only feed + # with no policy at all: they are removed as a side effect of pruning or + # closing the owning session, and never aged out. Measured at 2.6 GB on a box + # two weeks old, 470 MB of it belonging to sessions that no longer exist. + # Reported, with a bound the operator can set -- never deleted here: a + # recording is the history `sp find` and `sp history` read, and nothing should + # remove one on a health check. + local journal_days=${SESSION_KIT_JOURNAL_RETENTION_DAYS:-30} + [[ $journal_days =~ ^[0-9]+$ ]] || journal_days=30 + local journal_report= + journal_report=$(python3 - "$journal_dir" "$journal_days" 2>/dev/null <<'PY' +import os +import sys +import time + +root, days_text = sys.argv[1:3] +days = int(days_text) +if not os.path.isdir(root): + print("ok\tno session recordings are stored") + raise SystemExit(0) +cutoff = time.time() - days * 86400 +total = older = 0 +oldest = time.time() +for base, _directories, files in os.walk(root): + for name in files: + path = os.path.join(base, name) + try: + info = os.lstat(path) + except OSError: + continue + if not os.path.isfile(path): + continue + total += info.st_size + oldest = min(oldest, info.st_mtime) + if info.st_mtime < cutoff: + older += info.st_size + + +def size(value): + for unit in ("B", "KB", "MB"): + if value < 1024: + return f"{value:.0f} {unit}" + value /= 1024 + return f"{value:.1f} GB" + + +age = max(0, int((time.time() - oldest) / 86400)) +if older: + print( + f"warn\tsession recordings are {size(total)}, of which {size(older)} is " + f"older than {days} days; closing or pruning those sessions is what " + f"reclaims it, and SESSION_KIT_JOURNAL_RETENTION_DAYS sets the bound " + f"this check uses" + ) +else: + print(f"ok\tsession recordings are {size(total)}, none older than {days} days (oldest {age}d)") +PY +) || journal_report="" + if [[ -n $journal_report ]]; then + local journal_status= journal_detail= + journal_report=${journal_report//$'\t'/$'\034'} + IFS=$'\034' read -r journal_status journal_detail <<<"$journal_report" + case $journal_status in + ok | warn) add_check "$journal_status" journals "$journal_detail" ;; + *) add_check warn journals "session recordings could not be measured" ;; + esac + else + add_check warn journals "session recordings could not be measured" + fi + if [[ -e $transaction_path || -L $transaction_path ]]; then + add_check fail transaction "interrupted lifecycle transaction requires recovery" + else + add_check ok transaction "no interrupted lifecycle transaction" + fi + if [[ -f $integration_marker && ! -L $integration_marker && + $(stat_mode "$integration_marker") == 600 ]]; then + add_check ok login "enabled with a private release marker" + else + add_check warn login "disabled or not yet validated" + fi + if (( json )); then + printf '%s\n' "${checks[@]}" | python3 -c ' +import json,sys +rows=[] +for line in sys.stdin: + status,name,detail=line.rstrip("\n").split("\t",2) + rows.append({"status":status,"name":name,"detail":detail}) +print(json.dumps({"ok":all(r["status"]!="fail" for r in rows),"checks":rows},indent=2,sort_keys=True)) +' + else + for row in "${checks[@]}"; do + IFS=$'\t' read -r status name detail <<<"$row" + render_check_row "$status" "$name" "$detail" + done + fi + (( failed == 0 )) +} diff --git a/lib/sh/session_kit_install.sh b/lib/sh/session_kit_install.sh new file mode 100644 index 0000000..c1aad49 --- /dev/null +++ b/lib/sh/session_kit_install.sh @@ -0,0 +1,1835 @@ +#!/usr/bin/env bash +# Session Kit install, update, and rollback commands, including the release +# payload and platform service units. Source this file; do not execute it. +# +# Source order: bin/session-kit sources this module after it assigns the +# lifecycle roots, shpool_config, helpers, systemd_units, launchd_units, +# launchd_template_root, receipt_path, integration_marker, codex_theme_names, +# and projects_created, and after it defines die(), platform(), and +# require_arg(). These commands call into lib/sh/session_kit_checks.sh, +# lib/sh/session_kit_lifecycle.sh, lib/sh/session_kit_login.sh, and +# lib/sh/session_kit_projects.sh, all of which only have to be sourced before +# the dispatcher runs. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +# Pin the transaction's launcher source before any current-release pointer can +# move. A direct management entry from an older installation may source this +# module through $install_root/current; resolving BASH_SOURCE later, after a +# rollback flip, would silently select the rollback target's obsolete launcher. +session_kit_launcher_source=$( + cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd +)/deploy/session-kit-launcher + +install_launchers() { + local release_id=$1 helper source destination temporary + # Launchers are recovery-capable and release-independent. Always install the + # copy belonging to the code performing this transaction, including for a + # rollback to a release whose older manager cannot read today's journal. + source=$session_kit_launcher_source + [[ -f $source && ! -L $source ]] || die "stable Session Kit launcher is unavailable" + mkdir -p "$bin_dir" + for helper in "${helpers[@]}"; do + destination=$bin_dir/$helper + temporary=$(mktemp "$bin_dir/.${helper}.XXXXXX") + if ! install -m 0755 "$source" "$temporary" || + ! mv -f -- "$temporary" "$destination"; then + rm -f -- "$temporary" + return 1 + fi + done + destination=$bin_dir/session-kit + temporary=$(mktemp "$bin_dir/.session-kit.XXXXXX") + if ! install -m 0755 "$source" "$temporary" || + ! mv -f -- "$temporary" "$destination"; then + rm -f -- "$temporary" + return 1 + fi +} + +# The shipped Bash completion, copied once per command name into the directory +# bash-completion loads user files from. Copies rather than symlinks: the file +# lives in a read-only release that a rollback replaces, and a dangling symlink +# in a completion directory makes every new shell print an error. +# +# It is never fatal. A machine without the target directory writable, or with a +# name already taken by something that is not ours, keeps its completion as it +# was and the rest of the installation proceeds -- completion is a convenience, +# and refusing an install over it would be the wrong trade. +install_completions() { + local release_id=$1 source name destination temporary + [[ $completion_dir != none ]] || return 0 + source=$install_root/releases/$release_id/lib/sh/sp_completion.bash + [[ -f $source && ! -L $source ]] || return 0 + [[ ! -e $completion_dir || ( -d $completion_dir && ! -L $completion_dir ) ]] || + return 0 + mkdir -p -- "$completion_dir" 2>/dev/null || return 0 + for name in "${completion_names[@]}"; do + destination=$completion_dir/$name + # Anything already there that is not a kit copy belongs to the user or to + # a package: leave it exactly as it is. + if [[ -e $destination || -L $destination ]]; then + [[ -f $destination && ! -L $destination ]] || continue + grep -qxF -- "$completion_marker" "$destination" 2>/dev/null || continue + fi + temporary=$(mktemp "$completion_dir/.${name}.XXXXXX" 2>/dev/null) || continue + if install -m 0644 "$source" "$temporary" 2>/dev/null && + mv -f -- "$temporary" "$destination" 2>/dev/null; then + continue + fi + rm -f -- "$temporary" + done + return 0 +} + +install_codex_themes() { + local release_id=$1 codex_theme_root theme_name theme_file destination temporary + if [[ ! -d $install_root/releases/$release_id/config/codex-themes ]]; then + local -a obsolete_themes=() + mapfile -t obsolete_themes < <(codex_theme_layout targets) + for destination in "${obsolete_themes[@]}"; do + [[ ! -d $destination || -L $destination ]] || + die "refusing to replace theme directory: $destination" + rm -f -- "$destination" + done + return 0 + fi + codex_theme_root=$(codex_theme_layout create) + # Install the themes THIS release ships, rather than the names this program + # happens to know. Those are not the same list, in both directions. An update + # runs under the PREVIOUS launcher, so a release that adds a colour would + # install the older, smaller set and leave the new windows untinted until a + # second update. A rollback has the mirror problem: the release being + # restored predates names the running code knows, and requiring them refuses + # the rollback outright. + for theme_file in \ + "$install_root/releases/$release_id/config/codex-themes"/sk-*.tmTheme; do + [[ -e $theme_file || -L $theme_file ]] || continue + theme_name=${theme_file##*/} + # The release directory is trusted only as far as its shape: install + # nothing whose name is not exactly a kit theme, and nothing that is not a + # plain file. Absence means a smaller palette; a symlink means a tampered + # release. + [[ $theme_name =~ ^sk-[a-z]+\.tmTheme$ ]] || + die "release Codex theme name is unsafe: $theme_name" + [[ -f $theme_file && ! -L $theme_file ]] || + die "release Codex theme is unsafe: $theme_name" + destination=$codex_theme_root/${theme_file##*/} + temporary=$(mktemp "$codex_theme_root/.${theme_file##*/}.XXXXXX") + if ! install -m 0600 "$theme_file" "$temporary"; then + rm -f -- "$temporary" + return 1 + fi + if lifecycle_failpoint_armed theme-copy; then + rm -f -- "$temporary" + die "isolated test failpoint during theme copy" + fi + if ! mv -f -- "$temporary" "$destination"; then + rm -f -- "$temporary" + return 1 + fi + done +} + +# The kit-owned Codex tab title (K3), deployed as a template the launcher +# reads -- never as an edit to the person's own ~/.codex/config.toml. Codex +# takes the same key on the command line and a command-line value wins for that +# process, so a kit session gets the kit's title items and every other Codex +# window keeps whatever the personal config says. Removing the template returns +# kit sessions to the built-in default; SESSION_KIT_TAB_TITLE=off turns the +# whole feature off on both providers. +# +# Same safety rule as the /kit verb above: an unowned, world-writable, or +# non-plain destination is left alone rather than replaced, and a machine with +# no Codex is not an error. +install_codex_terminal_title() { + local release_id=$1 source_file + source_file=$install_root/releases/$release_id/config/codex/terminal-title.toml + [[ -f $source_file && ! -L $source_file ]] || return 0 + # A refusal (no Codex, an unsafe destination) exits 0 and leaves everything + # alone; a FAILED WRITE exits nonzero and takes the install with it, because + # a template that silently did not land is a Codex tab that silently keeps + # the vendor's title. + python3 - "$source_file" \ + "${SESSION_KIT_CODEX_HOME:-${CODEX_HOME:-$HOME/.codex}}" <<'KITTITLE' || return 1 +import os +import pathlib +import stat +import sys +import tempfile + +source_raw, codex_raw = sys.argv[1:3] +root = pathlib.Path(codex_raw) +if not root.is_absolute() or ".." in root.parts: + raise SystemExit(0) + + +def owner_directory(path, *, create): + """An owner-controlled real directory, or None. Never follows a symlink. + + Group-writable counts as writable by somebody else: this refuses 0o022, + not just other-writable, since the file it guards is read at every Codex + launch and turned into a command-line argument. + """ + try: + info = path.lstat() + except FileNotFoundError: + if not create: + return None + try: + path.mkdir(mode=0o700) + except OSError: + return None + return path + if ( + not stat.S_ISDIR(info.st_mode) + or stat.S_ISLNK(info.st_mode) + or info.st_uid != os.geteuid() + or stat.S_IMODE(info.st_mode) & 0o022 + ): + return None + return path + + +# A Codex home that does not exist is a provider that is not installed here. +if owner_directory(root, create=False) is None: + raise SystemExit(0) +directory = owner_directory(root / "session-kit", create=True) +if directory is None: + raise SystemExit(0) +destination = directory / "terminal-title.toml" +try: + existing = destination.lstat() +except FileNotFoundError: + pass +else: + # The stated rule, now enforced: a destination that is not a plain file + # this account owns, or that anyone else can write, is left exactly as it + # is. Type alone was checked before, so an unowned or group/world-writable + # regular file was overwritten. + if ( + stat.S_ISLNK(existing.st_mode) + or not stat.S_ISREG(existing.st_mode) + or existing.st_uid != os.geteuid() + or stat.S_IMODE(existing.st_mode) & 0o022 + ): + raise SystemExit(0) +temporary = None +try: + body = pathlib.Path(source_raw).read_bytes() + handle, temporary = tempfile.mkstemp(prefix=".terminal-title.", dir=directory) + with os.fdopen(handle, "wb") as stream: + stream.write(body) + os.chmod(temporary, 0o600) + os.replace(temporary, destination) +except OSError as exc: + if temporary is not None: + try: + os.unlink(temporary) + except OSError: + pass + print( + f"session-kit: the Codex tab-title template could not be written: {exc}", + file=sys.stderr, + ) + raise SystemExit(1) +KITTITLE + return 0 +} + +# `/kit` inside a live provider means "leave this running and give me the +# picker back". It is one line of shell -- `shpool detach`, which uses +# $SHPOOL_SESSION_NAME when no name is given -- but it has to be reachable +# from inside the conversation, so the kit ships it as a provider command and +# installs it beside the provider's own. +# +# Nothing here is best-effort about safety: a destination that is not an +# owner-controlled plain directory, or a file that is not a plain file, is +# left alone rather than replaced. A provider that is not installed on this +# machine simply has nowhere to put its copy, which is not an error. +# +# ONLY THE TYPED `/kit` MAY DETACH. Claude Code merged custom commands into +# skills, so a file in `commands/` is registered as a skill and, by default, +# the model may invoke it whenever the conversation looks relevant. The body +# opens with an injected `!`shpool detach``, which Claude Code runs when the +# skill content is RENDERED -- before the model reads a word of it -- and +# `allowed-tools` pre-approves it, so no prompt can catch it. An operator +# typed the bare word `kit` as an ordinary message, the model matched it +# against the description, and their terminal detached mid-turn while output was +# still painting (2026-08-15). `disable-model-invocation: true` in the +# template closes it at the first link: with it set, the description is not +# placed in the model's context at all, so there is nothing left to match, +# while the typed `/kit` keeps working exactly as before. The kit also teaches +# the bare word `kit` for the SHELL function, which is why that collision was +# always going to happen here and nowhere else. +install_provider_commands() { + local release_id=$1 source_root + source_root=$install_root/releases/$release_id/config/provider-commands + [[ -d $source_root ]] || return 0 + # CLAUDE_CONFIG_DIR is deliberately NOT consulted. An install run from + # inside a managed session inherits the account profile that session is + # using, and installing a machine-wide verb into one rotating profile + # directory puts it where the next account switch cannot see it. The kit's + # other Claude-side install targets use $HOME/.claude for the same reason. + python3 - "$source_root" "$HOME" \ + "${SESSION_KIT_CLAUDE_HOME:-$HOME/.claude}" \ + "${SESSION_KIT_CODEX_HOME:-${CODEX_HOME:-$HOME/.codex}}" \ + "${XDG_DATA_HOME:-$HOME/.local/share}/session-kit/accounts" <<'KITCMD' || return 1 +import os +import pathlib +import stat +import sys +import tempfile + +source_root, home_raw, claude_raw, codex_raw, accounts_raw = sys.argv[1:6] + + +def owner_directory(path, *, create): + """An owner-controlled real directory, or None. Never follows a symlink.""" + try: + info = path.lstat() + except FileNotFoundError: + if not create: + return None + try: + path.mkdir(mode=0o700) + except OSError: + return None + return path + if ( + not stat.S_ISDIR(info.st_mode) + or stat.S_ISLNK(info.st_mode) + or info.st_uid != os.geteuid() + or stat.S_IMODE(info.st_mode) & 0o002 + ): + return None + return path + + +def install_one(root_raw, subdirectory, name): + root = pathlib.Path(root_raw) + if not root.is_absolute() or ".." in root.parts: + return + # A provider home that does not exist is a provider that is not installed + # on this machine, which is not a failure to report. + if owner_directory(root, create=False) is None: + return + directory = owner_directory(root / subdirectory, create=True) + if directory is None: + return + body = pathlib.Path(source_root, name).read_bytes() + destination = directory / "kit.md" + try: + existing = destination.lstat() + except FileNotFoundError: + pass + else: + if stat.S_ISLNK(existing.st_mode) or not stat.S_ISREG(existing.st_mode): + return + handle, temporary = tempfile.mkstemp(prefix=".kit.md.", dir=directory) + try: + with os.fdopen(handle, "wb") as stream: + stream.write(body) + os.chmod(temporary, 0o600) + os.replace(temporary, destination) + except OSError: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + +# Claude only. Codex got a copy at ~/.codex/prompts/kit.md until 2026-08-15, +# when it was measured to do nothing: in codex-cli 0.145.0 the slash namespace +# is built-in commands only, its app-server protocol carries no channel for a +# custom command, and ~/.codex/prompts/ is not read at all. Installing a file +# that a provider never reads is worse than installing none -- it is a claim +# the docs then repeat. Ctrl-q is the Codex answer and needs no file. +install_one(claude_raw, "commands", "claude-kit.md") + +# An enrolled account is a WHOLE provider config directory, and a session the +# kit launches on that account reads its commands from there -- CLAUDE_CONFIG_DIR +# replaces the config root rather than adding to it. A verb installed only in +# ~/.claude is therefore invisible in exactly the sessions it exists for, which +# is what the live drill found. Every enrolled profile gets its own copy. +accounts = pathlib.Path(accounts_raw) +for provider, subdirectory, name in (("claude", "commands", "claude-kit.md"),): + root = accounts / provider + try: + aliases = sorted(entry.name for entry in os.scandir(root)) + except OSError: + continue + for alias in aliases: + install_one(os.fspath(root / alias), subdirectory, name) +KITCMD +} + +# Refuse unsafe Claude integration targets, invalid settings, and a conflicting +# default-profile status line before a lifecycle transaction exists. Enrolled +# account profiles are preferences in their own right: their status lines are +# preserved later instead of making a machine-wide release flip fail. +preflight_claude_integration() { + local source_root=$1 force=${2:-0} policy=${3:-install} + local claude_root accounts_root + claude_root=${SESSION_KIT_CLAUDE_HOME:-$HOME/.claude} + accounts_root=${XDG_DATA_HOME:-$HOME/.local/share}/session-kit/accounts/claude + python3 - "$source_root" "$HOME" "$claude_root" "$accounts_root" \ + "$claude_statusline_backups" "$claude_integration_ledger" "$force" "$policy" <<'PY' +import json +import os +import pathlib +import re +import stat +import sys + +source_root, home, claude_root, accounts_root, backups_path, ledger_path = map( + pathlib.Path, sys.argv[1:7] +) +force = sys.argv[7] == "1" +policy = sys.argv[8] + + +def owner_directory(path): + try: + info = path.lstat() + except FileNotFoundError: + return None + if ( + not stat.S_ISDIR(info.st_mode) + or stat.S_ISLNK(info.st_mode) + or info.st_uid != os.geteuid() + or stat.S_IMODE(info.st_mode) & 0o002 + ): + raise SystemExit(f"session-kit: Claude directory is not owner-controlled: {path}") + return path + + +def safe_regular_target(path, label): + try: + info = path.lstat() + except FileNotFoundError: + return + if ( + not stat.S_ISREG(info.st_mode) + or stat.S_ISLNK(info.st_mode) + or info.st_uid != os.geteuid() + ): + raise SystemExit(f"session-kit: unsafe {label}: {path}") + + +def load_settings(path): + if owner_directory(path.parent) is None: + return None + try: + info = path.lstat() + except FileNotFoundError: + return {} + if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode): + return None + if info.st_uid != os.geteuid(): + return None + if info.st_size > 1_048_576: + return None + try: + settings = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return None + if not isinstance(settings, dict): + return None + hooks = settings.get("hooks", {}) + if not isinstance(hooks, dict): + return None + for event in ("SessionStart", "UserPromptSubmit", "Stop"): + groups = hooks.get(event, []) + if not isinstance(groups, list): + return None + return settings + + +for name in ("nameintent_title.sh", "statusline.sh"): + source = source_root / name + try: + info = source.lstat() + except FileNotFoundError: + raise SystemExit("session-kit: release Claude integration files are unavailable") + if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode): + raise SystemExit("session-kit: release Claude integration files are unavailable") + +if not claude_root.is_absolute() or ".." in claude_root.parts: + raise SystemExit("session-kit: Claude home must be an absolute normalized path") +try: + home_exists = owner_directory(home) +except SystemExit: + raise SystemExit("session-kit: HOME is not an owner-controlled directory") +if home_exists is None: + raise SystemExit("session-kit: HOME is not an owner-controlled directory") +try: + root_exists = owner_directory(claude_root) +except SystemExit: + raise SystemExit( + f"session-kit: Claude home is not an owner-controlled directory: {claude_root}" + ) +if root_exists is not None: + hooks_root = claude_root / "hooks" + if hooks_root.exists() or hooks_root.is_symlink(): + try: + owner_directory(hooks_root) + except SystemExit: + raise SystemExit( + f"session-kit: Claude hooks directory is not owner-controlled: {hooks_root}" + ) + safe_regular_target(hooks_root / "nameintent_title.sh", "Claude integration file") + safe_regular_target(claude_root / "statusline.sh", "Claude integration file") + +safe_regular_target(backups_path, "Claude status-line backup") +safe_regular_target(ledger_path, "Claude integration ledger") +if backups_path.is_file(): + try: + backups = json.loads(backups_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise SystemExit(f"session-kit: invalid Claude status-line backup: {backups_path}: {exc}; move that file aside to continue (its recorded status-line restores are lost to the kit)") + if ( + not isinstance(backups, dict) + or backups.get("schema_version") != 1 + or not isinstance(backups.get("entries"), dict) + ): + raise SystemExit(f"session-kit: invalid Claude status-line backup: {backups_path}; move that file aside to continue (its recorded status-line restores are lost to the kit)") + for key, record in backups["entries"].items(): + if ( + not isinstance(key, str) + or not pathlib.Path(key).is_absolute() + or not isinstance(record, dict) + or not isinstance(record.get("present"), bool) + or set(record) - {"present", "value"} + or (record["present"] and "value" not in record) + ): + raise SystemExit( + f"session-kit: invalid Claude status-line backup: {backups_path}; " + "move that file aside to continue (its recorded status-line restores are lost to the kit)" + ) + +status_command = ( + "~/.claude/statusline.sh" + if claude_root == home / ".claude" + else str(claude_root / "statusline.sh") +) +canonical_status = { + "type": "command", + "command": status_command, + "refreshInterval": 2, +} +default_settings = load_settings(claude_root / "settings.json") +if default_settings is not None: + existing = default_settings.get("statusLine", object()) + if ( + "statusLine" in default_settings + and existing != canonical_status + and not force + and policy == "install" + ): + raise SystemExit( + "session-kit: refusing unrelated Claude statusLine in " + f"{claude_root / 'settings.json'}; rerun with --force to preserve and replace it" + ) + +try: + profiles = sorted(os.scandir(accounts_root), key=lambda entry: entry.name) +except (FileNotFoundError, NotADirectoryError): + profiles = [] +except OSError as exc: + raise SystemExit(f"session-kit: Claude account profiles are unreadable: {exc}") +for entry in profiles: + if not re.fullmatch(r"[A-Za-z0-9._-]+", entry.name): + continue + try: + if not entry.is_dir(follow_symlinks=False): + continue + except OSError: + continue + profile = accounts_root / entry.name + try: + owner_directory(profile) + except SystemExit: + continue + load_settings(profile / "settings.json") +PY +} + +# Install the two Claude-side executables from the selected release and make +# their settings registrations exact. Other settings and other hooks are +# preserved. Existing enrolled Claude profiles need the same registration as +# the default profile because CLAUDE_CONFIG_DIR replaces, rather than extends, +# the provider config root. +install_claude_integration() { + local release_id=$1 force=${2:-0} policy=${3:-install} source_root claude_root accounts_root + source_root=$install_root/releases/$release_id/config/claude + claude_root=${SESSION_KIT_CLAUDE_HOME:-$HOME/.claude} + accounts_root=${XDG_DATA_HOME:-$HOME/.local/share}/session-kit/accounts/claude + [[ -f $source_root/nameintent_title.sh && ! -L $source_root/nameintent_title.sh && + -f $source_root/statusline.sh && ! -L $source_root/statusline.sh ]] || + die "release Claude integration files are unavailable" + python3 - "$source_root" "$HOME" "$claude_root" "$accounts_root" \ + "$claude_statusline_backups" "$claude_integration_ledger" "$force" \ + "$release_id" "$policy" <<'PY' +import hashlib +import json +import os +import pathlib +import re +import stat +import sys +import tempfile + +source_root, home_raw, claude_raw, accounts_raw, backups_raw, ledger_raw = map( + pathlib.Path, sys.argv[1:7] +) +force = sys.argv[7] == "1" +release_id = sys.argv[8] +policy = sys.argv[9] + + +def owner_directory(path, *, create=False): + try: + info = path.lstat() + except FileNotFoundError: + if not create: + return None + path.mkdir(mode=0o700) + info = path.lstat() + mode = stat.S_IMODE(info.st_mode) + if ( + not stat.S_ISDIR(info.st_mode) + or stat.S_ISLNK(info.st_mode) + or info.st_uid != os.geteuid() + or mode & 0o002 + ): + return None + return path + + +def atomic_bytes(destination, payload, mode): + try: + info = destination.lstat() + except FileNotFoundError: + pass + else: + if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode) or info.st_uid != os.geteuid(): + raise SystemExit(f"session-kit: unsafe Claude integration file: {destination}") + descriptor, temporary = tempfile.mkstemp(prefix=f".{destination.name}.", dir=destination.parent) + try: + os.fchmod(descriptor, mode) + with os.fdopen(descriptor, "wb") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, destination) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + +home = pathlib.Path(home_raw) +claude_root = pathlib.Path(claude_raw) +if not claude_root.is_absolute() or ".." in claude_root.parts: + raise SystemExit("session-kit: Claude home must be an absolute normalized path") +if owner_directory(home) is None: + raise SystemExit("session-kit: HOME is not an owner-controlled directory") +if owner_directory(claude_root, create=True) is None: + raise SystemExit(f"session-kit: Claude home is not an owner-controlled directory: {claude_root}") +hooks_root = owner_directory(claude_root / "hooks", create=True) +if hooks_root is None: + raise SystemExit(f"session-kit: Claude hooks directory is not owner-controlled: {claude_root / 'hooks'}") + +if claude_root == home / ".claude": + hook_command = "~/.claude/hooks/nameintent_title.sh" + status_command = "~/.claude/statusline.sh" +else: + hook_command = str(claude_root / "hooks" / "nameintent_title.sh") + status_command = str(claude_root / "statusline.sh") +owned_hook_commands = { + "~/.claude/hooks/nameintent_title.sh", + str(claude_root / "hooks" / "nameintent_title.sh"), +} +canonical_status = { + "type": "command", + "command": status_command, + "refreshInterval": 2, +} + + +def load_settings(path): + if owner_directory(path.parent) is None: + return None + try: + info = path.lstat() + except FileNotFoundError: + return {}, 0o600 + def skip(reason): + print( + f"session-kit: skipping unusable Claude settings file {path}: {reason}", + file=sys.stderr, + ) + return None + + if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode): + return skip("not a regular file") + if info.st_uid != os.geteuid(): + return skip("not owned by the current user") + if info.st_size > 1_048_576: + return skip("larger than 1 MiB") + try: + settings = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return skip(f"invalid JSON ({exc})") + except (OSError, UnicodeError) as exc: + return skip(f"cannot read as UTF-8 JSON ({exc})") + if not isinstance(settings, dict): + return skip("top level is not an object") + hooks = settings.get("hooks", {}) + if not isinstance(hooks, dict): + return skip("hooks setting is not an object") + for event in ("SessionStart", "UserPromptSubmit", "Stop"): + if not isinstance(hooks.get(event, []), list): + return skip(f"{event} hooks setting is not a list") + return settings, stat.S_IMODE(info.st_mode) + + +def load_backups(path): + try: + info = path.lstat() + except FileNotFoundError: + return {"schema_version": 1, "entries": {}} + if ( + not stat.S_ISREG(info.st_mode) + or stat.S_ISLNK(info.st_mode) + or info.st_uid != os.geteuid() + or info.st_size > 1_048_576 + ): + raise SystemExit(f"session-kit: unsafe Claude status-line backup: {path}") + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise SystemExit(f"session-kit: invalid Claude status-line backup: {path}: {exc}") + if ( + not isinstance(payload, dict) + or payload.get("schema_version") != 1 + or not isinstance(payload.get("entries"), dict) + ): + raise SystemExit(f"session-kit: invalid Claude status-line backup: {path}") + for key, record in payload["entries"].items(): + if ( + not isinstance(key, str) + or not pathlib.Path(key).is_absolute() + or not isinstance(record, dict) + or not isinstance(record.get("present"), bool) + or set(record) - {"present", "value"} + or (record["present"] and "value" not in record) + ): + raise SystemExit(f"session-kit: invalid Claude status-line backup: {path}") + return payload + +def write_settings(path, settings, mode, *, register_statusline): + hooks = settings.setdefault("hooks", {}) + if not isinstance(hooks, dict): + raise SystemExit(f"session-kit: Claude hooks setting must contain an object: {path}") + canonical = {"hooks": [{"type": "command", "command": hook_command}]} + for event in ("SessionStart", "UserPromptSubmit", "Stop"): + groups = hooks.setdefault(event, []) + if not isinstance(groups, list): + raise SystemExit(f"session-kit: Claude {event} hooks must contain a list: {path}") + refreshed = [] + for group in groups: + if not isinstance(group, dict) or not isinstance(group.get("hooks"), list): + refreshed.append(group) + continue + kept = [ + hook + for hook in group["hooks"] + if not ( + isinstance(hook, dict) + and hook.get("type") == "command" + and hook.get("command") in owned_hook_commands + ) + ] + if kept or set(group) != {"hooks"}: + copy = dict(group) + copy["hooks"] = kept + refreshed.append(copy) + refreshed.append(canonical) + hooks[event] = refreshed + if register_statusline: + settings["statusLine"] = canonical_status + payload = (json.dumps(settings, indent=2, sort_keys=True) + "\n").encode() + atomic_bytes(path, payload, mode) + + +settings_paths = [claude_root / "settings.json"] +accounts_root = pathlib.Path(accounts_raw) +try: + account_entries = sorted(os.scandir(accounts_root), key=lambda entry: entry.name) +except FileNotFoundError: + account_entries = [] +except OSError as exc: + raise SystemExit(f"session-kit: Claude account profiles are unreadable: {exc}") +for entry in account_entries: + if not re.fullmatch(r"[A-Za-z0-9._-]+", entry.name): + continue + account_root = accounts_root / entry.name + try: + if not entry.is_dir(follow_symlinks=False): + continue + except OSError: + continue + if owner_directory(account_root) is None: + continue + settings_paths.append(account_root / "settings.json") + +loaded = [] +for settings_path in settings_paths: + item = load_settings(settings_path) + if item is not None: + loaded.append([settings_path, *item, True]) + +backups = load_backups(backups_raw) +entries = backups["entries"] +missing = object() +for item in loaded: + settings_path, settings, _mode, _register_statusline = item + existing = settings.get("statusLine", missing) + if existing is not missing and existing != canonical_status and not force: + item[3] = False + print( + "session-kit: skipping Claude statusLine rewrite in " + f"{settings_path}; unrelated value preserved", + file=sys.stderr, + ) + continue + key = os.fspath(settings_path) + operator_value = existing is not missing and existing != canonical_status + if operator_value or key not in entries: + # Refresh any operator value that this run will replace. An exact kit + # value from a pre-ledger release is kit ownership, not an operator + # preimage; record it as absent only when there is no earlier record. + record = {"present": operator_value} + if operator_value: + record["value"] = existing + entries[key] = record + +title_source = (source_root / "nameintent_title.sh").read_bytes() +status_source = (source_root / "statusline.sh").read_bytes() +atomic_bytes(hooks_root / "nameintent_title.sh", title_source, 0o700) +atomic_bytes(claude_root / "statusline.sh", status_source, 0o700) +atomic_bytes( + backups_raw, + (json.dumps(backups, indent=2, sort_keys=True) + "\n").encode(), + 0o600, +) +ledger = { + "schema_version": 1, + "release_id": release_id, + "files": { + os.fspath(hooks_root / "nameintent_title.sh"): hashlib.sha256(title_source).hexdigest(), + os.fspath(claude_root / "statusline.sh"): hashlib.sha256(status_source).hexdigest(), + }, +} +atomic_bytes( + ledger_raw, + (json.dumps(ledger, indent=2, sort_keys=True) + "\n").encode(), + 0o600, +) +for settings_path, settings, mode, register_statusline in loaded: + write_settings( + settings_path, + settings, + mode, + register_statusline=register_statusline, + ) +PY +} + +# Releases installed before the Claude integration payload was introduced are +# valid rollback targets. A wholly absent payload is that legacy shape; a +# partial payload is corruption and must still fail closed. +release_has_claude_integration() { + local release_id=$1 source_root + source_root=$install_root/releases/$release_id/config/claude + if [[ -f $source_root/nameintent_title.sh && ! -L $source_root/nameintent_title.sh && + -f $source_root/statusline.sh && ! -L $source_root/statusline.sh ]]; then + return 0 + fi + if [[ ! -e $source_root/nameintent_title.sh && ! -L $source_root/nameintent_title.sh && + ! -e $source_root/statusline.sh && ! -L $source_root/statusline.sh ]]; then + return 1 + fi + die "release Claude integration payload is incomplete or unsafe" +} + +# The lock-generation fence is a code capability, not a release-number +# boundary. A retained release is fence-aware only when its StateLock carries +# the complete generation-2 path that recognizes, publishes, and enters through +# the versioned publishing lock. +release_has_inventory_lock_fence() { + local release_id=$1 + python3 - "$install_root/releases/$release_id/lib/sessionkit_inventory/state_io.py" <<'PY' +import ast +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) +except (OSError, UnicodeError, SyntaxError): + raise SystemExit(1) + +constants = {} +state_lock = None +for node in tree.body: + if isinstance(node, (ast.Assign, ast.AnnAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + value = node.value + for target in targets: + if isinstance(target, ast.Name) and isinstance(value, ast.Constant): + constants[target.id] = value.value + elif isinstance(node, ast.ClassDef) and node.name == "StateLock": + state_lock = node + +if state_lock is None: + raise SystemExit(1) +methods = { + node.name: node + for node in state_lock.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) +} +enter = methods.get("__enter__") +enters_versioned_lock = enter is not None and any( + isinstance(node, ast.Attribute) + and node.attr == "_open_versioned_publishing_lock" + for node in ast.walk(enter) +) +aware = ( + constants.get("_PUBLISHING_LOCK_NAME") == "inventory-v2.lock" + and constants.get("_LEGACY_PUBLISHING_LOCK_NAME") == "inventory.lock" + and constants.get("_LEGACY_LOCK_FENCE") + == b"session-kit publishing lock generation 2\n" + and "_legacy_lock_is_fenced" in methods + and "_publish_legacy_lock_fence" in methods + and "_open_versioned_publishing_lock" in methods + and enters_versioned_lock +) +raise SystemExit(0 if aware else 1) +PY +} + +# A rollback to code that only knows inventory.lock must remove the exact +# generation-2 refusal sentinel before `current` can name that code. Serialize +# the durable rollback hold, un-fence, and pointer flip under one versioned-lock +# hold. A generation-two publisher that was already running sees the hold after +# it wakes and refuses instead of re-fencing legacy code. +unfence_inventory_lock_and_select_rollback() { + local target=$1 + python3 - "$state_root" "$install_root/releases/$target" "$install_root/current" <<'PY' +import contextlib +import fcntl +import os +import pathlib +import signal +import stat +import sys +import tempfile +import uuid + +root, target, current = map(pathlib.Path, sys.argv[1:4]) +fence = b"session-kit publishing lock generation 2\n" +rollback_hold = b"session-kit legacy rollback selected\n" +legacy = root / "inventory.lock" +versioned = root / "inventory-v2.lock" +hold = root / "inventory-rollback-v2" + + +def failpoint(name): + if ( + os.environ.get("SESSION_KIT_TESTING") == "1" + and os.environ.get("SESSION_KIT_TEST_FAILPOINT") == name + ): + parent = os.getppid() + if os.environ.get("SESSION_KIT_TEST_FAILPOINT_MODE") == "kill": + os.kill(parent, signal.SIGKILL) + os._exit(137) + raise SystemExit(f"session-kit: isolated test failpoint after {name}") + + +def fsync_directory(path): + directory = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + + +def atomic_regular(path, mode, content): + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=root) + try: + os.fchmod(descriptor, mode) + if content: + os.write(descriptor, content) + os.fsync(descriptor) + os.close(descriptor) + descriptor = -1 + os.replace(temporary, path) + fsync_directory(root) + finally: + if descriptor >= 0: + os.close(descriptor) + with contextlib.suppress(FileNotFoundError): + os.unlink(temporary) + + +def exact_hold_or_missing(): + flags = os.O_RDONLY + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(hold, flags) + except FileNotFoundError: + return False + try: + opened = os.fstat(descriptor) + pathname = hold.lstat() + body = os.read(descriptor, len(rollback_hold) + 1) + finally: + os.close(descriptor) + if ( + not stat.S_ISREG(opened.st_mode) + or stat.S_IMODE(opened.st_mode) != 0o400 + or opened.st_uid != os.geteuid() + or opened.st_nlink != 1 + or (opened.st_dev, opened.st_ino) != (pathname.st_dev, pathname.st_ino) + or body != rollback_hold + ): + raise SystemExit( + f"session-kit: legacy rollback publication hold is unsafe: {hold}" + ) + return True + +try: + root_info = root.lstat() +except OSError as exc: + raise SystemExit(f"session-kit: cannot inspect inventory state directory: {exc}") +if ( + not stat.S_ISDIR(root_info.st_mode) + or stat.S_ISLNK(root_info.st_mode) + or stat.S_IMODE(root_info.st_mode) != 0o700 + or root_info.st_uid != os.geteuid() +): + raise SystemExit( + f"session-kit: inventory state directory is unsafe for rollback: {root}" + ) + +flags = os.O_RDWR | os.O_CREAT +if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC +if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW +try: + lock_fd = os.open(versioned, flags, 0o600) + opened = os.fstat(lock_fd) + pathname = versioned.lstat() + if ( + not stat.S_ISREG(opened.st_mode) + or stat.S_IMODE(opened.st_mode) != 0o600 + or opened.st_uid != os.geteuid() + or opened.st_nlink != 1 + or (opened.st_dev, opened.st_ino) + != (pathname.st_dev, pathname.st_ino) + ): + raise OSError(f"unsafe generation-2 inventory lock: {versioned}") + fcntl.flock(lock_fd, fcntl.LOCK_EX) + pathname = versioned.lstat() + if (opened.st_dev, opened.st_ino) != (pathname.st_dev, pathname.st_ino): + raise OSError(f"generation-2 inventory lock changed: {versioned}") +except OSError as exc: + if "lock_fd" in locals(): + with contextlib.suppress(OSError): + os.close(lock_fd) + raise SystemExit(f"session-kit: cannot serialize inventory un-fence: {exc}") + +try: + read_flags = os.O_RDONLY + if hasattr(os, "O_CLOEXEC"): + read_flags |= os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + read_flags |= os.O_NOFOLLOW + try: + legacy_fd = os.open(legacy, read_flags) + except FileNotFoundError: + legacy_fd = None + legacy_info = None + legacy_body = None + except OSError as exc: + raise SystemExit(f"session-kit: cannot inspect legacy inventory lock: {exc}") + else: + try: + legacy_info = os.fstat(legacy_fd) + legacy_path_info = legacy.lstat() + legacy_body = os.read(legacy_fd, len(fence) + 1) + except OSError as exc: + raise SystemExit( + f"session-kit: cannot inspect legacy inventory lock: {exc}" + ) + finally: + os.close(legacy_fd) + if ( + not stat.S_ISREG(legacy_info.st_mode) + or stat.S_ISLNK(legacy_info.st_mode) + or legacy_info.st_uid != os.geteuid() + or legacy_info.st_nlink != 1 + or (legacy_info.st_dev, legacy_info.st_ino) + != (legacy_path_info.st_dev, legacy_path_info.st_ino) + ): + raise SystemExit( + f"session-kit: legacy inventory lock is unsafe for rollback: {legacy}" + ) + legacy_mode = stat.S_IMODE(legacy_info.st_mode) + if legacy_mode not in (0o400, 0o600) or ( + legacy_mode == 0o400 and legacy_body != fence + ): + raise SystemExit( + f"session-kit: legacy inventory lock is not the exact generation-2 fence: {legacy}" + ) + + if not exact_hold_or_missing(): + atomic_regular(hold, 0o400, rollback_hold) + failpoint("rollback-fence-marked") + + if legacy_info is None or stat.S_IMODE(legacy_info.st_mode) != 0o600: + atomic_regular(legacy, 0o600, b"") + failpoint("rollback-unfenced") + + temporary_link = current.with_name( + f".{current.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp" + ) + try: + os.symlink(target, temporary_link) + os.replace(temporary_link, current) + finally: + with contextlib.suppress(FileNotFoundError): + os.unlink(temporary_link) +finally: + with contextlib.suppress(OSError): + fcntl.flock(lock_fd, fcntl.LOCK_UN) + os.close(lock_fd) +PY +} + +install_defaults() { + local release_id=$1 force=${2:-0} + mkdir -p "$config_root" + chmod 700 "$config_root" 2>/dev/null || true + if [[ ! -e $config_root/projects.tsv ]]; then + install -m 0600 "$install_root/releases/$release_id/config/projects.example.tsv" \ + "$config_root/projects.tsv" + projects_created=1 + fi + [[ -e $config_root/inventory.json ]] || + install -m 0600 "$install_root/releases/$release_id/config/session-inventory.example.json" \ + "$config_root/inventory.json" + if [[ ! -e $shpool_config ]]; then + mkdir -p "$(dirname -- "$shpool_config")" + install -m 0600 "$install_root/releases/$release_id/config/shpool.example.toml" \ + "$shpool_config" + fi + if [[ $(platform) == macos ]]; then + configure_macos_shpool_shell + fi + # Kit-owned sk-*.tmTheme files track the release. Other themes are untouched. + install_codex_themes "$release_id" + # The kit-owned Codex tab title template. The person's config.toml is never + # edited; kit sessions pass this value on their own command line. + install_codex_terminal_title "$release_id" + # The /kit verb, in each provider's own command directory. + install_provider_commands "$release_id" + # The title hook and live status line, including every provider profile's + # registration, track the selected release as one transaction. + install_claude_integration "$release_id" "$force" +} + +configure_macos_shpool_shell() { + local modern_bash + modern_bash=$(command -v bash) + python3 - "$shpool_config" "$modern_bash" <<'PY' +import os +import pathlib +import stat +import sys +import tempfile +import tomllib + +path = pathlib.Path(sys.argv[1]) +shell = pathlib.Path(sys.argv[2]).resolve(strict=True) +info = path.lstat() +if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid(): + raise SystemExit("session-kit: shpool config must be an owner-controlled regular file") +text = path.read_text(encoding="utf-8") +try: + value = tomllib.loads(text) +except tomllib.TOMLDecodeError as exc: + raise SystemExit(f"session-kit: shpool config is invalid: {exc}") +configured = value.get("shell") +if configured is not None and configured != str(shell): + raise SystemExit( + f"session-kit: shpool config selects {configured!r}; macOS requires {str(shell)!r}" + ) +if configured == str(shell): + raise SystemExit(0) +escaped = str(shell).replace("\\", "\\\\").replace('"', '\\"') +updated = f'shell = "{escaped}"\n' + text +fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) +try: + os.fchmod(fd, stat.S_IMODE(info.st_mode)) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(updated) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) +finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass +PY +} + +install_systemd_units() { + local release_id=$1 + [[ $(platform) == linux ]] || return 0 + mkdir -p "$service_root" + local unit shpool_path source + # The unit list comes from the RUNNING kit, and during a rollback that is + # still the release being left -- so it correctly names units the TARGET + # release never carried. Copying one of those aborted the whole transaction + # under `set -euo pipefail`, which meant that the day any release added a + # unit, every earlier release became unreachable: adding a unit removed the + # undo for adding it. The list is right and the file is missing, so a + # missing file is skipped and said, never fatal. + # + # The same loop prunes in the other direction. A unit file left in the + # service root by a newer release is not merely untidy: its program is the + # release that is now current, and that program does not understand the + # arguments the stale unit passes it, so systemd would run a failing job on + # a timer forever. Nothing else prunes this directory. + for unit in "${systemd_units[@]}"; do + # A unit is a plain file name inside the service root. Both halves of this + # loop take the name from the RUNNING kit and join it onto a directory, so + # a name carrying a path would copy a file in from outside the release and + # -- worse, since it deletes -- prune a file from outside $service_root. No + # real release list holds such a name; refusing it costs nothing, and the + # refusal is not fatal for the same reason a missing file is not: this loop + # must never be the thing that makes a release unreachable. + if [[ -z $unit || $unit != "${unit##*/}" || $unit == .* ]]; then + printf 'session-kit: refusing unsafe unit name: %s\n' "$unit" >&2 + continue + fi + source=$install_root/releases/$release_id/systemd/$unit + if [[ ! -f $source ]]; then + if [[ -f $service_root/$unit && ! -L $service_root/$unit ]]; then + command rm -f -- "$service_root/$unit" + printf 'session-kit: %s is not part of this release; its unit file was removed. Disable it with: systemctl --user disable %s\n' \ + "$unit" "$unit" >&2 + else + printf 'session-kit: %s is not part of this release; skipped.\n' "$unit" >&2 + fi + continue + fi + install -m 0644 "$source" "$service_root/$unit" + done + # Templating an absent file is the same fatality by another route: a release + # that does not carry shpool.service leaves nothing here to fill in, and the + # loop above has just made that state survivable. Reading it unconditionally + # would put the abort straight back into the same transaction. + [[ -f $service_root/shpool.service ]] || return 0 + shpool_path=$(command -v shpool) + python3 - "$service_root/shpool.service" "$shpool_path" <<'PY' +import os +import pathlib +import sys +import tempfile + +path = pathlib.Path(sys.argv[1]) +executable = sys.argv[2] +if any(character.isspace() for character in executable): + raise SystemExit("session-kit: shpool path contains whitespace; refusing systemd unit") +text = path.read_text(encoding="utf-8") +if text.count("@SHPOOL@") != 1: + raise SystemExit("session-kit: shpool service template is invalid") +text = text.replace("@SHPOOL@", executable) +fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) +try: + os.fchmod(fd, 0o644) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) +finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass +PY +} + +install_launchd_units() { + local release_id=$1 + [[ $(platform) == macos ]] || return 0 + local shpool_path modern_bash_path + shpool_path=$(command -v shpool) + modern_bash_path=$(command -v bash) + [[ $(shpool version 2>/dev/null) == "shpool 0.11.0" ]] || + die "macOS requires shpool 0.11.0" + python3 - "$shpool_path" <<'PY' +import os,pathlib,stat,sys +path=pathlib.Path(sys.argv[1]) +info=path.stat() +if ( + not path.is_absolute() + or not stat.S_ISREG(info.st_mode) + or info.st_uid != os.geteuid() + or info.st_mode & 0o022 + or not os.access(path, os.X_OK) +): + raise SystemExit("session-kit: shpool must be an owner-controlled, non-writable executable") +PY + mkdir -p "$launchd_template_root" "$state_root/logs" + chmod 700 "$state_root" "$state_root/logs" 2>/dev/null || true + python3 - "$launchd_template_root" "$shpool_path" "$bin_dir/shpool_reaper" \ + "$install_root/current/bin/session_kit_watchdog" "$state_root/logs" \ + "$install_root" "$config_root" "$state_root" "$bin_dir" \ + "${XDG_CONFIG_HOME:-$HOME/.config}" "${XDG_STATE_HOME:-$HOME/.local/state}" \ + "$modern_bash_path" "$shpool_config" <<'PY' +import os +import pathlib +import plistlib +import sys +import tempfile + +( + root, shpool, reaper, watchdog, logs, install_root, config_root, + state_root, bin_dir, xdg_config, xdg_state, modern_bash, shpool_config, +) = map(pathlib.Path, sys.argv[1:]) +for executable in (shpool, reaper, watchdog): + if not executable.is_absolute(): + raise SystemExit(f"session-kit: launchd executable must be absolute: {executable}") +path_value = ":".join( + value for value in ( + str(pathlib.Path.home() / ".local/bin"), + str(pathlib.Path.home() / ".cargo/bin"), + "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", + ) +) +specs = { + "com.session-kit.shpool.plist": { + "Label": "com.session-kit.shpool", + "ProgramArguments": [str(shpool), "--config-file", str(shpool_config), "daemon"], + "KeepAlive": True, + "ProcessType": "Background", + "ThrottleInterval": 10, + }, + "com.session-kit.reaper.plist": { + "Label": "com.session-kit.reaper", + "ProgramArguments": [str(reaper), "--auto-close"], + "StartInterval": 3600, + }, + "com.session-kit.watchdog.plist": { + "Label": "com.session-kit.watchdog", + "ProgramArguments": [str(watchdog), "--once"], + "StartInterval": 60, + "EnvironmentVariables": {"SESSION_KIT_WATCHDOG_MODE": "report"}, + }, +} +for filename, value in specs.items(): + label = value["Label"] + value.setdefault("EnvironmentVariables", {})["PATH"] = path_value + value["EnvironmentVariables"]["HOME"] = str(pathlib.Path.home()) + value["EnvironmentVariables"]["SHELL"] = str(modern_bash) + value["EnvironmentVariables"].update({ + "SESSION_KIT_ROOT": str(install_root), + "SESSION_KIT_CONFIG_ROOT": str(config_root), + "SESSION_KIT_STATE_DIR": str(state_root), + "SESSION_KIT_BIN_DIR": str(bin_dir), + "XDG_CONFIG_HOME": str(xdg_config), + "XDG_STATE_HOME": str(xdg_state), + }) + value["WorkingDirectory"] = str(pathlib.Path.home()) + value["StandardOutPath"] = str(logs / f"{label}.out.log") + value["StandardErrorPath"] = str(logs / f"{label}.err.log") + for key in ("StandardOutPath", "StandardErrorPath"): + log_path = pathlib.Path(value[key]) + descriptor = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + os.close(descriptor) + os.chmod(log_path, 0o600) + path = root / filename + fd, temporary = tempfile.mkstemp(prefix=f".{filename}.", dir=root) + try: + os.fchmod(fd, 0o644) + with os.fdopen(fd, "wb") as handle: + plistlib.dump(value, handle, fmt=plistlib.FMT_XML, sort_keys=True) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass +PY + local unit + for unit in "${launchd_units[@]}"; do + plutil -lint "$launchd_template_root/$unit" >/dev/null || die "invalid launchd plist: $unit" + done +} + +install_platform_services() { + if [[ $(platform) == macos ]]; then + install_launchd_units "$1" + else + install_systemd_units "$1" + fi + refresh_platform_services +} + +# One read-only-to-the-estate systemctl call, over the same seam the tests use. +# A test install must never reach the operator's own user manager, so under the +# test flag nothing runs unless a fixture command is named explicitly. +kit_systemctl() { + local tool=${SESSION_KIT_SYSTEMCTL_CMD:-systemctl} + if [[ ${SESSION_KIT_TESTING:-0} == 1 && -z ${SESSION_KIT_SYSTEMCTL_CMD:-} ]]; then + return 0 + fi + command -v "$tool" >/dev/null 2>&1 || return 0 + "$tool" --user "$@" +} + +# Units are files; a running process is not. An activation writes new unit files +# and moves `current`, and until systemd is told it keeps serving the PREVIOUS +# unit definitions -- while bash, holding the inode it exec'd, keeps running the +# previous release's code. Ten activations passed over one watchdog process +# without a single restart, so every watchdog fix shipped that day was inert and +# `sp doctor` reported green throughout. The unit file said so in a comment and +# nothing enforced it; this is the enforcement. +# +# The session daemon and its socket are never restarted here: that would end +# every managed session. Only kit processes that carry their own code are, and +# only when they are already running -- try-restart starts nothing that the +# operator has not enabled. +refresh_platform_services() { + local unit + if [[ $(platform) == macos ]]; then + for unit in "${launchd_restart_units[@]}"; do + [[ -f $service_root/$unit && ! -L $service_root/$unit ]] || continue + launchctl kickstart -k "gui/$UID/${unit%.plist}" >/dev/null 2>&1 || + printf 'session-kit: %s was not restarted; it keeps running the previous release until it is\n' \ + "${unit%.plist}" >&2 + done + return 0 + fi + [[ $(platform) == linux ]] || return 0 + if ! kit_systemctl daemon-reload >/dev/null 2>&1; then + printf 'session-kit: systemd did not reload the units; run: systemctl --user daemon-reload\n' >&2 + return 0 + fi + for unit in "${systemd_timer_units[@]:-}"; do + [[ -n $unit ]] || continue + [[ -f $service_root/$unit && ! -L $service_root/$unit ]] || continue + # `enable` only when systemd has never been told about this unit at all. + # A timer the operator disabled reports `disabled`, and that answer is + # their -- an update must not quietly turn it back on. + state=$(kit_systemctl is-enabled "$unit" 2>/dev/null || true) + [[ -z $state ]] || continue + kit_systemctl enable --now "$unit" >/dev/null 2>&1 || + printf 'session-kit: %s was installed but not enabled; enable it with: systemctl --user enable --now %s\n' \ + "$unit" "$unit" >&2 + done + for unit in "${systemd_restart_units[@]}"; do + [[ -f $service_root/$unit && ! -L $service_root/$unit ]] || continue + kit_systemctl try-restart "$unit" >/dev/null 2>&1 || + printf 'session-kit: %s was not restarted; it keeps running the previous release until it is; restart it with: systemctl --user restart %s\n' \ + "$unit" "$unit" >&2 + done + return 0 +} + +write_release_manifest() { + python3 - "$1" "$2" <<'PY' +import hashlib +import json +import os +import pathlib +import stat +import sys + +root = pathlib.Path(sys.argv[1]) +commit = sys.argv[2] +meta = root / "RELEASE.json" +meta.write_text( + json.dumps( + { + "schema_version": 1, + "lifecycle_schema_version": 2, + "commit": commit, + }, + indent=2, + sort_keys=True, + ) + "\n", + encoding="utf-8", +) +for path in sorted(root.rglob("*"), reverse=True): + if path.is_symlink(): + raise SystemExit(f"session-kit: release source contains symlink: {path}") + if path.is_file(): + mode = stat.S_IMODE(path.stat().st_mode) + os.chmod(path, 0o555 if mode & 0o111 else 0o444) +lines = [] +for path in sorted((path for path in root.rglob("*") if path.is_file())): + rel = path.relative_to(root).as_posix() + lines.append(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {rel}") +manifest = root / "MANIFEST.sha256" +manifest.write_text("\n".join(lines) + "\n", encoding="utf-8") +os.chmod(manifest, 0o444) +for path in sorted((path for path in root.rglob("*") if path.is_dir()), reverse=True): + os.chmod(path, 0o555) +PY +} + +# Bytecode caches have to belong to the tree they ship in. `cp -R` gives every +# copied file a new mtime, so a cache built while the SOURCE tree was in use +# records a source timestamp the copy does not have, and python then recompiles +# from source on every import -- measured at 112-148 ms where a valid cache +# costs 41 ms, on every `sp` command, every picker frame helper and every +# five-second refresh. Nothing could repair it either: the release directory is +# read-only, and shpool_status exports PYTHONDONTWRITEBYTECODE=1 for that exact +# reason, so the failed cache write was silent and permanent. +# +# Compiling here, from the copied sources, with hash-based invalidation, makes +# the cache valid however the mtimes land -- including after a rollback that +# restores a file, and after any copy that renews a timestamp. A machine without +# compileall still installs; it just pays the compile per call, as it does today. +compile_release_bytecode() { + local staging=$1 + [[ -d $staging/lib ]] || return 0 + # A cache inherited from the source tree is not evidence about this tree. + find "$staging" -type d -name __pycache__ -prune -exec rm -rf -- {} + \ + 2>/dev/null || true + python3 -m compileall -q --invalidation-mode checked-hash -- "$staging/lib" \ + >/dev/null 2>&1 || + printf 'session-kit: bytecode caches were not built; python compiles from source on every call\n' >&2 + return 0 +} + +install_release() { + local source=$1 release_id=$2 + local release_dir=$install_root/releases/$release_id + if [[ -e $release_dir ]]; then + [[ -d $release_dir && ! -L $release_dir ]] || + die "release path is unsafe: $release_dir" + verify_release "$release_dir" "$release_id" + return 0 + fi + mkdir -p "$install_root/releases" + local staging=$install_root/releases/.install-"$release_id"-$$ + [[ ! -e $staging ]] || die "staging path already exists" + mkdir "$staging" + local item + for item in bin lib bashrc config deploy systemd macos shpool-patch extras; do + [[ -e $source/$item ]] || continue + cp -R "$source/$item" "$staging/$item" + done + find "$staging/bin" "$staging/deploy" -type f -exec chmod 0755 {} + + compile_release_bytecode "$staging" + write_release_manifest "$staging" "$release_id" + mv "$staging" "$release_dir" + chmod 0555 "$release_dir" + verify_release "$release_dir" "$release_id" +} + +install_command() { + local source= login=prompt journal=prompt projects=prompt noninteractive=0 force=0 + while (($#)); do + case "$1" in + --source) require_arg "$1" "${2:-}"; source=$2; shift 2 ;; + --enable-login) login=enable; shift ;; + --disable-login) login=disable; shift ;; + --journal) require_arg "$1" "${2:-}"; journal=$2; shift 2 ;; + --import-projects) projects=import; shift ;; + --no-import-projects) projects=skip; shift ;; + --force) force=1; shift ;; + --non-interactive) + noninteractive=1 + shift + ;; + *) die "unknown install option: $1" ;; + esac + done + [[ -n $source ]] || die "install requires --source PATH" + recover_pending_transaction + source=$(cd -- "$source" && pwd -P) || die "source is unavailable" + check_source "$source" + local release_id previous current_platform + release_id=$(release_id_from_source "$source") + previous=$(current_release_id) + current_platform=$(platform) + if [[ $login == prompt ]]; then + # The installer asks nothing. Login integration is the marked default, so + # a person's install takes it and a rollover over an existing installation + # with a validated marker keeps it; the summary at the end states the fact + # and names the one command that turns it off. Disabling (which also + # invalidates the launch marker) stays an explicit --disable-login choice, + # never a silent default, and an unattended install adds nothing to a + # shell it was not asked to touch. + if [[ -t 0 && $noninteractive == 0 ]] || + [[ -L $install_root/current && -f $integration_marker && + ! -L $integration_marker ]]; then + login=enable + else + login=disable + fi + fi + if [[ $journal == prompt ]]; then + if [[ -L $install_root/current ]]; then + # A rollover over an existing installation keeps the operator's current + # journal choice, exactly like the login rollover above. Turning + # journals off must be an explicit --journal off, never a silent + # default: the silent off-default erased all terminal recordings from + # 2026-07-30 to 2026-08-12 and re-armed itself the same afternoon. + journal=preserve + else + # Session history can hold raw commands and output, so recording stays + # off unless it is asked for. The summary at the end states the fact and + # names the way on; automation chooses with --journal on|off. + journal=off + fi + fi + validate_mutable_targets + if [[ -e $install_root/releases/$release_id ]]; then + verify_release "$install_root/releases/$release_id" "$release_id" + fi + preflight_claude_integration "$source/config/claude" "$force" install + python3 "$source/deploy/session-kit-release" seed-floor \ + --state-root "$state_root" \ + --remedy-tool "$source/tools/reset-collection-order.py" >/dev/null + begin_transaction install + install_transaction_pending=1 + trap 'if [[ ${install_transaction_pending:-0} == 1 ]]; then recover_pending_transaction || true; fi' EXIT + install_release "$source" "$release_id" + # Management stays on the newest successfully verified release even when + # the user later rolls the session payload back. This recovery pointer is + # deliberately independent of the selected runtime release. + atomic_symlink "$install_root/releases/$release_id" "$manager_link" + atomic_symlink "$install_root/releases/$release_id" "$install_root/current" + lifecycle_failpoint current + install_launchers "$release_id" + install_completions "$release_id" + install_defaults "$release_id" "$force" + lifecycle_failpoint themes + configure_initial_projects "$projects" "$noninteractive" + install_platform_services "$release_id" + set_journal_choice "$journal" + if [[ $login == enable ]]; then enable_login --quiet; else disable_login --quiet; fi + write_ownership_markers + write_receipt "$release_id" "$source" "$previous" "$current_platform" + commit_transaction + install_transaction_pending=0 + trap - EXIT + printf 'Installed Session Kit release %s.\n' "$release_id" + if [[ -f $integration_marker && ! -L $integration_marker ]]; then + printf 'Login integration is on. Turn it off with: session-kit disable-login\n' + else + printf 'Login integration is off. Turn it on with: session-kit enable-login\n' + fi + if [[ -e $HOME/.no_shpool_journal ]]; then + printf 'History recording is off. Turn it on with: session-kit update --journal on\n' + else + printf 'History recording is on. Turn it off with: session-kit update --journal off\n' + fi + if [[ $current_platform == linux ]]; then + printf 'User services were installed and systemd was reloaded. Start them with:\n' + printf ' systemctl --user enable --now shpool.socket shpool-reaper.timer session-kit-subagent-sweep.timer session-kit-watchdog.service\n' + elif [[ $current_platform == macos ]]; then + printf 'LaunchAgents were installed but not loaded. Review them, then run:\n' + printf ' session-kit services enable\n' + fi + printf 'Run session-kit doctor before opening a new session.\n' +} + +update_command() { + # journal defaults to preserve: an update must never flip recording off + # (or on) behind the operator's back, see install_command's rollover note. + local source= login= journal=preserve force=0 + while (($#)); do + case "$1" in + --source) require_arg "$1" "${2:-}"; source=$2; shift 2 ;; + --enable-login) login=enable; shift ;; + --disable-login) login=disable; shift ;; + --journal) require_arg "$1" "${2:-}"; journal=$2; shift 2 ;; + --force) force=1; shift ;; + *) die "unknown update option: $1" ;; + esac + done + [[ $(platform) != unsupported ]] || die "update is supported only on Linux and macOS" + # Recovery is independent of the next source path. Restore a prior interrupted + # transaction before validating a new or now-missing source. + recover_pending_transaction + if [[ -z $source && -r $receipt_path ]]; then + source=$(python3 - "$receipt_path" <<'PY' +import json,sys +try: + value=json.load(open(sys.argv[1],encoding="utf-8")).get("source","") +except (OSError,ValueError): + value="" +print(value if isinstance(value,str) else "") +PY +) + fi + [[ -n $source ]] || die "update needs --source PATH" + source=$(cd -- "$source" && pwd -P) || die "source is unavailable" + check_source "$source" + if [[ -z $login ]]; then + if [[ -f $integration_marker && ! -L $integration_marker ]]; then + login=enable + else + login=disable + fi + fi + local handoff_args=(--source "$source" --journal "$journal") + case "$login" in + enable) handoff_args+=(--enable-login) ;; + disable) handoff_args+=(--disable-login) ;; + esac + (( force == 0 )) || handoff_args+=(--force) + if [[ ${SESSION_KIT_UPDATE_HANDOFF:-0} != 1 ]]; then + [[ -f $source/bin/session-kit && ! -L $source/bin/session-kit && + -x $source/bin/session-kit ]] || die "source management command is unsafe" + SESSION_KIT_UPDATE_HANDOFF=1 exec "$source/bin/session-kit" update "${handoff_args[@]}" + fi + local args=("${handoff_args[@]}" --non-interactive --no-import-projects) + install_command "${args[@]}" +} + +rollback_command() { + local target= + while (($#)); do + case "$1" in + --to) require_arg "$1" "${2:-}"; target=$2; shift 2 ;; + *) die "unknown rollback option: $1" ;; + esac + done + [[ $(platform) != unsupported ]] || die "rollback is supported only on Linux and macOS" + recover_pending_transaction + if [[ -z $target && -r $receipt_path ]]; then + target=$(python3 - "$receipt_path" <<'PY' +import json,sys +try: + value=json.load(open(sys.argv[1],encoding="utf-8")).get("previous_release","") +except (OSError,ValueError): + value="" +print(value if isinstance(value,str) else "") +PY +) + fi + [[ $target =~ ^[0-9a-f]{40}$ ]] || die "rollback requires a full release commit" + [[ -d $install_root/releases/$target && ! -L $install_root/releases/$target ]] || + die "rollback release is not installed: $target" + verify_release "$install_root/releases/$target" "$target" + local rollback_unfence=0 + if ! release_has_inventory_lock_fence "$target"; then + rollback_unfence=1 + fi + local active_tool=$install_root/current/deploy/session-kit-release + if [[ -f $active_tool && ! -L $active_tool ]]; then + python3 - "$active_tool" "$HOME" "$install_root/releases/$target" <<'PY' +import inspect +import pathlib +import runpy +import sys + +namespace = runpy.run_path(sys.argv[1], run_name="session_kit_release_gate") +gate = namespace["validate_rollback_launch_records"] +# An older active tool has the one-argument legacy-only gate; a target-aware +# tool must be told the target or it refuses every current-family record. +if len(inspect.signature(gate).parameters) >= 2: + gate(pathlib.Path(sys.argv[2]), pathlib.Path(sys.argv[3])) +else: + gate(pathlib.Path(sys.argv[2])) +PY + fi + validate_mutable_targets + local rollback_claude=0 + if release_has_claude_integration "$target"; then + preflight_claude_integration \ + "$install_root/releases/$target/config/claude" 0 rollback + rollback_claude=1 + fi + begin_transaction rollback + # A rollback may select a release that predates provider-hook activation and + # therefore cannot interpret this release's expanded transaction journal. + # Keep recovery pinned to the code that began the flip and restore the whole + # preimage on every ordinary failure before an older launcher can run. + rollback_transaction_pending=1 + trap 'if [[ ${rollback_transaction_pending:-0} == 1 ]]; then recover_pending_transaction || true; fi' EXIT + local previous source= + previous=$(current_release_id) + if [[ -r $receipt_path ]]; then + source=$(python3 - "$receipt_path" <<'PY' +import json,sys +try: + value=json.load(open(sys.argv[1],encoding="utf-8")).get("source","") +except (OSError,ValueError): + value="" +print(value if isinstance(value,str) else "") +PY +) + fi + if (( rollback_unfence )); then + unfence_inventory_lock_and_select_rollback "$target" + else + atomic_symlink "$install_root/releases/$target" "$install_root/current" + fi + lifecycle_failpoint current + install_codex_themes "$target" + # Pre-integration releases have no config/claude payload. They remain valid + # emergency rollback targets; keep the currently installed Claude-side + # integration in place until a release carrying that payload is selected. + if (( rollback_claude )); then + install_claude_integration "$target" 0 rollback + fi + install_codex_terminal_title "$target" + lifecycle_failpoint themes + install_platform_services "$target" + if [[ -f $integration_marker && ! -L $integration_marker ]]; then + printf 'session-kit-integration-v1 %s\n' "$target" > "$integration_marker" + chmod 600 "$integration_marker" + fi + write_ownership_markers + write_receipt "$target" "$source" "$previous" "$(platform)" + # Refresh every stable launcher while the recovery journal still exists. + # The management launcher pins that journal to this pre-flip release, so it + # remains capable of restoring the transaction even though `current` now + # names the rollback target. + install_launchers "$target" + install_completions "$target" + lifecycle_failpoint rollback-precommit + commit_transaction + rollback_transaction_pending=0 + trap - EXIT + # Stable launchers already resolve `current`, and their management entry pins + # recovery to the journal preimage while a transaction is pending. No + # post-commit launcher rewrite exists, so there is no kill window between + # journal removal and recovery-capable command installation. + lifecycle_failpoint rollback-postcommit + lifecycle_failpoint rollback-launchers + printf 'Rolled back Session Kit to %s. Sessions kept running; a running watchdog was restarted onto this release.\n' "$target" +} diff --git a/lib/sh/session_kit_lifecycle.sh b/lib/sh/session_kit_lifecycle.sh new file mode 100644 index 0000000..0e393e5 --- /dev/null +++ b/lib/sh/session_kit_lifecycle.sh @@ -0,0 +1,766 @@ +#!/usr/bin/env bash +# Session Kit lifecycle transactions, receipts, and Codex theme layout. +# Source this file; do not execute it. +# +# Source order: bin/session-kit sources this module after it assigns the +# lifecycle roots, integration_marker, receipt_path, shpool_config, helpers, +# systemd_units, launchd_units, launchd_template_root, transaction_path, and +# codex_theme_names, and after it defines die() and platform(). The functions +# here also call validate_roots() from lib/sh/session_kit_checks.sh. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +# A stable launcher from a pre-hook release can source this module after an +# interrupted pointer flip. Supply the two newer globals here so that recovery +# remains usable even though that older entry script never assigned them. +claude_settings=${claude_settings:-${SESSION_KIT_CLAUDE_SETTINGS:-${SESSION_KIT_CLAUDE_HOME:-"$HOME/.claude"}/settings.json}} +codex_hooks=${codex_hooks:-${SESSION_KIT_CODEX_HOOKS:-${SESSION_KIT_CODEX_HOME:-${CODEX_HOME:-"$HOME/.codex"}}/hooks.json}} +claude_statusline_backups=${claude_statusline_backups:-${SESSION_KIT_STATE_DIR:-${XDG_STATE_HOME:-"$HOME/.local/state"}/session-kit}/claude-statusline-backups.json} +claude_integration_ledger=${claude_integration_ledger:-${SESSION_KIT_STATE_DIR:-${XDG_STATE_HOME:-"$HOME/.local/state"}/session-kit}/claude-integration.json} + +atomic_symlink() { + local target=$1 destination=$2 + python3 - "$target" "$destination" <<'PY' +import os +import pathlib +import sys +import uuid + +target, destination = sys.argv[1:3] +path = pathlib.Path(destination) +path.parent.mkdir(parents=True, exist_ok=True) +temporary = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp") +try: + os.symlink(target, temporary) + os.replace(temporary, path) +finally: + try: + temporary.unlink() + except FileNotFoundError: + pass +PY +} + +codex_theme_layout() { + local action=$1 custom=0 + if [[ -n ${SESSION_KIT_CODEX_HOME:-} || -n ${CODEX_HOME:-} ]]; then + custom=1 + fi + python3 - "$action" "$HOME" \ + "${SESSION_KIT_CODEX_HOME:-${CODEX_HOME:-$HOME/.codex}}" \ + "$custom" "${codex_theme_names[@]}" <<'PY' +import grp +import os +import pathlib +import pwd +import stat +import sys + + +# One rule, applied wherever this file decides whether a provider directory may +# be written through: no other account may be +# able to write it. A distribution that gives every account a private group and +# a 002 umask leaves a provider's own config directory group-writable, which +# exposes it to nobody, so that one shape is allowed. Every condition must hold +# and any lookup failure refuses. +def private_group(gid): + try: + account = pwd.getpwuid(os.geteuid()) + group = grp.getgrgid(gid) + accounts = pwd.getpwall() + except (KeyError, OSError): + return False + if gid != account.pw_gid or group.gr_name != account.pw_name or list(group.gr_mem): + return False + covered = False + for other in accounts: + if other.pw_name == account.pw_name: + covered = other.pw_gid == gid + elif other.pw_gid == gid: + return False + return covered + + +def mode_permits(info): + mode = stat.S_IMODE(info.st_mode) + if mode & 0o002: + return False + if not mode & 0o020: + return True + return info.st_uid == os.geteuid() and private_group(info.st_gid) + + +def write_repair(path, info): + """Name the chmod that repairs a directory the caller owns.""" + mode = stat.S_IMODE(info.st_mode) + if stat.S_ISDIR(info.st_mode) and info.st_uid == os.geteuid() and mode & 0o022: + return f"; run: chmod {'go-w' if mode & 0o002 else 'g-w'} {path}" + return "" + + +action, home_raw, root_raw, custom, *colors = sys.argv[1:] +home = pathlib.Path(home_raw) +root = pathlib.Path(root_raw) +default_root = home / ".codex" + +if not root.is_absolute() or ".." in root.parts: + raise SystemExit("session-kit: Codex home must be an absolute normalized path") + + +def directory_info(path: pathlib.Path): + try: + info = path.lstat() + except FileNotFoundError: + return None + if not ( + stat.S_ISDIR(info.st_mode) + and not stat.S_ISLNK(info.st_mode) + and info.st_uid in {0, os.geteuid()} + and mode_permits(info) + ): + raise SystemExit( + f"session-kit: unsafe Codex path ancestor: {path}{write_repair(path, info)}" + ) + return info + + +def validate_chain(path: pathlib.Path) -> None: + try: + relative = path.relative_to(home) + except ValueError: + base = pathlib.Path(path.anchor) + relative = path.relative_to(base) + else: + base_info = directory_info(home) + if base_info is None or base_info.st_uid != os.geteuid(): + raise SystemExit("session-kit: HOME is not an owner-controlled directory") + base = home + if base == pathlib.Path(path.anchor) and directory_info(base) is None: + raise SystemExit("session-kit: Codex path root is unavailable") + current = base + for part in relative.parts: + current = current / part + if directory_info(current) is None: + break + + +validate_chain(root) +root_info = directory_info(root) +if root_info is None: + if custom == "1" or root != default_root: + raise SystemExit( + "session-kit: custom Codex home must already be an owner-controlled directory" + ) + if directory_info(root.parent) is None: + raise SystemExit("session-kit: Codex home parent is unsafe") + if action == "create": + root.mkdir(mode=0o700) + +if directory_info(root) is not None and root.lstat().st_uid != os.geteuid(): + raise SystemExit("session-kit: Codex home must be owned by the current user") + +themes = root / "themes" +validate_chain(themes) +themes_info = directory_info(themes) +if themes_info is None: + if action == "create": + themes.mkdir(mode=0o700) +elif themes_info.st_uid != os.geteuid(): + raise SystemExit("session-kit: Codex themes must be owned by the current user") + +if action == "create": + if directory_info(themes) is None: + raise SystemExit("session-kit: Codex themes directory could not be secured") + print(themes) +elif action == "validate": + pass +elif action == "targets": + for color in colors: + print(themes / f"sk-{color}.tmTheme") +elif action == "root": + print(root) +else: + raise SystemExit("session-kit: invalid Codex theme layout action") +PY +} + +lifecycle_transaction() { + python3 - "$@" <<'PY' +import base64 +import grp +import json +import os +import pathlib +import pwd +import re +import stat +import sys +import tempfile +import time +import uuid + + +# One rule, applied wherever this file decides whether a provider directory may +# be written through: no other account may be +# able to write it. A distribution that gives every account a private group and +# a 002 umask leaves a provider's own config directory group-writable, which +# exposes it to nobody, so that one shape is allowed. Every condition must hold +# and any lookup failure refuses. +def private_group(gid): + try: + account = pwd.getpwuid(os.geteuid()) + group = grp.getgrgid(gid) + accounts = pwd.getpwall() + except (KeyError, OSError): + return False + if gid != account.pw_gid or group.gr_name != account.pw_name or list(group.gr_mem): + return False + covered = False + for other in accounts: + if other.pw_name == account.pw_name: + covered = other.pw_gid == gid + elif other.pw_gid == gid: + return False + return covered + + +def mode_permits(info): + mode = stat.S_IMODE(info.st_mode) + if mode & 0o002: + return False + if not mode & 0o020: + return True + return info.st_uid == os.geteuid() and private_group(info.st_gid) + + +def write_repair(path, info): + """Name the chmod that repairs a directory the caller owns.""" + mode = stat.S_IMODE(info.st_mode) + if stat.S_ISDIR(info.st_mode) and info.st_uid == os.geteuid() and mode & 0o022: + return f"; run: chmod {'go-w' if mode & 0o002 else 'g-w'} {path}" + return "" + + +operation, journal_raw, *values = sys.argv[1:] +journal = pathlib.Path(journal_raw) + +def fsync_parent(path): + descriptor = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + +def atomic_json(path, payload): + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + fsync_parent(path) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + +def is_claude_settings(path): + home = pathlib.Path.home() + claude_root = pathlib.Path( + os.environ.get("SESSION_KIT_CLAUDE_HOME", home / ".claude") + ) + default_settings = pathlib.Path( + os.environ.get( + "SESSION_KIT_CLAUDE_SETTINGS", claude_root / "settings.json" + ) + ) + if path == default_settings: + return True + accounts_root = pathlib.Path( + os.environ.get("XDG_DATA_HOME", home / ".local/share") + ) / "session-kit" / "accounts" / "claude" + try: + relative = path.relative_to(accounts_root) + except ValueError: + return False + return bool( + len(relative.parts) == 2 + and relative.parts[1] == "settings.json" + and re.fullmatch(r"[A-Za-z0-9._-]+", relative.parts[0]) + ) + +def capture(path): + try: + info = path.lstat() + except FileNotFoundError: + return {"path": str(path), "kind": "absent"} + if stat.S_ISLNK(info.st_mode): + return {"path": str(path), "kind": "symlink", "target": os.readlink(path)} + if stat.S_ISREG(info.st_mode): + # Claude settings owned by another account are deliberately skipped by + # the integration writer. Do not read or later replace them merely to + # journal a transaction that cannot change them. + if info.st_uid != os.geteuid() and is_claude_settings(path): + return {"path": str(path), "kind": "untouched"} + return { + "path": str(path), + "kind": "file", + "mode": stat.S_IMODE(info.st_mode), + "content": base64.b64encode(path.read_bytes()).decode("ascii"), + } + raise SystemExit(f"session-kit: transaction target is unsafe: {path}") + +def restore(entry): + path = pathlib.Path(entry["path"]) + kind = entry["kind"] + if kind == "untouched": + return + try: + info = path.lstat() + except FileNotFoundError: + info = None + if info is not None and not (stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode)): + raise SystemExit(f"session-kit: refusing incompatible transaction state: {path}") + if info is not None: + path.unlink() + if kind == "absent": + return + path.parent.mkdir(parents=True, exist_ok=True) + if kind == "symlink": + os.symlink(entry["target"], path) + elif kind == "file": + data = base64.b64decode(entry["content"], validate=True) + fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + os.fchmod(fd, int(entry["mode"])) + with os.fdopen(fd, "wb") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + else: + raise SystemExit("session-kit: invalid lifecycle transaction entry") + +if operation == "begin": + if journal.exists() or journal.is_symlink(): + raise SystemExit(f"session-kit: pending lifecycle transaction: {journal}") + entries = [capture(pathlib.Path(value)) for value in values[1:]] + payload = { + "schema_version": 1, + "transaction_id": uuid.uuid4().hex, + "action": values[0], + "started_at_unix": int(time.time()), + "state": "pending", + "entries": entries, + } + atomic_json(journal, payload) +elif operation == "recover": + if not journal.exists() and not journal.is_symlink(): + raise SystemExit(0) + info = journal.lstat() + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600: + raise SystemExit(f"session-kit: unsafe lifecycle transaction journal: {journal}") + try: + payload = json.loads(journal.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"session-kit: invalid lifecycle transaction journal: {exc}") + if payload.get("schema_version") != 1 or payload.get("state") != "pending": + raise SystemExit("session-kit: incompatible lifecycle transaction state") + entries = payload.get("entries") + if not isinstance(entries, list): + raise SystemExit("session-kit: invalid lifecycle transaction entries") + paths = [entry.get("path") for entry in entries if isinstance(entry, dict)] + base_count = len(values) - 2 + if len(paths) != len(entries) or paths[:base_count] != values[:base_count]: + raise SystemExit("session-kit: lifecycle transaction targets do not match this installation") + + def validate_recorded_config(path_raw, expected_name): + path = pathlib.Path(path_raw) + if not path.is_absolute() or path.name != expected_name: + raise SystemExit("session-kit: invalid recorded provider hook target") + home = pathlib.Path.home() + try: + relative = path.parent.relative_to(home) + except ValueError: + current = pathlib.Path(path.anchor) + relative = path.parent.relative_to(current) + else: + current = home + for part in (".", *relative.parts): + if part != ".": + current = current / part + try: + ancestor = current.lstat() + except FileNotFoundError: + break + if ( + not stat.S_ISDIR(ancestor.st_mode) + or stat.S_ISLNK(ancestor.st_mode) + or ancestor.st_uid not in {0, os.geteuid()} + or not mode_permits(ancestor) + ): + raise SystemExit( + "session-kit: unsafe recorded provider hook ancestor: " + f"{current}{write_repair(current, ancestor)}" + ) + + core_count = base_count + if len(paths) >= base_count + 2: + possible_settings, possible_hooks = paths[base_count : base_count + 2] + if ( + isinstance(possible_settings, str) + and isinstance(possible_hooks, str) + and pathlib.Path(possible_settings).name == "settings.json" + and pathlib.Path(possible_hooks).name == "hooks.json" + ): + validate_recorded_config(possible_settings, "settings.json") + validate_recorded_config(possible_hooks, "hooks.json") + core_count += 2 + # Releases after provider-hook activation also transaction the two + # kit-owned Claude files and every enrolled Claude profile's settings. + # They are appended after themes so an older pending journal retains the + # exact prefix the recovery code above already understands. + trailing_entries = entries[core_count:] + claude_root = pathlib.Path( + os.environ.get("SESSION_KIT_CLAUDE_HOME", pathlib.Path.home() / ".claude") + ) + accounts_root = pathlib.Path( + os.environ.get("XDG_DATA_HOME", pathlib.Path.home() / ".local/share") + ) / "session-kit" / "accounts" / "claude" + + def recorded_claude_integration(path_raw): + path = pathlib.Path(path_raw) + state_root = pathlib.Path(os.environ.get( + "SESSION_KIT_STATE_DIR", + pathlib.Path(os.environ.get("XDG_STATE_HOME", pathlib.Path.home() / ".local/state")) / "session-kit", + )) + if path in { + state_root / "claude-statusline-backups.json", + state_root / "claude-integration.json", + }: + validate_recorded_config(path_raw, path.name) + return True + if path in { + claude_root / "hooks" / "nameintent_title.sh", + claude_root / "statusline.sh", + }: + validate_recorded_config(path_raw, path.name) + return True + try: + relative = path.relative_to(accounts_root) + except ValueError: + return False + if ( + len(relative.parts) == 2 + and relative.parts[1] == "settings.json" + and re.fullmatch(r"[A-Za-z0-9._-]+", relative.parts[0]) + ): + validate_recorded_config(path_raw, "settings.json") + return True + return False + + seen_integration = set() + while trailing_entries: + candidate = trailing_entries[-1] + candidate_path = candidate.get("path") if isinstance(candidate, dict) else None + if not isinstance(candidate_path, str) or not recorded_claude_integration(candidate_path): + break + if candidate_path in seen_integration: + raise SystemExit("session-kit: duplicate Claude integration transaction target") + seen_integration.add(candidate_path) + trailing_entries.pop() + theme_entries = trailing_entries + expected_themes = [ + f"sk-{color}.tmTheme" + for color in ( + "red", "blue", "green", "yellow", "purple", "orange", "pink", "cyan", + "lime", "magenta", "silver", "sand", "sky", "sea", + ) + ] + if theme_entries: + theme_paths = [pathlib.Path(entry["path"]) for entry in theme_entries] + recorded_names = [path.name for path in theme_paths] + # A transaction records the themes the release it ran for actually + # shipped, and this list is the union across releases. Requiring the + # whole list would refuse to recover a transaction written by a release + # with a smaller palette, so require the recorded names to appear in + # canonical order without gaps in meaning -- a subsequence -- and to be + # distinct. An unknown or reordered name still fails. + remaining = list(expected_themes) + ordered = True + for name in recorded_names: + while remaining and remaining[0] != name: + remaining.pop(0) + if not remaining: + ordered = False + break + remaining.pop(0) + parents = {path.parent for path in theme_paths} + if ( + not ordered + or not recorded_names + or len(set(recorded_names)) != len(recorded_names) + or len(parents) != 1 + or next(iter(parents)).name != "themes" + ): + raise SystemExit("session-kit: invalid recorded theme transaction targets") + parent = next(iter(parents)) + home = pathlib.Path.home() + try: + relative = parent.relative_to(home) + except ValueError: + current = pathlib.Path(parent.anchor) + relative = parent.relative_to(current) + else: + current = home + for part in (".", *relative.parts): + if part != ".": + current = current / part + try: + ancestor = current.lstat() + except FileNotFoundError: + raise SystemExit(f"session-kit: recorded theme ancestor is missing: {current}") + if ( + not stat.S_ISDIR(ancestor.st_mode) + or stat.S_ISLNK(ancestor.st_mode) + or ancestor.st_uid not in {0, os.geteuid()} + or not mode_permits(ancestor) + ): + raise SystemExit( + "session-kit: unsafe recorded theme ancestor: " + f"{current}{write_repair(current, ancestor)}" + ) + for entry in reversed(entries): + if not isinstance(entry, dict) or not isinstance(entry.get("path"), str): + raise SystemExit("session-kit: invalid lifecycle transaction entry") + restore(entry) + journal.unlink() + fsync_parent(journal) + print(f"Recovered interrupted Session Kit {payload.get('action', 'lifecycle')} transaction.") +elif operation == "commit": + if not journal.is_file() or journal.is_symlink(): + raise SystemExit(f"session-kit: missing lifecycle transaction journal: {journal}") + payload = json.loads(journal.read_text(encoding="utf-8")) + if payload.get("schema_version") != 1 or payload.get("state") != "pending": + raise SystemExit("session-kit: incompatible lifecycle transaction state") + payload["state"] = "committed" + payload["committed_at_unix"] = int(time.time()) + backup_root = journal.parent / "backups" + backup_root.mkdir(parents=True, exist_ok=True) + os.chmod(backup_root, 0o700) + backup = backup_root / ( + f"lifecycle-{payload['committed_at_unix']}-{payload['transaction_id']}.json" + ) + atomic_json(backup, payload) + journal.unlink() + fsync_parent(journal) +else: + raise SystemExit("session-kit: invalid lifecycle transaction operation") +PY +} + +recover_pending_transaction() { + validate_roots + local -a targets=() + mapfile -t targets < <(transaction_core_targets) + lifecycle_transaction recover "$transaction_path" "${targets[@]}" +} + +transaction_core_targets() { + local unit helper + printf '%s\n' \ + "$install_root/current" \ + "$bin_dir/session-kit" \ + "$bashrc_path" \ + "$bash_profile_path" \ + "$zshrc_path" \ + "$integration_marker" \ + "$HOME/.no_shpool_journal" \ + "$receipt_path" \ + "$install_root/.session-kit-owned.json" \ + "$config_root/.session-kit-owned.json" + printf '%s\n' \ + "$config_root/projects.tsv" \ + "$config_root/inventory.json" \ + "$shpool_config" + for helper in "${helpers[@]}"; do printf '%s\n' "$bin_dir/$helper"; done + if [[ $(platform) == macos ]]; then + for unit in "${launchd_units[@]}"; do printf '%s\n' "$launchd_template_root/$unit"; done + else + for unit in "${systemd_units[@]}"; do printf '%s\n' "$service_root/$unit"; done + fi + # New transaction targets are appended so a release predating provider-hook + # activation still writes a recoverable prefix if it flips to this release. + printf '%s\n' "$claude_settings" "$codex_hooks" +} + +transaction_targets() { + transaction_core_targets + codex_theme_layout targets + claude_integration_targets +} + +# Claude reads these files outside the kit's release and config roots. Record +# every one before an install changes it, including the settings of already +# enrolled account profiles. The default settings file is already a legacy +# core target, so it is deliberately not repeated here. +claude_integration_targets() { + local claude_root accounts_root account alias + claude_root=${SESSION_KIT_CLAUDE_HOME:-$HOME/.claude} + printf '%s\n' \ + "$claude_root/hooks/nameintent_title.sh" \ + "$claude_root/statusline.sh" + accounts_root=${XDG_DATA_HOME:-$HOME/.local/share}/session-kit/accounts/claude + if [[ -d $accounts_root && ! -L $accounts_root ]]; then + for account in "$accounts_root"/*; do + [[ -d $account && ! -L $account ]] || continue + alias=${account##*/} + [[ $alias =~ ^[A-Za-z0-9._-]+$ ]] || continue + printf '%s\n' "$account/settings.json" + done + fi + printf '%s\n' "$claude_statusline_backups" "$claude_integration_ledger" +} + +begin_transaction() { + local action=$1 + shift + umask 077 + mkdir -p "$state_root" + chmod 700 "$state_root" 2>/dev/null || true + codex_theme_layout validate + local -a targets=() + mapfile -t targets < <(transaction_targets) + lifecycle_transaction begin "$transaction_path" "$action" "${targets[@]}" "$@" +} + +commit_transaction() { + lifecycle_transaction commit "$transaction_path" +} + +# True only when the isolated test harness armed exactly this failpoint. +# +# A failpoint aborts an install or update at a chosen instruction so a test can +# prove the transaction recovers. Production must never honour one: without the +# SESSION_KIT_TESTING gate, any process able to set an environment variable +# could abort every install mid-transaction just by naming a failpoint. Both +# conditions are checked in this one place so no caller can arm a failpoint by +# checking only the name. +lifecycle_failpoint_armed() { + [[ ${SESSION_KIT_TESTING:-0} == 1 && ${SESSION_KIT_TEST_FAILPOINT:-} == "$1" ]] +} + +lifecycle_failpoint() { + lifecycle_failpoint_armed "$1" || return 0 + if [[ ${SESSION_KIT_TEST_FAILPOINT_MODE:-} == kill ]]; then + kill -KILL "$$" + fi + die "isolated test failpoint after $1" +} + +write_receipt() { + local release_id=$1 source=$2 previous=$3 current_platform=$4 + umask 077 + mkdir -p "$state_root" + chmod 700 "$state_root" 2>/dev/null || true + python3 - "$receipt_path" "$release_id" "$source" "$previous" "$current_platform" <<'PY' +import json +import os +import pathlib +import sys +import tempfile +import time + +path, release_id, source, previous, platform = sys.argv[1:6] +destination = pathlib.Path(path) +payload = { + "schema_version": 2, + "installed_release": release_id, + "previous_release": previous or None, + "source": source, + "platform": platform, + "installed_at_unix": int(time.time()), + "roots": { + "install": os.environ.get("SESSION_KIT_ROOT", str(pathlib.Path.home() / ".local/lib/session-kit")), + "config": os.environ.get( + "SESSION_KIT_CONFIG_ROOT", + str(pathlib.Path(os.environ.get("XDG_CONFIG_HOME", pathlib.Path.home() / ".config")) / "session-kit"), + ), + }, +} +fd, temporary = tempfile.mkstemp(prefix=f".{destination.name}.", dir=destination.parent) +try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, destination) +finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass +PY +} + +write_ownership_markers() { + local root marker kind + for kind in install config; do + if [[ $kind == install ]]; then root=$install_root; else root=$config_root; fi + marker=$root/.session-kit-owned.json + umask 077 + mkdir -p "$root" + chmod 700 "$root" 2>/dev/null || true + python3 - "$marker" "$root" "$kind" <<'PY' +import json +import os +import pathlib +import tempfile +import sys + +marker, root, kind = map(pathlib.Path, sys.argv[1:4]) +payload = { + "schema_version": 1, + "owner": "session-kit", + "kind": str(kind), + "root": str(root.resolve(strict=False)), +} +fd, temporary = tempfile.mkstemp(prefix=f".{marker.name}.", dir=marker.parent) +try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, marker) +finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass +PY + done +} + +current_release_id() { + local resolved + [[ -L $install_root/current ]] || return 0 + resolved=$(cd -P -- "$install_root/current" 2>/dev/null && pwd) || return 0 + case "$resolved" in + "$install_root"/releases/*) printf '%s\n' "${resolved##*/}" ;; + esac +} diff --git a/lib/sh/session_kit_login.sh b/lib/sh/session_kit_login.sh new file mode 100644 index 0000000..a77cc1f --- /dev/null +++ b/lib/sh/session_kit_login.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# Session Kit shell login integration and journal opt-in. +# Source this file; do not execute it. +# +# Source order: bin/session-kit sources this module after it assigns +# bashrc_path, bash_profile_path, zshrc_path, login_start, login_end, +# state_root, install_root, and integration_marker, and after it defines die() +# and platform(). enable_login() also calls current_release_id() from +# lib/sh/session_kit_lifecycle.sh. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +rewrite_login() { + local action=$1 shell_path shell_kind + local -a shell_specs=("$bashrc_path:bash") + [[ $(platform) != macos ]] || shell_specs+=("$bash_profile_path:bash-login" "$zshrc_path:zsh") + for shell_kind in "${shell_specs[@]}"; do + shell_path=${shell_kind%:*} + shell_kind=${shell_kind##*:} + mkdir -p "$(dirname -- "$shell_path")" + if [[ $action == disable && ! -e $shell_path && ! -L $shell_path ]]; then + continue + fi + [[ -e $shell_path ]] || : > "$shell_path" + [[ -f $shell_path && ! -L $shell_path ]] || + die "refusing to modify non-regular shell file: $shell_path" + python3 - "$shell_path" "$action" "$login_start" "$login_end" "$shell_kind" <<'PY' +import os +import pathlib +import sys +import tempfile + +path_raw, action, start, end, shell_kind = sys.argv[1:6] +path = pathlib.Path(path_raw) +text = path.read_text(encoding="utf-8") +begin = text.find(start) +finish = text.find(end) +if (begin < 0) != (finish < 0) or (begin >= 0 and finish < begin): + raise SystemExit("session-kit: shell integration markers are incomplete; refusing to edit") +if begin >= 0: + finish += len(end) + if finish < len(text) and text[finish] == "\n": + finish += 1 + before = text[:begin].rstrip("\n") + after = text[finish:].lstrip("\n") + text = before + ("\n" if before and after else "") + after +if action == "enable": + if shell_kind in {"bash", "bash-login"}: + body = ( + 'if [[ -r "$HOME/.local/lib/session-kit/current/bashrc/shpool.bashrc" ]]; then\n' + ' source "$HOME/.local/lib/session-kit/current/bashrc/shpool.bashrc"\n' + "fi\n" + ) + else: + body = ( + 'export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"\n' + ) + block = f"{start}\n{body}{end}\n" + text = text.rstrip("\n") + ("\n\n" if text.strip() else "") + block +fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) +try: + os.fchmod(fd, path.stat().st_mode & 0o777) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) +finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass +PY + done +} + +backup_login_once() { + local backup_root=$state_root/backups shell_path shell_name backup absent + umask 077 + mkdir -p "$backup_root" + chmod 700 "$backup_root" 2>/dev/null || true + local -a shell_specs=("$bashrc_path:bashrc") + [[ $(platform) != macos ]] || shell_specs+=("$bash_profile_path:bash-profile" "$zshrc_path:zshrc") + for shell_name in "${shell_specs[@]}"; do + shell_path=${shell_name%:*} + shell_name=${shell_name##*:} + backup=$backup_root/$shell_name.before-session-kit + absent=$backup_root/$shell_name.was-absent + [[ -e $backup || -e $absent ]] && continue + if [[ -e $shell_path || -L $shell_path ]]; then + [[ -f $shell_path && ! -L $shell_path ]] || + die "refusing to back up non-regular shell file: $shell_path" + install -m 0600 "$shell_path" "$backup" + else + : > "$absent" + chmod 600 "$absent" + fi + done +} + +set_journal_choice() { + case "$1" in + on) + if [[ -e $HOME/.no_shpool_journal ]]; then + [[ -f $HOME/.no_shpool_journal && ! -L $HOME/.no_shpool_journal ]] || + die "refusing to replace unsafe journal sentinel" + command rm -- "$HOME/.no_shpool_journal" + fi + ;; + off) + if [[ -e $HOME/.no_shpool_journal ]]; then + [[ -f $HOME/.no_shpool_journal && ! -L $HOME/.no_shpool_journal ]] || + die "refusing to replace unsafe journal sentinel" + else + (umask 077; : > "$HOME/.no_shpool_journal") + fi + ;; + preserve) + # Keep whatever the operator has: sentinel present stays present, + # absent stays absent. Rollovers and updates land here by default. + ;; + *) die "--journal must be on, off, or preserve" ;; + esac +} + +enable_login() { + backup_login_once + rewrite_login enable + if [[ -L $install_root/current ]]; then + local release_id + release_id=$(current_release_id) + [[ $release_id =~ ^[0-9a-f]{40}$ ]] || die "active release is invalid" + umask 077 + mkdir -p "$state_root" + printf 'session-kit-integration-v1 %s\n' "$release_id" > "$integration_marker" + chmod 600 "$integration_marker" + fi + [[ ${1:-} == --quiet ]] || + printf 'Login integration is on. Turn it off with: session-kit disable-login\n' +} + +disable_login() { + rewrite_login disable + if [[ -e $integration_marker ]]; then + [[ -f $integration_marker && ! -L $integration_marker ]] || + die "refusing to remove unsafe integration marker" + command rm -- "$integration_marker" + fi + [[ ${1:-} == --quiet ]] || + printf 'Login integration is off. Existing sessions and history were not changed.\n' +} diff --git a/lib/sh/session_kit_projects.sh b/lib/sh/session_kit_projects.sh new file mode 100644 index 0000000..1045838 --- /dev/null +++ b/lib/sh/session_kit_projects.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Session Kit project discovery and first-install import prompt. +# Source this file; do not execute it. +# +# Source order: bin/session-kit sources this module after it assigns +# install_root, config_root, state_root, and projects_created, and after it +# defines die(). install_defaults() sets projects_created before +# configure_initial_projects() reads it. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +projects_tool() { + local release=${1:-$install_root/current} + shift || true + local tool=$release/lib/sessionkit_inventory/projects.py + [[ -f $tool && ! -L $tool ]] || die "project discovery tool is unavailable" + python3 "$tool" --projects-file "$config_root/projects.tsv" \ + --state-dir "$state_root" "$@" +} + +# A project's manifest is a different store from the host's project list, and +# a different program reads it. Both are `session-kit projects <verb>` to the +# person typing, because a startup command a person cannot review or approve +# is a startup command that can never run: the verbs existed and nothing +# reached them. +projects_manifest_tool() { + local release=${1:-$install_root/current} + shift || true + local tool=$release/lib/sessionkit_projects/cli.py + [[ -f $tool && ! -L $tool ]] || die "the project manifest tool is unavailable" + python3 "$tool" --projects-file "$config_root/projects.tsv" \ + --state-dir "$state_root" "$@" +} + +projects_command() { + case "${1:-}" in + launch-plan | approve-startup | check | resolve | context | group-sessions) + projects_manifest_tool "$install_root/current" "$@" + ;; + *) + projects_tool "$install_root/current" "$@" + ;; + esac +} + +configure_initial_projects() { + local choice=$1 noninteractive=$2 answer count discovery + (( projects_created )) || return 0 + if [[ $choice == import ]]; then + projects_tool "$install_root/current" import + return + fi + if [[ $choice == skip || $noninteractive == 1 || ! -t 0 ]]; then + printf 'Project import skipped. Run session-kit projects import whenever you are ready.\n' + return + fi + discovery=$(mktemp "$state_root/project-discovery.XXXXXX") + if ! projects_tool "$install_root/current" --json candidates > "$discovery"; then + command rm -f -- "$discovery" + printf 'Project discovery was unavailable. Run session-kit projects import later.\n' >&2 + return + fi + count=$(python3 - "$discovery" <<'PY' +import json,sys +value=json.load(open(sys.argv[1],encoding="utf-8")) +print(len(value.get("candidates",[]))) +for warning in value.get("warnings",[]): + print(f"Warning: {warning}",file=sys.stderr) +PY +) + if (( count == 0 )); then + command rm -f -- "$discovery" + printf 'No existing Claude Code or Codex project folders were found.\n' + printf 'Add one later with session-kit projects add.\n' + return + fi + # Never all-or-nothing. Every directory a provider has ever run in is + # offered by number, with the short name it would get, and only the chosen + # ones are written. + python3 - "$discovery" <<'PY' +import json,sys +value=json.load(open(sys.argv[1],encoding="utf-8")) +rows=value["candidates"] +print(f"\nFound {len(rows)} directory(s) your providers have used:") +for number,row in enumerate(rows,1): + print(f" {number:2} [{row['provider'].capitalize():6}] {row['cwd']} → {row['alias']}") +PY + command rm -f -- "$discovery" + printf '\nWhich should the picker offer? numbers/ranges, a for all, Enter for none: ' + read -r answer + if [[ -z $answer ]]; then + printf 'None added. Run session-kit projects import whenever you are ready.\n' + return + fi + projects_tool "$install_root/current" import --select "$answer" || { + printf 'Nothing added. Run session-kit projects import later.\n' >&2 + return + } +} diff --git a/lib/sh/session_kit_services.sh b/lib/sh/session_kit_services.sh new file mode 100644 index 0000000..34346d6 --- /dev/null +++ b/lib/sh/session_kit_services.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# Session Kit user service control: systemd units on Linux, LaunchAgents on +# macOS. Source this file; do not execute it. +# +# Source order: bin/session-kit sources this module after it assigns +# install_root, state_root, service_root, launchd_units, launchd_template_root, +# and launchd_receipt, and after it defines die() and platform(). +# services_command() also calls validate_roots() from +# lib/sh/session_kit_checks.sh. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +launchd_receipt_matches() { + python3 - "$launchd_receipt" "$1" "$2" <<'PY' +import hashlib,json,os,pathlib,stat,sys +receipt=pathlib.Path(sys.argv[1]); unit=sys.argv[2]; active=pathlib.Path(sys.argv[3]) +try: + info=receipt.lstat() + value=json.loads(receipt.read_text(encoding="utf-8")) + active_info=active.lstat() +except (OSError,ValueError): + raise SystemExit(1) +if ( + not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() + or stat.S_IMODE(info.st_mode) != 0o600 + or not stat.S_ISREG(active_info.st_mode) or active_info.st_uid != os.geteuid() + or value.get("schema_version") != 1 + or set(value) != {"schema_version", "digests"} + or not isinstance(value.get("digests"), dict) +): + raise SystemExit(1) +expected=value["digests"].get(unit) +actual=hashlib.sha256(active.read_bytes()).hexdigest() +raise SystemExit(0 if isinstance(expected,str) and expected==actual else 1) +PY +} + +write_launchd_receipt() { + python3 - "$launchd_receipt" "$service_root" "${launchd_units[@]}" <<'PY' +import hashlib,json,os,pathlib,tempfile,sys +receipt=pathlib.Path(sys.argv[1]); root=pathlib.Path(sys.argv[2]); units=sys.argv[3:] +payload={ + "schema_version":1, + "digests":{unit:hashlib.sha256((root/unit).read_bytes()).hexdigest() for unit in units}, +} +receipt.parent.mkdir(parents=True,exist_ok=True) +fd,temporary=tempfile.mkstemp(prefix=f".{receipt.name}.",dir=receipt.parent) +try: + os.fchmod(fd,0o600) + with os.fdopen(fd,"w",encoding="utf-8") as handle: + json.dump(payload,handle,indent=2,sort_keys=True); handle.write("\n") + handle.flush(); os.fsync(handle.fileno()) + os.replace(temporary,receipt) +finally: + try: os.unlink(temporary) + except FileNotFoundError: pass +PY +} + +services_command() { + local action=${1:-} + [[ $# == 1 ]] || die "services requires exactly one action: enable, disable, or status" + case "$action" in enable|disable|status) ;; *) die "services action must be enable, disable, or status" ;; esac + local current_platform unit label domain="gui/$UID" + current_platform=$(platform) + if [[ $current_platform == linux ]]; then + case "$action" in + enable) + systemctl --user daemon-reload + # The watchdog is the unit that recovers a session whose terminal + # died. Every Linux install writes its unit file, so leaving it out of + # enable installed it dead while every file-level check still passed. + systemctl --user enable --now shpool.socket shpool-reaper.timer \ + session-kit-subagent-sweep.timer \ + session-kit-watchdog.service + ;; + disable) + systemctl --user disable --now session-kit-watchdog.service \ + shpool-reaper.timer session-kit-subagent-sweep.timer shpool.socket + ;; + status) + systemctl --user --no-pager status shpool.socket shpool-reaper.timer \ + session-kit-subagent-sweep.timer \ + session-kit-watchdog.service + ;; + esac + return + fi + [[ $current_platform == macos ]] || die "services is supported only on Linux and macOS" + validate_roots + for unit in "${launchd_units[@]}"; do + [[ -f $launchd_template_root/$unit && ! -L $launchd_template_root/$unit ]] || + die "LaunchAgent template is missing or unsafe: $launchd_template_root/$unit" + plutil -lint "$launchd_template_root/$unit" >/dev/null || die "invalid LaunchAgent: $unit" + done + service_list_json() { + python3 "$install_root/current/lib/session_inventory.py" platform timeout 10 -- \ + shpool -D list --json + } + sessions_are_empty() { + python3 -c 'import json,sys; v=json.load(sys.stdin); raise SystemExit(0 if isinstance(v,dict) and v.get("sessions")==[] else 1)' + } + validate_active_launchagents() { + local active_unit + for active_unit in "${launchd_units[@]}"; do + if [[ -e $service_root/$active_unit || -L $service_root/$active_unit ]]; then + [[ -f $service_root/$active_unit && ! -L $service_root/$active_unit ]] || + die "refusing unsafe LaunchAgent destination: $service_root/$active_unit" + if ! cmp -s "$launchd_template_root/$active_unit" "$service_root/$active_unit" && + ! launchd_receipt_matches "$active_unit" "$service_root/$active_unit"; then + die "refusing a modified or unowned LaunchAgent: $service_root/$active_unit" + fi + fi + done + } + case "$action" in + enable) + launchctl print "$domain" >/dev/null 2>&1 || + die "the macOS GUI user domain is unavailable; log into the Mac desktop first" + label=com.session-kit.shpool + validate_active_launchagents + if ! launchctl print "$domain/$label" >/dev/null 2>&1; then + local probe_status=0 + service_list_json >/dev/null 2>&1 || probe_status=$? + if (( probe_status == 0 )); then + die "an unmanaged shpool daemon is already reachable; refusing a second daemon" + elif (( probe_status == 124 )); then + die "the shpool probe timed out; refusing a possible second daemon" + fi + fi + mkdir -p "$service_root" + for unit in "${launchd_units[@]}"; do + label=${unit%.plist} + if launchctl print "$domain/$label" >/dev/null 2>&1; then + [[ -f $service_root/$unit && ! -L $service_root/$unit ]] || + die "loaded label has no owned LaunchAgent file: $label" + cmp -s "$launchd_template_root/$unit" "$service_root/$unit" || + die "loaded label has a pending definition; disable services before enabling again: $label" + printf 'Already loaded: %s\n' "$label" + continue + fi + if [[ -e $service_root/$unit || -L $service_root/$unit ]]; then + [[ -f $service_root/$unit && ! -L $service_root/$unit ]] || + die "refusing unsafe LaunchAgent destination: $service_root/$unit" + cmp -s "$launchd_template_root/$unit" "$service_root/$unit" || + die "refusing to replace a different LaunchAgent: $service_root/$unit" + else + install -m 0644 "$launchd_template_root/$unit" "$service_root/$unit" + fi + if ! launchctl bootstrap "$domain" "$service_root/$unit"; then + die "failed to load LaunchAgent: $label; previously loaded jobs were left running" + fi + printf 'Loaded: %s\n' "$label" + done + write_launchd_receipt + ;; + disable) + validate_active_launchagents + local sessions_json lock_file=$state_root/create.lock shpool_loaded=0 + launchctl print "$domain/com.session-kit.shpool" >/dev/null 2>&1 && shpool_loaded=1 + mkdir -p "$state_root" + exec 9>"$lock_file" || die "cannot open the Session Kit action lock" + python3 - 9 <<'PY' +import fcntl,sys +fcntl.flock(int(sys.argv[1]),fcntl.LOCK_EX) +PY + if (( shpool_loaded )); then + sessions_json=$(service_list_json 2>/dev/null) || + die "cannot prove shpool has no sessions; refusing to unload services" + sessions_are_empty <<<"$sessions_json" || die "shpool still has sessions; refusing to unload services" + fi + for unit in com.session-kit.watchdog.plist com.session-kit.reaper.plist; do + label=${unit%.plist} + if launchctl print "$domain/$label" >/dev/null 2>&1; then + launchctl bootout "$domain/$label" + printf 'Unloaded: %s\n' "$label" + else + printf 'Already unloaded: %s\n' "$label" + fi + if [[ -e $service_root/$unit || -L $service_root/$unit ]]; then + [[ -f $service_root/$unit && ! -L $service_root/$unit ]] || + die "refusing unsafe LaunchAgent destination: $service_root/$unit" + cmp -s "$launchd_template_root/$unit" "$service_root/$unit" || + die "refusing to remove a different LaunchAgent: $service_root/$unit" + command rm -- "$service_root/$unit" + fi + done + if (( shpool_loaded )); then + sessions_json=$(service_list_json 2>/dev/null) || + die "cannot re-prove empty shpool before unload; shpool was left running" + sessions_are_empty <<<"$sessions_json" || + die "a session appeared during disable; shpool was left running" + fi + unit=com.session-kit.shpool.plist + label=${unit%.plist} + if launchctl print "$domain/$label" >/dev/null 2>&1; then + launchctl bootout "$domain/$label" + printf 'Unloaded: %s\n' "$label" + else + printf 'Already unloaded: %s\n' "$label" + fi + if [[ -e $service_root/$unit ]]; then + command rm -- "$service_root/$unit" + fi + if [[ -e $launchd_receipt || -L $launchd_receipt ]]; then + [[ -f $launchd_receipt && ! -L $launchd_receipt ]] || + die "refusing unsafe LaunchAgent receipt: $launchd_receipt" + command rm -- "$launchd_receipt" + fi + exec 9>&- + ;; + status) + local any_missing=0 + for unit in "${launchd_units[@]}"; do + label=${unit%.plist} + if launchctl print "$domain/$label" >/dev/null 2>&1; then + printf 'LOADED %s\n' "$label" + else + printf 'UNLOADED %s\n' "$label" + any_missing=1 + fi + done + (( any_missing == 0 )) + ;; + esac +} diff --git a/lib/sh/session_kit_uninstall.sh b/lib/sh/session_kit_uninstall.sh new file mode 100644 index 0000000..21ef1ba --- /dev/null +++ b/lib/sh/session_kit_uninstall.sh @@ -0,0 +1,452 @@ +#!/usr/bin/env bash +# Session Kit uninstall command. Source this file; do not execute it. +# +# Source order: bin/session-kit sources this module after it assigns the +# lifecycle roots, helpers, launchd_units, and receipt_path, and after it +# defines die() and platform(). uninstall_command() also calls the validators +# in lib/sh/session_kit_checks.sh, the transaction helpers in +# lib/sh/session_kit_lifecycle.sh, and disable_login() in +# lib/sh/session_kit_login.sh. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +# Only files that still carry the kit's own marker line are removed. A copy the +# user edited, or a completion for a same-named command from somewhere else, +# survives an uninstall untouched. +remove_completions() { + local name destination + [[ $completion_dir != none ]] || return 0 + [[ -d $completion_dir && ! -L $completion_dir ]] || return 0 + for name in "${completion_names[@]}"; do + destination=$completion_dir/$name + [[ -f $destination && ! -L $destination ]] || continue + grep -qxF -- "$completion_marker" "$destination" 2>/dev/null || continue + command rm -f -- "$destination" + done + return 0 +} + +remove_claude_integration() { + local action=${1:-remove} release_id claude_root accounts_root source_root + release_id=$(current_release_id) + source_root=$install_root/releases/$release_id/config/claude + if [[ ! -f $source_root/nameintent_title.sh || ! -f $source_root/statusline.sh ]] && + [[ -L $manager_link ]]; then + source_root=$manager_link/config/claude + fi + claude_root=${SESSION_KIT_CLAUDE_HOME:-$HOME/.claude} + accounts_root=${XDG_DATA_HOME:-$HOME/.local/share}/session-kit/accounts/claude + python3 - "$source_root" "$HOME" "$claude_root" "$accounts_root" \ + "$claude_statusline_backups" "$claude_integration_ledger" "$action" <<'PY' +import hashlib +import json +import os +import pathlib +import re +import stat +import sys +import tempfile + +source_root, home, claude_root, accounts_root, backups_path, ledger_path = map( + pathlib.Path, sys.argv[1:7] +) +action = sys.argv[7] +hook_path = claude_root / "hooks" / "nameintent_title.sh" +status_path = claude_root / "statusline.sh" +hook_command = "~/.claude/hooks/nameintent_title.sh" if claude_root == home / ".claude" else str(hook_path) +status_command = "~/.claude/statusline.sh" if claude_root == home / ".claude" else str(status_path) +canonical_status = { + "type": "command", + "command": status_command, + "refreshInterval": 2, +} + + +def remove_exact(path, source): + try: + info = path.lstat() + expected = source.read_bytes() + payload = path.read_bytes() + except OSError: + return + if ( + stat.S_ISREG(info.st_mode) + and not stat.S_ISLNK(info.st_mode) + and info.st_uid == os.geteuid() + and payload == expected + ): + path.unlink() + + +def load_integration_ledger(path): + try: + info = path.lstat() + value = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return None + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise SystemExit(f"session-kit: invalid Claude integration ledger: {path}: {exc}") + files = value.get("files") if isinstance(value, dict) else None + expected_files = {os.fspath(hook_path), os.fspath(status_path)} + if ( + not stat.S_ISREG(info.st_mode) + or stat.S_ISLNK(info.st_mode) + or info.st_uid != os.geteuid() + or not isinstance(value, dict) + or value.get("schema_version") != 1 + or not isinstance(value.get("release_id"), str) + or not re.fullmatch(r"[0-9a-f]{40}", value["release_id"]) + or not isinstance(files, dict) + or not set(files).issubset(expected_files) + or any( + not isinstance(name, str) + or not pathlib.Path(name).is_absolute() + or not isinstance(digest, str) + or not re.fullmatch(r"[0-9a-f]{64}", digest) + for name, digest in files.items() + ) + ): + raise SystemExit(f"session-kit: invalid Claude integration ledger: {path}") + return value, expected_files - set(files) + + +def remove_recorded(path, recorded): + expected = recorded.get(os.fspath(path)) + if expected is None: + return + try: + info = path.lstat() + payload = path.read_bytes() + except OSError: + return False + if ( + stat.S_ISREG(info.st_mode) + and not stat.S_ISLNK(info.st_mode) + and info.st_uid == os.geteuid() + and hashlib.sha256(payload).hexdigest() == expected + ): + path.unlink() + return True + return False + + +def atomic_json(path, value, mode): + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + os.fchmod(descriptor, mode) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(value, stream, indent=2, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + +def load_backups(path): + try: + info = path.lstat() + value = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {"schema_version": 1, "entries": {}} + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise SystemExit(f"session-kit: invalid Claude status-line backup: {path}: {exc}; move that file aside to continue (its recorded status-line restores are lost to the kit)") + if ( + not stat.S_ISREG(info.st_mode) + or stat.S_ISLNK(info.st_mode) + or info.st_uid != os.geteuid() + or not isinstance(value, dict) + or value.get("schema_version") != 1 + or not isinstance(value.get("entries"), dict) + ): + raise SystemExit(f"session-kit: invalid Claude status-line backup: {path}; move that file aside to continue (its recorded status-line restores are lost to the kit)") + for key, record in value["entries"].items(): + if ( + not isinstance(key, str) + or not pathlib.Path(key).is_absolute() + or not isinstance(record, dict) + or not isinstance(record.get("present"), bool) + or set(record) - {"present", "value"} + or (record["present"] and "value" not in record) + ): + raise SystemExit(f"session-kit: invalid Claude status-line backup: {path}; move that file aside to continue (its recorded status-line restores are lost to the kit)") + return value + + +backups = load_backups(backups_path) +backup_entries = backups["entries"] +recorded_integration = load_integration_ledger(ledger_path) +if action == "preflight": + raise SystemExit(0) +if action != "remove": + raise SystemExit("session-kit: invalid Claude integration removal action") + + +def unregister(path): + try: + info = path.lstat() + settings = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return + if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode) or not isinstance(settings, dict): + return + backup = backup_entries.pop(os.fspath(path), None) + if settings.get("statusLine") == canonical_status: + if isinstance(backup, dict) and backup.get("present") is True and "value" in backup: + settings["statusLine"] = backup["value"] + else: + settings.pop("statusLine") + hooks = settings.get("hooks") + if isinstance(hooks, dict): + for event in ("SessionStart", "UserPromptSubmit", "Stop"): + groups = hooks.get(event) + if not isinstance(groups, list): + continue + refreshed = [] + for group in groups: + if not isinstance(group, dict) or not isinstance(group.get("hooks"), list): + refreshed.append(group) + continue + kept = [ + hook for hook in group["hooks"] + if not ( + isinstance(hook, dict) + and hook.get("type") == "command" + and hook.get("command") == hook_command + ) + ] + if kept or set(group) != {"hooks"}: + copy = dict(group) + copy["hooks"] = kept + refreshed.append(copy) + if refreshed: + hooks[event] = refreshed + else: + hooks.pop(event, None) + if not hooks: + settings.pop("hooks", None) + atomic_json(path, settings, stat.S_IMODE(info.st_mode)) + + +settings_paths = [claude_root / "settings.json"] +try: + entries = sorted(accounts_root.iterdir(), key=lambda path: path.name) +except OSError: + entries = [] +for entry in entries: + if re.fullmatch(r"[A-Za-z0-9._-]+", entry.name) and entry.is_dir() and not entry.is_symlink(): + settings_paths.append(entry / "settings.json") +for path in settings_paths: + unregister(path) +if backup_entries: + atomic_json(backups_path, backups, 0o600) +else: + try: + info = backups_path.lstat() + except FileNotFoundError: + pass + else: + if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode): + raise SystemExit(f"session-kit: unsafe Claude status-line backup: {backups_path}") + backups_path.unlink() +if recorded_integration is None: + # Compatibility for installations made before the integration ledger. + remove_exact(hook_path, source_root / "nameintent_title.sh") + remove_exact(status_path, source_root / "statusline.sh") +else: + ledger, missing_files = recorded_integration + recorded_files = ledger["files"] + retained_files = [] + for path in (hook_path, status_path): + key = os.fspath(path) + if key not in recorded_files: + continue + if remove_recorded(path, recorded_files): + recorded_files.pop(key) + else: + retained_files.append(key) + if missing_files: + print( + "session-kit: incomplete Claude integration ledger retained; " + "unrecorded files were not removed: " + ", ".join(sorted(missing_files)), + file=sys.stderr, + ) + if retained_files: + print( + "session-kit: Claude integration ledger retained; " + "recorded files were not removed: " + ", ".join(sorted(retained_files)), + file=sys.stderr, + ) + if missing_files or recorded_files: + atomic_json(ledger_path, ledger, 0o600) + else: + ledger_path.unlink() + +PY +} + +remove_claude_quota_cache() { + python3 - "$HOME" <<'PY' +import os +import pathlib +import shutil +import stat +import sys + +home = pathlib.Path(sys.argv[1]) +quota_root = home / ".claude" / "cache" / "session-kit-quota" +# The status line owns this whole tree. It may contain symlinks into enrolled +# account profiles, so validate the parents and remove the links without +# following them only after the uninstall transaction has committed. +for ancestor in (home / ".claude", home / ".claude" / "cache"): + try: + info = ancestor.lstat() + except FileNotFoundError: + break + if ( + not stat.S_ISDIR(info.st_mode) + or stat.S_ISLNK(info.st_mode) + or info.st_uid != os.geteuid() + ): + print( + f"session-kit: quota cache retained; unsafe parent: {ancestor}", + file=sys.stderr, + ) + break +else: + try: + info = quota_root.lstat() + except FileNotFoundError: + pass + else: + if ( + stat.S_ISDIR(info.st_mode) + and not stat.S_ISLNK(info.st_mode) + and info.st_uid == os.geteuid() + ): + try: + shutil.rmtree(quota_root) + except OSError as exc: + print( + f"session-kit: quota cache retained: {quota_root}: {exc}", + file=sys.stderr, + ) + else: + print( + f"session-kit: quota cache retained; unsafe path: {quota_root}", + file=sys.stderr, + ) +PY +} + +uninstall_command() { + local purge_code=0 purge_config=0 helper + while (($#)); do + case "$1" in + --purge-code) purge_code=1; shift ;; + --purge-config) purge_config=1; shift ;; + *) die "unknown uninstall option: $1" ;; + esac + done + recover_pending_transaction + validate_roots + if [[ $(platform) == macos ]]; then + local unit label + for unit in "${launchd_units[@]}"; do + label=${unit%.plist} + launchctl print "gui/$UID/$label" >/dev/null 2>&1 && + die "unload Session Kit LaunchAgents first: session-kit services disable" + [[ ! -e $service_root/$unit && ! -L $service_root/$unit ]] || + die "remove inactive LaunchAgents safely with: session-kit services disable" + done + elif [[ $(platform) == linux ]]; then + # macOS has refused to uninstall over live LaunchAgents since this command + # existed; Linux never looked at its service directory at all, so an + # uninstall left every unit file behind, pointing at code that was about + # to be removed. Same rule, same remedy: a unit systemd still knows about + # is stopped by the documented verb first, and the files go with the + # uninstall rather than outliving it. + local unit enablement activity + for unit in "${systemd_units[@]}"; do + # `is-enabled` answers about the link systemd holds to this file, which + # is the answer that decides this: a unit file removed from behind a + # live enablement is the state the old text refused to create. It has + # more than one affirmative answer -- a unit enabled for this boot says + # `enabled-runtime`, a hand-linked one `linked` -- and `active` is not + # among them at all, because activity is what `is-active` reports. + enablement=$(kit_systemctl is-enabled "$unit" 2>/dev/null || true) + case $enablement in + enabled | enabled-runtime | linked | linked-runtime) + die "disable Session Kit services first: session-kit services disable" + ;; + esac + # Activity is reported, never refused. A running daemon is holding the + # operator's live sessions, no uninstall of this kit has ever ended one, + # and removing a unit file does not stop a process that is already + # running. Refusing here would make the only way out of an installation + # go through killing every session in it. + activity=$(kit_systemctl is-active "$unit" 2>/dev/null || true) + [[ $activity != active && $activity != activating ]] || + printf 'session-kit: %s is still running and is left running; its unit file is removed. Stop it with: systemctl --user stop %s\n' \ + "$unit" "$unit" >&2 + # Judge every path before anything is removed. The removal below keeps + # its own guard for the race, but a refusal discovered there has already + # deleted the launchers and pays for it with a transaction rollback; + # refusing here costs the operator nothing. + [[ ! -e $service_root/$unit || ( -f $service_root/$unit && ! -L $service_root/$unit ) ]] || + die "refusing to remove unsafe unit file: $service_root/$unit" + done + fi + validate_uninstall_launchers + if (( purge_config )); then + validate_purge_ownership config "$config_root" "$config_root/.session-kit-owned.json" + fi + if (( purge_code )); then + validate_purge_ownership install "$install_root" "$install_root/.session-kit-owned.json" + fi + validate_mutable_targets + remove_claude_integration preflight + begin_transaction uninstall + uninstall_transaction_pending=1 + trap 'if [[ ${uninstall_transaction_pending:-0} == 1 ]]; then recover_pending_transaction || true; fi' EXIT + disable_login + remove_claude_integration + for helper in "${helpers[@]}"; do + [[ ! -e $bin_dir/$helper || ( -f $bin_dir/$helper && ! -L $bin_dir/$helper ) ]] || + die "refusing to remove unsafe launcher: $bin_dir/$helper" + command rm -f -- "$bin_dir/$helper" + done + command rm -f -- "$bin_dir/session-kit" + if [[ $(platform) == linux ]]; then + # The unit files are this installation's, and nothing else prunes them. + for unit in "${systemd_units[@]}"; do + [[ ! -e $service_root/$unit || ( -f $service_root/$unit && ! -L $service_root/$unit ) ]] || + die "refusing to remove unsafe unit file: $service_root/$unit" + command rm -f -- "$service_root/$unit" + done + fi + remove_completions + if (( purge_config )); then + [[ ! -e $config_root || ( -d $config_root && ! -L $config_root ) ]] || + die "refusing to remove unsafe config directory" + command rm -rf -- "$config_root" + fi + if (( purge_code )); then + [[ ! -e $install_root || ( -d $install_root && ! -L $install_root ) ]] || + die "refusing to remove unsafe install directory" + find "$install_root" -type d -exec chmod u+rwx {} + + command rm -rf -- "$install_root" + fi + command rm -f -- "$receipt_path" + commit_transaction + uninstall_transaction_pending=0 + trap - EXIT + remove_claude_quota_cache + printf 'Session Kit integration removed. Journals, archives, and session data were retained.\n' + printf 'No service was stopped or restarted. Review active sessions before removing service files.\n' +} diff --git a/lib/sh/shpool_login_actions.sh b/lib/sh/shpool_login_actions.sh new file mode 100644 index 0000000..1adcef8 --- /dev/null +++ b/lib/sh/shpool_login_actions.sh @@ -0,0 +1,1489 @@ +#!/usr/bin/env bash +# Picker action menu and the proof-bound actions behind it: open, take over, +# close, rename, fork, and guided new sessions. Every state change goes out as +# a proof-bound sp command. Source this file; do not execute it. +# +# Source order: bin/shpool_login sources this module ahead of its first trap. +# These functions read SNAPSHOT, VIEW, PAGE, QUERY, RECOVERY, SP_CMD, +# PICKER_REFUSED_STATUS, and PICKER_ATTACH_FAILED_STATUS, all assigned in that +# file before the boot sequence runs, and call into the theme, live, view, and +# render modules. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +show_other_provider_sessions() { + python3 - "$VIEW" <<'PY' +import json +import os +import re +import shutil +import sys +import unicodedata + +with open(sys.argv[1], encoding="utf-8") as handle: + data = json.load(handle) +rows = data.get("outside_agents", []) +width = max(1, min(239, shutil.get_terminal_size(fallback=(100, 24)).columns - 1)) + +def clean(value): + text = "".join( + " " if unicodedata.category(character).startswith("C") else character + for character in str(value or "") + ) + return " ".join(text.split()) + +def cells(text): + return sum( + 0 if unicodedata.combining(character) + else 2 if unicodedata.east_asian_width(character) in {"W", "F"} + else 1 + for character in text + ) + +def shorten(text, room): + text = clean(text) + if cells(text) <= room: + return text + kept = [] + used = 0 + for character in text: + size = cells(character) + if used + size > max(0, room - 1): + break + kept.append(character) + used += size + return "".join(kept) + "…" + +def pad(text, room): + return text + " " * max(0, room - cells(text)) + +def project_hint(value): + path = clean(value) + if not path.startswith("/"): + return "" + parts = [part for part in path.split("/") if part] + account_names = { + parts[index + 1].casefold() + for index, part in enumerate(parts[:-1]) + if part.casefold() in {"home", "users"} + } + account_names.add(os.path.basename(os.path.expanduser("~")).casefold()) + generic = { + "app", "apps", "code", "current", "home", "repo", "repos", "src", + "srv", "users", "v2", "var", "workspace", "workspaces", + } + for part in reversed(parts): + folded = part.casefold() + if ( + part + and not part.startswith(".") + and folded not in generic + and folded not in account_names + ): + return part + return "" + +def private_names(value): + path = clean(value) + parts = [part for part in path.split("/") if part] if path.startswith("/") else [] + names = { + parts[index + 1] + for index, part in enumerate(parts[:-1]) + if part.casefold() in {"home", "users"} + } + home_name = os.path.basename(os.path.expanduser("~")) + if home_name: + names.add(home_name) + return {name for name in names if name} + +def safe_title(value, cwd, fallback): + title = clean(value) + path = clean(cwd) + hint = project_hint(cwd) + if path: + title = title.replace(path, hint or fallback) + for name in sorted(private_names(cwd), key=len, reverse=True): + title = re.sub(re.escape(name), "account", title, flags=re.IGNORECASE) + return clean(title) or fallback + +print() +print(" Outside the kit") +if data.get("stale"): + print(" Last-known sessions outside the kit. Not confirmed live, and not openable here.") +else: + print(" Live sessions outside the kit. Not openable here.") +if not rows: + print(" Outside the kit: none.") +else: + labels = {"claude": "Claude", "codex": "Codex"} + prepared = [] + for row in rows: + provider = labels.get( + str(row.get("display_provider") or row.get("provider") or "").casefold(), + "Unknown", + ) + hint = project_hint(row.get("cwd")) + title = safe_title( + row.get("display_title") or row.get("title"), + row.get("cwd"), + provider, + ) + detail = "not openable here" + if hint: + detail = f"project: {hint} | {detail}" + prepared.append((provider, title, detail)) + prefix = " - " + title_width = min( + max((cells(title) for _provider, title, _detail in prepared), default=1), + max( + 1, + min( + width - cells(prefix) - cells(detail) - 3 + for _provider, _title, detail in prepared + ), + ), + ) + previous_provider = None + for provider, title, detail in prepared: + if provider != previous_provider: + print(f" {provider}") + previous_provider = provider + print(prefix + pad(shorten(title, title_width), title_width) + " | " + detail) +print() +PY + picker_back_read +} + +number_metadata() { + local number=$1 + python3 - "$VIEW" "$number" <<'PY' +import base64 +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + data = json.load(handle) +wanted = int(sys.argv[2]) +matches = [ + row + for row in data.get("sessions", []) + if row.get("terminal_number") == wanted +] +if len(matches) != 1: + raise SystemExit(2) +row = matches[0] +values = ( + row.get("availability"), + "true" if row.get("mutation_allowed") is True else "false", + row.get("display_shpool_id"), + row.get("provider"), + row.get("title"), + row.get("shpool_id_raw"), + (row.get("identity") or {}).get("uuid"), + (row.get("identity") or {}).get("confidence"), +) +for value in values: + print(base64.b64encode(str(value or "").encode()).decode()) +PY +} + +decode64() { + printf '%s' "$1" | base64 -d +} + +# Dismiss BY IDENTITY, never by position alone. +# +# The screen's `d` numbers were bare array positions in watchdog-repairs.json, +# captured when the list was drawn. Retirement now deletes records from the +# middle of that file on roughly every 60-second sweep, and the modal read +# blocks with no timeout for as long as a person reads the screen -- so the +# numbers on screen stopped matching the file, and `d2` acknowledged a +# different session's warning while printing "Dismissed 1 repair failure." +# (reproduced in review, 2026-08-15; the same shift was already possible when +# an append hit the 50-record cap). +# +# The row's own identity travels with its position and is re-checked against +# the file immediately before the write. A list that moved refuses out loud and +# says to look again, which is the only safe answer: the record the person +# meant is still there, just not where it was. +dismiss_repair_failure() { + local wanted=$1 token index="" identity="" stamp="" + [[ $wanted =~ ^[0-9]+$ ]] || return 1 + while IFS=$'\t' read -r token index identity stamp; do + [[ $token == "d$wanted" && $index =~ ^[0-9]+$ ]] && break + index="" + done <<<"$REPAIR_INDEX" + if [[ -z $index ]]; then + printf ' There is no repair %s on this screen. Numbers shown here work.\n' "$wanted" + return 0 + fi + if python3 - "$REPAIR_FILE" "$index" "$identity" "$stamp" <<'PY' +import json +import os +from pathlib import Path +import stat +import sys +import tempfile + +path = Path(sys.argv[1]) +index = int(sys.argv[2]) +wanted_identity = sys.argv[3] if len(sys.argv) > 3 else "" +wanted_stamp = sys.argv[4] if len(sys.argv) > 4 else "" +flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) +descriptor = os.open(path, flags) +with os.fdopen(descriptor, encoding="utf-8") as handle: + metadata = os.fstat(handle.fileno()) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.geteuid(): + raise SystemExit(2) + data = json.load(handle) +entries = data.get("repairs") if isinstance(data, dict) else None +if not isinstance(entries, list) or not 0 <= index < len(entries): + raise SystemExit(2) +item = entries[index] +if not isinstance(item, dict) or item.get("acknowledged") or item.get("outcome") == "repaired": + raise SystemExit(2) +# The row that was drawn, or nothing. A record that moved, was retired, or was +# replaced by another under the same position is NOT the one the person read. +stamp = item.get("at_unix_ms") +stamp = str(stamp) if isinstance(stamp, int) and not isinstance(stamp, bool) else "" +identity = " ".join(str(item.get("old_shpool_id") or "").split()) +if identity != wanted_identity or stamp != wanted_stamp: + raise SystemExit(3) +item["acknowledged"] = True +out, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) +try: + os.fchmod(out, 0o600) + with os.fdopen(out, "w", encoding="utf-8") as handle: + out = -1 + json.dump(data, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) +finally: + if out >= 0: + os.close(out) + try: + os.unlink(temporary) + except FileNotFoundError: + pass +PY + then + echo " Dismissed 1 repair failure." + elif (( $? == 3 )); then + echo " That list changed while it was on screen, so nothing was dismissed. Open Needs you again and the numbers will match." + else + echo " The repair record changed or was unsafe to update. Nothing changed." + fi +} + +create_proof() { + local number=$1 + python3 - "$VIEW" "$number" "$SK_STATE_DIR" <<'PY' +import json +import os +from pathlib import Path +import stat +import sys +import tempfile + +view_path, number_text, state_dir = sys.argv[1:4] +with open(view_path, encoding="utf-8") as handle: + data = json.load(handle) +if data.get("stale") or data.get("source") != "live": + raise SystemExit(3) +wanted = int(number_text) +matches = [ + row + for row in data.get("sessions", []) + if row.get("terminal_number") == wanted +] +if len(matches) != 1: + raise SystemExit(2) +row = matches[0] +if row.get("mutation_allowed") is not True: + raise SystemExit(3) +identity = row.get("identity") or {} +shell = row.get("shpool_shell") or {} +daemon = data.get("daemon_generation") or {} +proof = { + "daemon_pid": daemon.get("pid"), + "daemon_process_start_ticks": daemon.get("process_start_ticks"), + "proof_type": "session-kit-picker-session-v1", + "provider": row.get("provider"), + "provider_pid": identity.get("pid"), + "provider_process_start_ticks": identity.get("process_start_ticks"), + "schema_version": 1, + "shell_pid": shell.get("pid"), + "shell_process_start_ticks": shell.get("process_start_ticks"), + "shpool_id": row.get("shpool_id_raw"), + "started_at_unix_ms": row.get("started_at_unix_ms"), + "uuid": identity.get("uuid") or "", +} +payload = (json.dumps(proof, sort_keys=True, separators=(",", ":")) + "\n").encode() +descriptor, name = tempfile.mkstemp(prefix="picker-proof.", suffix=".json", dir=state_dir) +try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb") as handle: + descriptor = -1 + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + metadata = os.lstat(name) + if ( + not stat.S_ISREG(metadata.st_mode) + or stat.S_IMODE(metadata.st_mode) != 0o600 + or metadata.st_uid != os.geteuid() + ): + raise OSError("unsafe proof") + print(name) +except BaseException: + if descriptor >= 0: + os.close(descriptor) + try: + os.unlink(name) + except OSError: + pass + raise +PY +} + +run_proof_action() { + local command=$1 number=$2 + shift 2 + local proof status confirm_id + proof=$(create_proof "$number") || { + echo " The displayed session is stale or unsafe. Nothing changed." + return 1 + } + TEMP_FILES+=("$proof") + # sp's guard asks nothing; where it wants the exact session named, the + # picker names it from the proof it just wrote, so the answer is the same + # whether or not a terminal is attached. + confirm_id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("shpool_id") or "")' \ + "$proof" 2>/dev/null) || confirm_id="" + SESSION_KIT_CONFIRM_ID=$confirm_id "$SP_CMD" "$command" "$proof" "$@" + status=$? + command rm -f -- "$proof" + if (( status != 0 )); then + # The caller explains the failure itself when it has something better to + # say than a bare refusal. + if [[ ${PROOF_ACTION_QUIET:-0} != 1 ]]; then + echo " The action was refused. Nothing changed." + fi + return "$status" + fi +} + +refresh_after_action() { + # A failed refresh must never kill the picker: the previous snapshot stays + # on screen (marked stale, actions disabled) and the next loop retries. The + # search, the page and the jump marker are the person's, not the snapshot's, + # so they cross the refresh -- exactly as the background collector does it. + if ! refresh_snapshot keep; then + sk_log_action picker_refresh failed || true + echo " Live refresh failed. Showing the last confirmed list." + fi +} + +# --- Account pickers ------------------------------------------------------- +# +# Both account surfaces -- the new-session wizard step and the account switch +# -- speak one vocabulary. `sp account choices` alone decides selectability and +# hands every row a `state` string ("ready", "blocked: health expired", ...). +# Nothing below re-derives, re-words, or hides it, so the row a person reads is +# the row a refusal quotes back. One implementation serves both surfaces and +# both halves of the exchange: `render` draws the list, `pick` resolves a typed +# answer, and the Enter rule is written once because they share this file. + +account_choices_count() { + python3 - "$1" <<'PY' +import json +import sys + +try: + with open(sys.argv[1], encoding="utf-8") as handle: + print(len(json.load(handle).get("choices", []))) +except (OSError, ValueError): + raise SystemExit(1) +PY +} + +account_choice_ui() { + # account_choice_ui render <choices-json> <use|switch> <title> + # account_choice_ui pick <choices-json> <use|switch> <typed answer> + # + # pick prints the chosen alias, or the sentence explaining the refusal, and + # exits 0 chosen | 2 back | 3 refused, ask again | 1 payload unusable. + python3 - "$@" <<'PY' +import json +import shutil +import sys +import unicodedata + +action, path, mode, argument = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] +try: + with open(path, encoding="utf-8") as handle: + data = json.load(handle) +except (OSError, ValueError): + print(" The account list could not be read.") + raise SystemExit(1) +if not isinstance(data, dict): + print(" The account list could not be read.") + raise SystemExit(1) +rows = [row for row in data.get("choices", []) if isinstance(row, dict)] +eligible = [row for row in rows if row.get("eligible") is True] +recommendation = str(data.get("recommendation") or "") + +MISSING = "—" + + +def clean(value, limit=60): + text = "".join( + character if character.isprintable() else " " for character in str(value) + ) + return " ".join(text.split())[:limit] + + +def cells(text): + """Terminal columns, not characters: an alias is whatever a person typed.""" + return sum( + 0 if unicodedata.combining(character) + else 2 if unicodedata.east_asian_width(character) in {"W", "F"} + else 1 + for character in text + ) + + +def pad(text, width): + return text + " " * max(0, width - cells(text)) + + +def shorten(text, room): + if cells(text) <= room: + return text + if room <= 1: + return "…" + kept = [] + used = 0 + for character in text: + size = cells(character) + if used + size > room - 1: + break + kept.append(character) + used += size + return "".join(kept) + "…" + + +def state_of(row): + fallback = "ready" if row.get("eligible") is True else "blocked: state unavailable" + return clean(row.get("state") or fallback, 80) + + +def usage_of(row): + parts = [] + for label, key in (("5h", "u5h"), ("7d", "u7d")): + value = row.get(key) + if isinstance(value, bool) or not isinstance(value, (int, float)): + continue + parts.append(f"{label} {round(float(value) * 100)}%") + return " · ".join(parts) + + +def identity_of(row): + alias = clean(row.get("alias"), 40) + email = clean(row.get("email"), 80) + return f"{alias}: {email}" if email else alias + + +def number_of(alias): + for number, row in enumerate(rows, 1): + if row.get("alias") == alias: + return number + return None + + +# What Enter takes here. The wizard's Enter means one thing on every screen: +# the recommended choice, or the only one there is. It used to mean Back on +# this screen and "use the default project" on the next, so a person had to +# read the footer to find out which -- which is what a footer is for, but not +# what a key is for. +# +# Switching a RUNNING session's account is deliberately not in that rule: the +# person came here to name one account for one session, there is no choice to +# take on their behalf, and the footer says Enter goes back instead. +default_alias = "" +if mode == "use": + advised = next( + ( + row + for row in eligible + if recommendation and clean(row.get("alias"), 40) == recommendation + ), + None, + ) + if advised is not None: + default_alias = clean(advised.get("alias"), 40) + elif not recommendation and len(eligible) == 1: + default_alias = clean(eligible[0].get("alias"), 40) + +if action == "render": + print(f" {clean(argument, 40)}") + # Columns, not a ragged edge. The alias and the email are both variable + # width and both sit in front of everything else, so an unpadded row made + # the plan, the state and the usage land in a different place on every + # line. Widths come from the rows actually on this screen. + width = max(20, min(200, shutil.get_terminal_size(fallback=(100, 24)).columns - 1)) + cell = [ + ( + identity_of(row), + clean(row.get("plan"), 40) or MISSING, + state_of(row), + usage_of(row) or MISSING, + "recommended" if row.get("recommended") else "", + ) + for row in rows + ] + identity_room = max(10, width - 46) + identity_width = min( + identity_room, max((cells(item[0]) for item in cell), default=1) + ) + plan_width = max((cells(item[1]) for item in cell), default=1) + state_width = max((cells(item[2]) for item in cell), default=1) + usage_width = max((cells(item[3]) for item in cell), default=1) + for number, item in enumerate(cell, 1): + identity, plan, state, usage, mark = item + line = ( + f" {number:2} {pad(shorten(identity, identity_width), identity_width)}" + f" | {pad(plan, plan_width)} | {pad(state, state_width)}" + f" | {pad(usage, usage_width)}" + ) + if mark: + line = f"{line} | {mark}" + print(line.rstrip() if not mark else line) + reason = clean(data.get("recommendation_reason"), 240) + if recommendation and reason: + print(f" Why {clean(recommendation, 40)}: {reason}") + roster_state = clean( + data.get("roster_state") + or ("fresh" if data.get("roster_fresh") else "unknown"), + 20, + ) + if roster_state != "fresh": + print(" Account health is unknown. Every enabled account is offered.") + if data.get("advice_fresh") is False: + print(" Rotation advice is stale or absent: no account is recommended.") + blocked = data.get("recommendation_blocked") + if isinstance(blocked, dict) and blocked.get("alias"): + print( + f" {clean(blocked.get('alias'), 40)} is advised but not selectable:" + f" {clean(blocked.get('state'), 80)}" + ) + if rows and not eligible: + print(" No account here is selectable; every row above says why.") + verb = "use" if mode == "use" else "change to" + advice_tail = "" + if recommendation and number_of(recommendation): + advice_tail = ( + f" ({number_of(recommendation)} is the recommended" + f" {clean(recommendation, 40)})" + ) + if default_alias: + print( + f" ↵ {verb} {default_alias} · number {verb} that account" + f"{advice_tail} · b back" + ) + else: + print(f" ↵ back · number {verb} that account{advice_tail} · b back") + raise SystemExit(0) + +choice = clean(argument, 40) +if not rows: + print(" There is no account to choose from.") + raise SystemExit(2) +if choice.casefold() in {"b", "back"}: + raise SystemExit(2) +if not choice: + # Enter takes the recommended account, or the only selectable one. With + # neither, there is nothing for it to take and it goes back -- which the + # footer said before the person pressed it. + if default_alias: + print(default_alias) + raise SystemExit(0) + raise SystemExit(2) +if not choice.isdigit(): + print(" That is not an account number. Numbers shown here work.") + raise SystemExit(3) +number = int(choice) +if not 1 <= number <= len(rows): + print(f" There is no account {number} on this screen. Numbers shown here work.") + raise SystemExit(3) +row = rows[number - 1] +alias = clean(row.get("alias"), 40) +if row.get("eligible") is not True: + # The refusal quotes that row's own state: the person reads why THAT + # account is out, not a guess about a login. + print(f" Account {number} ({alias}) is not selectable: {state_of(row)}.") + raise SystemExit(3) +print(alias) +PY +} + +account_choices_render() { + # $1 choices json, $2 mode, $3 title + account_choice_ui render "$1" "$2" "$3" +} + +account_choice_pick() { + # $1 choices json, $2 mode, $3 typed answer + account_choice_ui pick "$1" "$2" "$3" +} + +change_account_number() { + local number=$1 provider=$2 choices answer alias status count + new_temp account-switch-choices || return + choices=$NEW_TEMP + if ! "$SP_CMD" account choices "$provider" >"$choices" 2>/dev/null; then + echo " Account choices are unavailable. Nothing changed." + return + fi + count=$(account_choices_count "$choices") || { + echo " The account list could not be read. Nothing changed." + return + } + echo + if [[ $count == 0 ]]; then + printf ' No %s account is enrolled, so there is nothing to change to.\n' "$provider" + return + fi + account_choices_render "$choices" switch "Change to account" || { + echo " The account list could not be read. Nothing changed." + return + } + echo + while true; do + picker_modal_read answer " target account ❯ " || return + alias=$(account_choice_pick "$choices" switch "$answer") + status=$? + if (( status == 0 )); then + break + fi + if (( status == 3 )); then + # A refused pick asks again on this step; the list is still on screen. + [[ -z $alias ]] || printf '%s\n' "$alias" + continue + fi + if (( status != 2 )) && [[ -n $alias ]]; then + printf '%s\n' "$alias" + fi + return + done + if [[ ! $alias =~ ^[a-z][a-z0-9_-]{0,11}$ ]]; then + echo " The account list is unreadable. Nothing changed." + return + fi + run_proof_action picker-account-switch "$number" "$alias" || true + refresh_after_action +} +choose_number() { + local number=$1 metadata + require_live_actions || return + number_on_page "$number" || { + printf ' There is no session %s on this screen. Numbers shown here work.\n' "$number" + return + } + metadata=$(number_metadata "$number") || { + echo " The displayed row changed format. Nothing changed." + return + } + local -a fields=() + mapfile -t fields <<<"$metadata" + if (( ${#fields[@]} != 8 )); then + echo " The displayed row changed format. Nothing changed." + return + fi + local availability mutation display_id provider title title_state uuid confidence + availability=$(decode64 "${fields[0]}") + mutation=$(decode64 "${fields[1]}") + display_id=$(decode64 "${fields[2]}") + provider=$(decode64 "${fields[3]}") + title=$(decode64 "${fields[4]}") + uuid=$(decode64 "${fields[6]}") + confidence=$(decode64 "${fields[7]}") + title_state=$(python3 - "$VIEW" "$number" <<'PY' +import json, sys +with open(sys.argv[1], encoding="utf-8") as handle: + rows = json.load(handle).get("sessions", []) +matches = [row for row in rows if row.get("terminal_number") == int(sys.argv[2])] +print(str(matches[0].get("provider_title_state") or "") if len(matches) == 1 else "") +PY +) || title_state="" + if [[ $mutation != true ]]; then + printf ' Session %s (%s) is display-only. Nothing changed.\n' "$number" "$title" + return + fi + if [[ $availability == ready ]]; then + # The window's tab carries the thread name for the whole attachment, + # the only in-window name surface Codex has (Claude's own title hook + # re-asserts the same name in-session). Cleared again on return. + sk_tab_title "$title" + local open_status=0 + PROOF_ACTION_QUIET=1 run_proof_action picker-open "$number" || open_status=$? + # The attachment (or its failed attempt) owned the screen; start the + # menu from a clean frame. + picker_clear_screen + case "$open_status" in + 0) ;; + "$PICKER_REFUSED_STATUS") + echo " The displayed session changed or failed a safety check. Nothing changed." + ;; + "$PICKER_ATTACH_FAILED_STATUS") + printf ' Could not open session %s. It was left alone.\n' "$number" + ;; + *) + printf ' Could not open session %s. It was left alone.\n' "$number" + ;; + esac + sk_tab_title "session kit" + refresh_after_action + return + fi + + while true; do + echo + printf ' %s [session %s]\n' "$title" "$number" + echo " Open elsewhere." + echo + echo " 1 Move it here (the other window disconnects)" + echo " 2 History" + echo " 3 Close it (the shell and everything inside will end)" + if [[ $provider == claude || $provider == codex ]]; then + echo " 4 Change subscription account (keeps this exact conversation)" + fi + if [[ $provider == codex && $title_state == pending ]]; then + echo " 5 Apply the pending title (restarts only a proven-idle Codex provider)" + fi + # Enter takes the most likely option (operator ruling, 2026-08-16): a + # person who picked a session that is open elsewhere almost always wants + # it here. Moving it is not destruction -- the other window disconnects + # and can take it back the same way. + echo " ↵ move it here · b back" + echo + local action offered="1, 2, 3" + if [[ $provider == claude || $provider == codex ]]; then + offered+=", 4" + fi + if [[ $provider == codex && $title_state == pending ]]; then + offered+=", 5" + fi + picker_modal_read action " action ❯ " || return 0 + case "$action" in + b|B|back|q|Q) return ;; + ""|1) + run_proof_action picker-takeover "$number" || true + refresh_after_action + return + ;; + 2) + run_proof_action picker-history "$number" || true + refresh_after_action + return + ;; + 3) + run_proof_action picker-close "$number" || true + refresh_after_action + return + ;; + 4) + if [[ $provider == claude || $provider == codex ]]; then + change_account_number "$number" "$provider" + return + fi + echo " Only a Claude or Codex conversation can change account. Nothing changed." + ;; + 5) + if [[ $provider == codex && $title_state == pending ]]; then + run_proof_action picker-title-refresh "$number" || true + refresh_after_action + return + fi + echo " That session has no pending Codex title. Nothing changed." + ;; + *) picker_unknown_choice "$offered, b, and Enter" ;; + esac + done +} + +# Closing ends a shell and everything running inside it. It asks nothing: the +# number was typed against the rows on the screen, guards run before the close, +# and the conversation stays restorable afterwards. +close_row_title() { + local number=$1 metadata + metadata=$(number_metadata "$number") || return 1 + local -a fields=() + mapfile -t fields <<<"$metadata" + (( ${#fields[@]} == 8 )) || return 1 + decode64 "${fields[4]}" +} + + +# The one selection grammar: k 5 · k 5, 6, 8 · k 4-7 · k all. Every number is +# validated against the CURRENT page before anything closes; one bad token +# refuses the whole request so a typo never kills a neighbor. +# +# `all` means the page that was drawn, not the page that exists now. A live +# refresh reorders the list on its own -- the sort follows recent output and +# needs-you -- so resolving `all` at action time could close a set the person +# had never seen. The drawn set is closed, and only if it is still the set on +# screen; anything else refuses and repaints. +direct_close() { + local raw=$1 token + local -a numbers=() + if [[ ${raw,,} == a || ${raw,,} == all ]]; then + local -a shown=() current=() + mapfile -t shown <<<"$PAGE_RENDERED_NUMBERS" + mapfile -t current < <(page_numbers) + local drawn_set="" live_set="" + for token in "${shown[@]}"; do + [[ -z $token ]] || drawn_set+="$token"$'\n' + done + for token in "${current[@]}"; do + [[ -z $token ]] || live_set+="$token"$'\n' + done + [[ -n $drawn_set ]] || { + echo " There is nothing on this screen to close. Nothing changed." + return + } + if (( PAGE_RENDER_CHANGED )) || [[ $drawn_set != "$live_set" ]]; then + echo " The list changed. Nothing changed." + return + fi + mapfile -t numbers <<<"${drawn_set%$'\n'}" + else + raw=${raw//,/ } + for token in $raw; do + if [[ $token =~ ^[0-9]+$ ]]; then + numbers+=("$token") + elif [[ $token =~ ^([0-9]+)-([0-9]+)$ ]]; then + local first=${BASH_REMATCH[1]} last=${BASH_REMATCH[2]} + if (( first > last || last - first > 98 )); then + echo " There is no such range. Numbers shown here work." + return + fi + local expanded + for (( expanded=first; expanded<=last; expanded++ )); do + numbers+=("$expanded") + done + else + echo " There is no session number in that. Numbers shown here work." + return + fi + done + fi + (( ${#numbers[@]} > 0 )) || { + echo " There is no session number in that. Numbers shown here work." + return + } + require_live_actions || return + local number + for number in "${numbers[@]}"; do + number_on_page "$number" || { + printf ' There is no session %s on this screen. Numbers shown here work.\n' "$number" + return + } + done + local -a closed=() + for number in "${numbers[@]}"; do + if run_proof_action picker-close "$number"; then + closed+=("$number") + fi + done + # Say what happened, here, before the refresh. The only feedback used to be a + # list redrawn without that row -- so when the refresh failed, the screen + # showed "Showing the last confirmed list" with the closed session still on + # it and nothing anywhere saying it had been closed. + if (( ${#closed[@]} == 1 )); then + printf ' Closed session %s.\n' "${closed[0]}" + elif (( ${#closed[@]} > 1 )); then + local joined="" item + for item in "${closed[@]}"; do + joined+="${joined:+, }$item" + done + printf ' Closed %s sessions: %s.\n' "${#closed[@]}" "$joined" + fi + refresh_after_action +} + +# T11: history took any number in the whole view while every other row key +# refuses one that is not on the screen. One typed number, one meaning. +history_number() { + local number=${1//[[:space:]]/} + if [[ ! $number =~ ^[0-9]+$ ]]; then + echo " That needs a session number, such as h 19. Numbers shown here work." + return + fi + number_on_page "$number" || { + printf ' There is no session %s on this screen. Numbers shown here work.\n' "$number" + return + } + run_proof_action picker-history "$number" || true +} + +rename_number() { + local number=$1 metadata provider + require_live_actions || return + number_on_page "$number" || { + printf ' There is no session %s on this screen. Numbers shown here work.\n' "$number" + return + } + metadata=$(number_metadata "$number") || return + local -a fields=() + mapfile -t fields <<<"$metadata" + if (( ${#fields[@]} != 8 )); then + echo " The displayed row changed format. Nothing changed." + return + fi + provider=$(decode64 "${fields[3]}") + if [[ $provider != claude && $provider != codex ]]; then + echo " Only Claude and Codex conversations can have a custom name." + return + fi + local title + picker_modal_read title " New name (↵ back) ❯ " || return 0 + [[ -n ${title//[[:space:]]/} ]] || { + echo " Nothing changed." + return + } + run_proof_action picker-name "$number" "$title" || true + refresh_after_action +} + +change_model_number() { + local number=$1 metadata provider requested choices + require_live_actions || return + number_on_page "$number" || { + printf ' There is no session %s on this screen. Numbers shown here work.\n' "$number" + return + } + metadata=$(number_metadata "$number") || return + local -a fields=() + mapfile -t fields <<<"$metadata" + if (( ${#fields[@]} != 8 )); then + echo " The displayed row changed format. Nothing changed." + return + fi + provider=$(decode64 "${fields[3]}") + if [[ $provider != claude && $provider != codex ]]; then + echo " Only a Claude or Codex conversation can change model." + return + fi + choices=$(PYTHONPATH="$MODULE_DIR/..${PYTHONPATH:+:$PYTHONPATH}" \ + python3 - "$provider" <<'PY' +import os +import sys +from sessionkit_inventory.worker_model import configured_models + +for model in configured_models(sys.argv[1], os.environ): + print(model) +PY +) || choices="" + if [[ -z $choices ]]; then + echo " No models are configured for this provider. Nothing changed." + return + fi + echo + echo " Models" + local model index=0 + while IFS= read -r model; do + index=$(( index + 1 )) + printf ' %s %s\n' "$index" "$model" + done <<<"$choices" + picker_modal_read requested " Model number or exact name · ↵ back · b back ❯ " || return 0 + [[ -n ${requested//[[:space:]]/} ]] || { + echo " Nothing changed." + return + } + # `b` is Back here rather than a model name: no provider names a model `b`, + # and the validator below would refuse it anyway with a longer sentence. + case "${requested,,}" in + b|back) return ;; + esac + if [[ $requested =~ ^[0-9]+$ ]]; then + local selected="" + index=0 + while IFS= read -r model; do + index=$(( index + 1 )) + if [[ $requested == "$index" ]]; then + selected=$model + break + fi + done <<<"$choices" + requested=$selected + elif ! grep -Fqx -- "$requested" <<<"$choices"; then + requested="" + fi + [[ -n $requested ]] || { + echo " That model is not in this provider's configured list. Nothing changed." + return + } + run_proof_action picker-change-model "$number" "$requested" || true + refresh_after_action +} + +reset_name_number() { + local number=$1 metadata provider uuid confidence + require_live_actions || return + number_on_page "$number" || { + printf ' There is no session %s on this screen. Numbers shown here work.\n' "$number" + return + } + metadata=$(number_metadata "$number") || return + local -a fields=() + mapfile -t fields <<<"$metadata" + if (( ${#fields[@]} != 8 )); then + echo " The displayed row changed format. Nothing changed." + return + fi + provider=$(decode64 "${fields[3]}") + uuid=$(decode64 "${fields[6]}") + confidence=$(decode64 "${fields[7]}") + if [[ ( $provider != claude && $provider != codex ) || + $confidence != exact || -z $uuid ]]; then + echo " Only exact Claude and Codex conversations can reset a custom name." + return + fi + run_proof_action picker-name-reset "$number" || true + refresh_after_action +} + +fork_number() { + local number=$1 metadata provider uuid confidence + require_live_actions || return + number_on_page "$number" || { + printf ' There is no session %s on this screen. Numbers shown here work.\n' "$number" + return + } + metadata=$(number_metadata "$number") || return + local -a fields=() + mapfile -t fields <<<"$metadata" + if (( ${#fields[@]} != 8 )); then + echo " The displayed row changed format. Nothing changed." + return + fi + provider=$(decode64 "${fields[3]}") + uuid=$(decode64 "${fields[6]}") + confidence=$(decode64 "${fields[7]}") + if [[ ( $provider != claude && $provider != codex ) || + $confidence != exact || -z $uuid ]]; then + echo " Only exact Claude and Codex conversations can be forked." + return + fi + run_proof_action picker-fork "$number" || true + refresh_after_action +} + +# ---- jump and view toggles --------------------------------------------- +# Every session the screen's estate-wide attention count includes. Hidden +# machine sessions carry only their origin here; their titles never leave the +# private view until the explicit show action expands them. +picker_attention_numbers() { + python3 - "$VIEW" 2>/dev/null <<'PY' +import json +import sys + +try: + with open(sys.argv[1], encoding="utf-8") as handle: + data = json.load(handle) +except (OSError, ValueError): + raise SystemExit(0) +picker = data.get("_picker") or {} +for row in picker.get("attention") or []: + if not isinstance(row, dict): + continue + number = row.get("number") + if isinstance(number, bool) or not isinstance(number, int) or number <= 0: + continue + print(f"{number}\t{row.get('origin') or 'human'}") +PY +} + +# Move to the next session that wants a person, wrapping at the end. The row +# is marked rather than selected: nothing is opened, attached, or acknowledged +# by finding it. +jump_next_attention() { + local line number origin chosen="" chosen_origin=human first="" first_origin=human + local previous_query=$QUERY previous_expanded=${PICKER_MACHINE_EXPANDED:-0} + local seen_current=0 + while IFS=$'\t' read -r number origin; do + [[ $number =~ ^[0-9]+$ ]] || continue + if [[ -z $first ]]; then + first=$number + first_origin=$origin + fi + if [[ -z $PICKER_JUMP_NUMBER ]]; then + chosen=$number + chosen_origin=$origin + break + fi + if (( seen_current )) && [[ -z $chosen ]]; then + chosen=$number + chosen_origin=$origin + break + fi + [[ $number == "$PICKER_JUMP_NUMBER" ]] && seen_current=1 + done < <(picker_attention_numbers) + if [[ -z $chosen ]]; then + if [[ -z $first ]]; then + PICKER_JUMP_NUMBER="" + echo " Nothing needs you." + return + fi + # Past the last one, or the marked row is gone: start again at the top. + chosen=$first + chosen_origin=$first_origin + fi + if [[ $chosen_origin == machine ]]; then + PICKER_MACHINE_EXPANDED=1 + fi + # First try the person's current search, expanding machine rows when the + # chosen session needs it. Clear the search only when it actually hides the + # target, and say so when that happens. + if [[ ${PICKER_MACHINE_EXPANDED:-0} != "$previous_expanded" ]]; then + build_view || { + PICKER_MACHINE_EXPANDED=$previous_expanded + build_view || true + echo " The list could not show that session. Nothing changed." + return + } + fi + local chosen_index + chosen_index=$(view_index_for_number "$chosen") || chosen_index="" + if [[ -z $chosen_index && -n $QUERY ]]; then + QUERY="" + if ! build_view; then + QUERY=$previous_query + PICKER_MACHINE_EXPANDED=$previous_expanded + build_view || true + echo " The list could not show that session. Nothing changed." + return + fi + chosen_index=$(view_index_for_number "$chosen") || chosen_index="" + [[ -z $chosen_index ]] || printf ' Search cleared to show session %s.\n' "$(picker_green "$chosen")" + fi + if [[ -z $chosen_index ]]; then + QUERY=$previous_query + PICKER_MACHINE_EXPANDED=$previous_expanded + build_view || true + echo " The list could not show that session. Nothing changed." + return + fi + PICKER_JUMP_NUMBER=$chosen + PICKER_JUMP_PENDING=1 + printf ' Session %s needs you.\n' "$(picker_green "$chosen")" +} + +toggle_machine_sessions() { + local previous=${PICKER_MACHINE_EXPANDED:-0} + local count counts subagent_total + counts=$(python3 - "$VIEW" <<'PY' 2>/dev/null +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + picker = json.load(handle).get("_picker") or {} +for name in ("machine_expandable_count", "subagent_orphan_count"): + value = picker.get(name, 0) + print(value if isinstance(value, int) and not isinstance(value, bool) else 0) +PY +) || counts=$'0\n0' + count=${counts%%$'\n'*} + subagent_total=${counts#*$'\n'} + if [[ ! $count =~ ^[0-9]+$ || $count -eq 0 ]]; then + if [[ $subagent_total =~ ^[0-9]+$ && $subagent_total -gt 0 ]]; then + echo " Subagent sessions stay with their parent. Nothing changed." + return + fi + echo " No machine sessions are on this screen. Nothing changed." + return + fi + if [[ $previous == 1 ]]; then + PICKER_MACHINE_EXPANDED=0 + echo " Machine sessions hidden." + else + PICKER_MACHINE_EXPANDED=1 + echo " Machine sessions shown." + fi + PICKER_JUMP_NUMBER="" + PICKER_JUMP_PENDING=0 + PAGE=1 + if ! build_view; then + PICKER_MACHINE_EXPANDED=$previous + build_view || true + echo " The machine-session view could not change." + fi +} + +# Grouping decides which rows sit together, never which rows exist. State is +# the default the picker has always drawn; the other two are opt-in for one +# session of the picker and reset when it is next started. +cycle_grouping() { + local requested=${1:-} mode previous + case "${requested,,}" in + "") + case "$(picker_group_mode)" in + state) mode=provider ;; + provider) mode=project ;; + *) mode=state ;; + esac + ;; + state) mode=state ;; + provider) mode=provider ;; + project) mode=project ;; + *) + echo " Grouping is state, provider, or project. Nothing changed." + return + ;; + esac + previous=${PICKER_GROUP_MODE:-state} + PICKER_GROUP_MODE=$mode + if ! build_view; then + PICKER_GROUP_MODE=$previous + build_view || { + sk_log_action picker_refresh failed || true + echo " Live refresh failed. Showing the last confirmed list." + return + } + echo " The list could not be regrouped. The grouping is unchanged." + return + fi + PAGE=1 + printf ' Grouping by %s.\n' "$mode" +} + +# Compact rows drop the group headings and the trailing spacer, and keep only +# the primary state beside each name. Same rows, same numbers, more of them. +toggle_compact() { + if [[ ${PICKER_COMPACT:-0} == 1 ]]; then + PICKER_COMPACT=0 + echo " Compact rows off." + else + PICKER_COMPACT=1 + echo " Compact rows on." + fi + PAGE=1 +} + +project_file() { + local destination=$1 + python3 - "$SK_PROJECTS_FILE" "$destination" <<'PY' +import json +import os +from pathlib import Path +import re +import sys +import unicodedata + +source, destination = map(Path, sys.argv[1:3]) +projects = [] +# The provider is already chosen before this list is shown, and a project's +# own row records a default at most, never a lock. Two rows that differ only +# by provider are one directory listed twice, so keep the first and show it +# under its configured alias. +listed = set() +if source.is_file() and not source.is_symlink(): + for line in source.read_text(encoding="utf-8").splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + fields = line.split("\t") + if len(fields) < 3: + continue + alias, configured_provider, cwd = fields[:3] + if ( + re.fullmatch(r"[a-z0-9_-]+", alias) + and configured_provider in {"claude", "codex", "shell", "any"} + and cwd.startswith("/") + and not any( + unicodedata.category(character).startswith("C") + for character in cwd + ) + and os.path.isdir(cwd) + ): + try: + key = os.path.realpath(cwd) + except OSError: + key = cwd + if key in listed: + continue + listed.add(key) + projects.append({"alias": alias, "cwd": cwd}) +default = next((index for index, row in enumerate(projects, 1) if row["alias"] == "sl"), 1 if projects else None) +with destination.open("w", encoding="utf-8") as handle: + json.dump({"projects": projects, "default": default}, handle, sort_keys=True, separators=(",", ":")) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) +os.chmod(destination, 0o600) +PY +} + +guided_account() { + local provider=$1 choices answer selected status count + GUIDED_ACCOUNT= + new_temp login-accounts || return 1 + choices=$NEW_TEMP + if ! "$SP_CMD" account choices "$provider" >"$choices" 2>/dev/null; then + echo " Account choices are unavailable. Nothing changed." + return 1 + fi + count=$(account_choices_count "$choices") || { + echo " The account list is unreadable. Nothing changed." + return 1 + } + echo + # The enrolment instruction replaces the list. It used to trail an empty + # list and a footer offering choices that did not exist. + if [[ $count == 0 ]]; then + printf ' No %s account is enrolled. Use sp account adopt-default or sp account enroll first.\n' "$provider" + return 1 + fi + account_choices_render "$choices" use "Account" || { + echo " The account list is unreadable. Nothing changed." + return 1 + } + echo + while true; do + picker_modal_read answer " account ❯ " || return 1 + selected=$(account_choice_pick "$choices" use "$answer") + status=$? + if (( status == 0 )); then + break + fi + if (( status == 3 )); then + # A refused pick asks again here. Unwinding to the dashboard threw away + # the provider the person had already chosen. + [[ -z $selected ]] || printf '%s\n' "$selected" + continue + fi + if (( status != 2 )) && [[ -n $selected ]]; then + printf '%s\n' "$selected" + fi + return 1 + done + if [[ ! $selected =~ ^[a-z][a-z0-9_-]{0,11}$ ]]; then + # This abort was silent, which read as the picker ignoring a keypress. + echo " The account list is unreadable. Nothing changed." + return 1 + fi + GUIDED_ACCOUNT=$selected +} +guided_new() { + require_live_actions || return + echo + echo " New session" + echo " 1 Claude Code" + echo " 2 Codex" + echo " 3 Shell" + # Enter takes the most likely option on every screen (operator ruling, + # 2026-08-16): here that is Claude Code. Enter-as-back on this screen sent + # the most common act in the picker -- opening a new thread -- backwards. + echo " ↵ Claude Code · b back" + echo + local answer provider + picker_modal_read answer " provider ❯ " || return 0 + case "$answer" in + b|B|back|q|Q) return ;; + ""|1) provider=claude ;; + 2) provider=codex ;; + 3) provider=shell ;; + *) + picker_unknown_choice "1, 2, 3, Enter, and b" + return + ;; + esac + + local account_alias= + if [[ $provider == claude || $provider == codex ]]; then + guided_account "$provider" || return + account_alias=$GUIDED_ACCOUNT + fi + + local projects + new_temp login-projects || return + projects=$NEW_TEMP + project_file "$projects" || { + echo " The project list is unavailable. Nothing changed." + return + } + echo + python3 - "$projects" <<'PY' +import json,sys,unicodedata +d=json.load(open(sys.argv[1])) +default=d.get("default") +print(" Project") +def cells(text): + return sum( + 0 if unicodedata.combining(character) + else 2 if unicodedata.east_asian_width(character) in {"W", "F"} + else 1 + for character in text + ) +# The alias is variable width and the path sits behind it: one column, so the +# paths read as a list instead of a staircase. +alias_width=max((cells(row["alias"]) for row in d.get("projects",[])), default=0) +for number,row in enumerate(d.get("projects",[]),1): + label=row["alias"] + ":" + label += " " * max(0, alias_width + 1 - cells(label)) + print(f" {number:2} {label} {row['cwd']}") +if default: + row=d["projects"][default-1] + print(f" ↵ use {row['alias']}: {row['cwd']} · number use that project · b back") +else: + print(" ↵ use the current directory · number use that project · b back") +PY + echo + local project_choice project_alias + picker_modal_read project_choice " project ❯ " || return 0 + case "${project_choice,,}" in + b|back) return ;; + esac + project_alias=$(python3 - "$projects" "$project_choice" <<'PY' +import json,sys +d=json.load(open(sys.argv[1])) +choice=sys.argv[2].strip() +if not choice: + default=d.get("default") + if not default: + print("") + raise SystemExit(0) + choice=str(default) +if not choice.isdigit(): + raise SystemExit(2) +number=int(choice) +rows=d.get("projects",[]) +if number < 1 or number > len(rows): + raise SystemExit(2) +print(rows[number-1]["alias"]) +PY +) || { + echo " There is no such project on this screen. Numbers shown here work." + return + } + if [[ $provider == claude || $provider == codex ]]; then + echo " Name it later with name number." + fi + if [[ -n $project_alias ]]; then + if [[ -n $account_alias ]]; then + "$SP_CMD" new "$provider" "$project_alias" --account "$account_alias" || true + else + "$SP_CMD" new "$provider" "$project_alias" || true + fi + else + if [[ -n $account_alias ]]; then + "$SP_CMD" new "$provider" --account "$account_alias" || true + else + "$SP_CMD" new "$provider" || true + fi + fi + refresh_after_action +} diff --git a/lib/sh/shpool_login_live.sh b/lib/sh/shpool_login_live.sh new file mode 100644 index 0000000..8a47d18 --- /dev/null +++ b/lib/sh/shpool_login_live.sh @@ -0,0 +1,1628 @@ +#!/usr/bin/env bash +# Picker process lifetime and the live snapshot loop: temporary files, the +# logged exit path, background collection, and the reads that let a menu +# repaint under a half-typed line. Source this file; do not execute it. +# +# Source order: bin/shpool_login sources this module before it installs its +# first trap, so trap cleanup EXIT always names a defined function. Everything +# else these functions read (TEMP_FILES, NEW_TEMP, SNAPSHOT, VIEW, PAGE, QUERY, +# LIVE_PID, LIVE_FILE, LIVE_DONE, LIVE_FINGERPRINT, PICKER_LIVE_STATUS, +# PENDING_CACHE, PICKER_INTERRUPTED, RECOVERY) is assigned further down that +# file, which is still before the boot sequence that first calls them. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +cleanup() { + picker_input_restore + picker_paste_disarm + # The collector goes FIRST. It was left running on every ordinary exit -- + # picker_live_stop had exactly one caller, the self-upgrade -- so quitting + # during a refresh left a detached status collection burning a core and + # ~150 MB, and its last write recreated the done-marker this loop had just + # deleted (five such files were found on disk). + picker_live_stop + # The event subscriber is a long-lived child of this shell. Left behind it + # would hold a descriptor on a deleted temp file for as long as the daemon + # runs, so it goes before the temp files it writes to. + picker_events_stop + local path + for path in "${TEMP_FILES[@]}"; do + [[ -z $path || ! -f $path ]] || command rm -- "$path" + done +} + +# ---- pasted input ----------------------------------------------------------- +# A paste is data, never a queue of commands. Unarmed, a pasted block arrives +# as ordinary keystrokes and its first newline submits, so a block containing +# `k 17` closed session 17 and a blank line inside it left the picker. With +# bracketed paste armed the terminal brackets the block in these two markers, +# and everything between them is read here as ONE literal line: it fills the +# prompt, where it can be read, edited, or used as filter text, and nothing +# inside it is ever dispatched. +PICKER_PASTE_OPEN=$'\033[200~' +PICKER_PASTE_CLOSE=$'\033[201~' +PICKER_PASTE_RAW="" +PICKER_PASTE_TEXT="" +# The bytes the last escape sequence consumed after ESC, so a caller can tell +# a paste marker from an arrow key it should keep dropping. +PICKER_ESCAPE_TAIL="" +# Far more than any filter, name, or path, and small enough that assembling it +# a character at a time stays instant. +PICKER_PASTE_LIMIT=8192 + +# A timed read from the terminal can eat the byte it was handed: bash pulls +# the byte out of the kernel, the timer fires before the builtin finishes, and +# the jump out of the read abandons what was already consumed -- the kernel no +# longer has it and the variable never gets it. On a loaded machine the gap +# between those two moments stretches to whole scheduler slices, and about one +# keypress in three hundred dies on the timer edge (measured 2026-08-19: +# `read -n 1 -t 1` on a raw pty under one contended CPU lost the q of a q-Enter +# exactly the way CI keeps losing it). So no read that CONSUMES from the +# terminal may carry a timeout. Waiting happens against this fifo -- a +# descriptor that never has data, so its timeouts have nothing to abandon -- +# and readiness is asked with `read -t 0`, which consumes nothing. +PICKER_TICK_FD="" +PICKER_TICK_FAILED=0 + +picker_tick_open() { + [[ -z $PICKER_TICK_FD ]] || return 0 + (( ! PICKER_TICK_FAILED )) || return 1 + local fifo=${SK_STATE_DIR:-${TMPDIR:-/tmp}}/.picker-tick.$$ + command rm -f -- "$fifo" + if ! mkfifo -m 600 -- "$fifo" 2>/dev/null; then + PICKER_TICK_FAILED=1 + return 1 + fi + # Opened read-write, so this shell is its own writer and the open cannot + # block; unlinked at once, so the descriptor is the only trace it leaves. + if ! exec {PICKER_TICK_FD}<>"$fifo"; then + PICKER_TICK_FD="" + PICKER_TICK_FAILED=1 + command rm -f -- "$fifo" + return 1 + fi + command rm -f -- "$fifo" +} + +# Sleep without touching the terminal. Nothing ever writes the tick fifo, so +# the timeout is the whole point, and a trapped signal still ends the wait +# early -- which is how Ctrl-C keeps its sub-second response. +picker_tick() { + local __sk_tick_junk="" + IFS= read -r -t "${1:-0.05}" -u "$PICKER_TICK_FD" __sk_tick_junk 2>/dev/null + return 0 +} + +# True when the terminal has input ready to hand over whole -- a complete line +# in canonical mode, any byte in character mode -- asked without consuming: +# a zero timeout never reads. +picker_input_ready() { + IFS= read -r -t 0 +} + +# Wait up to $1 MICROSECONDS for terminal input. 0 the moment input is ready; +# 142 when the budget ran out or a trap flagged interrupt or resize, so +# callers answer signals as fast as the timed read they replaced. The budget +# is measured on the wall clock, not in ticks: under load every tick pays a +# scheduling tax, and twenty of them stretched the one-second beat to two or +# three -- which starved the event-collection cadence the beat drives. +picker_wait_input() { + local __sk_wait_end=0 __sk_wait_ticks=0 __sk_wait_limit=0 + # Readiness is asked again AFTER every tick and answered before the signal + # flags: keys and a Ctrl-C landing inside the same tick must come out in + # the order the person produced them. The old blocking read had this + # property for free -- it returned each queued byte ahead of noticing any + # flag -- and the interrupt test types a line and interrupts it in the + # same breath, expecting the line echoed and THEN thrown away. + if [[ -n ${EPOCHREALTIME:-} ]]; then + __sk_wait_end=$(( ${EPOCHREALTIME/./} + $1 )) + while true; do + picker_input_ready && return 0 + (( ${EPOCHREALTIME/./} < __sk_wait_end )) || return 142 + picker_tick + picker_input_ready && return 0 + (( PICKER_INTERRUPTED == 0 && PICKER_RESIZED == 0 )) || return 142 + done + fi + # No sub-second clock in this bash: count ticks and accept the stretch. + __sk_wait_limit=$(( $1 / 50000 )) + (( __sk_wait_limit > 0 )) || __sk_wait_limit=1 + while true; do + picker_input_ready && return 0 + (( __sk_wait_ticks < __sk_wait_limit )) || return 142 + picker_tick + __sk_wait_ticks=$(( __sk_wait_ticks + 1 )) + picker_input_ready && return 0 + (( PICKER_INTERRUPTED == 0 && PICKER_RESIZED == 0 )) || return 142 + done +} + +picker_paste_arm() { + (( PICKER_SCREEN )) || return 0 + [[ -t 0 ]] || return 0 + printf '\033[?2004h' +} + +picker_paste_disarm() { + (( PICKER_SCREEN )) || return 0 + [[ -t 0 ]] || return 0 + printf '\033[?2004l' +} + +# One line, no control bytes. Every newline in a paste is a separator the +# prompt would have read as Enter, so they become spaces along with every +# other control byte -- nothing pasted can submit a line, move the cursor, or +# forge a row. +picker_paste_clean() { + local text=${1//[[:cntrl:]]/ } + local -a words=() + # Default IFS collapses runs of blanks and drops them at both ends. + read -r -a words <<<"$text" || true + text="${words[*]}" + PICKER_PASTE_TEXT=${text:0:$PICKER_PASTE_LIMIT} +} + +# Read the rest of a paste straight from the terminal, up to its closing +# marker. Bounded twice: a terminal that never closes the block must not hold +# the prompt, and an enormous paste must not be assembled forever. +# One raw byte of a paste block: -N so a newline is data, the wait spent on +# ticks (forty of them, the old two-second bound), the take untimed -- the +# same separation as every other consuming read here. +picker_paste_next() { + local __sk_paste_var=$1 __sk_paste_ticks=0 + if [[ -z $PICKER_TICK_FD ]]; then + # shellcheck disable=SC2229 + IFS= read -r -s -N 1 -t 2 "$__sk_paste_var" 2>/dev/null # tick-fallback + return + fi + while ! picker_input_ready; do + __sk_paste_ticks=$(( __sk_paste_ticks + 1 )) + (( __sk_paste_ticks <= 40 )) || return 1 + picker_tick + done + # shellcheck disable=SC2229 + IFS= read -r -s -N 1 "$__sk_paste_var" 2>/dev/null +} + +picker_paste_drain() { + local character="" + PICKER_PASTE_RAW="" + while (( ${#PICKER_PASTE_RAW} < PICKER_PASTE_LIMIT )); do + # -N takes the byte as it is, so a newline inside the block is data here + # rather than the end of a read. + picker_paste_next character || break + if [[ $character == $'\033' ]]; then + picker_swallow_escape + [[ $PICKER_ESCAPE_TAIL != '[201~' ]] || break + continue + fi + PICKER_PASTE_RAW+=$character + done +} + +# Fold a line that carries an opening marker -- plus whatever the terminal +# still has queued behind it -- into one literal line in PICKER_PASTE_TEXT. +picker_paste_absorb() { + local line=$1 prefix body trailing="" + prefix=${line%%"$PICKER_PASTE_OPEN"*} + body=${line#*"$PICKER_PASTE_OPEN"} + if [[ $body == *"$PICKER_PASTE_CLOSE"* ]]; then + trailing=${body#*"$PICKER_PASTE_CLOSE"} + body=${body%%"$PICKER_PASTE_CLOSE"*} + else + # The block runs past the newline that ended this read; the rest of it is + # still queued and belongs to the same paste. + picker_paste_drain + body+=$'\n'$PICKER_PASTE_RAW + fi + picker_paste_clean "$prefix$body$trailing" +} + +picker_paste_in_line() { + [[ $1 == *"$PICKER_PASTE_OPEN"* ]] +} + +# Ctrl-C abandons the line, and everything typed ahead of it goes with the +# line: a keystroke still sitting in the terminal is part of the command the +# person just cancelled, and reading it at the next prompt would act on it. +# A real interrupt keystroke flushes the queue itself; a signal delivered any +# other way does not, which is the whole difference this closes. +picker_discard_pending_input() { + local __sk_junk="" __sk_reads=0 + [[ -t 0 ]] || return 0 + while (( __sk_reads < PICKER_PASTE_LIMIT )); do + __sk_reads=$(( __sk_reads + 1 )) + IFS= read -r -s -N 1 -t 0.01 __sk_junk 2>/dev/null || break # discard-by-design + done + return 0 +} + +# What a person had set up on the screen, carried across a self-upgrade only. +# Read once and unset, so nothing a session is launched with ever inherits it. +# Called after the first snapshot, which is what clears the filter and page. +picker_resume_view() { + local page=${SESSION_KIT_PICKER_RESUME_PAGE:-} + local query=${SESSION_KIT_PICKER_RESUME_QUERY:-} + unset SESSION_KIT_PICKER_RESUME_QUERY SESSION_KIT_PICKER_RESUME_PAGE + if [[ -n $query ]]; then + QUERY=$query + build_view || { + QUERY="" + build_view || true + } + fi + # The page is left for render_main to clamp against the size it computes. + [[ $page =~ ^[0-9]+$ && $page -ge 1 ]] && PAGE=$page + return 0 +} + +picker_input_restore() { + if [[ -n ${PICKER_TTY_STATE:-} && -t 0 ]]; then + stty "$PICKER_TTY_STATE" < /dev/tty 2>/dev/null || true + fi + PICKER_TTY_STATE="" +} + +# Every deliberate or forced picker exit records why, so "it dropped me to a +# regular terminal" is diagnosable from the action log after the fact. Leaving +# on purpose is a success; anything else is the picker failing to run, and its +# reason is already on stderr (`sp help exit-codes`). +picker_exit() { + local reason=$1 status=1 + sk_log_action picker_exit "$reason" || true + case "$reason" in + terminal_requested | quit | input_closed) status=0 ;; + esac + exit "$status" +} + +picker_live_seconds() { + local seconds=${SESSION_KIT_PICKER_REFRESH_SECONDS:-5} + [[ $seconds =~ ^[0-9]+$ ]] || seconds=0 + # A repaint REPLACES the menu. A terminal that cannot be cleared would + # stack copies down the screen instead, so it stays static. + (( PICKER_SCREEN )) || seconds=0 + # Never poll faster than a snapshot takes to collect. + (( seconds == 0 || seconds >= 2 )) || seconds=2 + printf '%s' "$seconds" +} + +# ---- push events ------------------------------------------------------------ +# The list changes when a session is created, attached, detached or removed, +# and the daemon says so the moment it happens (EVENTS.md). Subscribing to that +# stream is what lets the timed poll drop from every five seconds to every +# thirty: the poll stops being how the picker learns and becomes the safety net +# under a stream that can be dropped. +# +# Three properties of the stream decide this whole design: +# * the events carry no payload -- one line means "the table moved", so every +# event is answered by the same full collection the poll runs; +# * a slow subscriber is DROPPED by the daemon, with no replay, so a stream +# that ends is not "quiet", it is a hole -- reconnecting has to be paired +# with a full resync or the picker keeps showing what was true before it; +# * nothing here may start a process in the typing loop, so the stream is +# read through one long-lived file descriptor with the `read` builtin. +# +# Off with SESSION_KIT_PICKER_EVENTS=0 -- or off, no, false, in any +# capitalisation, spaces around it and all (picker_switch_off) -- which +# restores exactly the five-second poll this replaced. Watchdog health probes +# are NOT part of this: events say the table changed, never that a session is +# healthy. +PICKER_EVENTS_STATE=off +PICKER_EVENTS_FILE= +PICKER_EVENTS_DOWN= +PICKER_EVENTS_PID= +PICKER_EVENTS_FD= +PICKER_EVENTS_STARTS=0 +# Two failed subscriptions are a broken stream, not a race: after that the +# picker stays on the poll for the rest of its life and says so in the log. +PICKER_EVENTS_MAX_STARTS=2 +PICKER_LIVE_INTERVAL=0 +# The attention watcher. The daemon's stream cannot carry "this session is +# waiting for a person" -- that is no create, no attach, no detach and no +# remove -- so on its own it is not a reason to poll less often: the one column +# an operator watches would have got six times slower. Measured on this estate: +# 25 seconds of `shpool events` over eleven busy sessions, zero lines. This +# second stream watches the files that answer changes, and the poll widens ONLY +# while both are live. +PICKER_PULSE_PID= +PICKER_PULSE_STATE=off +# An event that lands while a collection is already running describes a table +# that collection may have read BEFORE the change. Coalescing an event storm is +# right; coalescing an event into a snapshot older than itself is how a change +# disappears until the next poll. +PICKER_EVENTS_TRAILING=0 + +# A switch an operator reaches for is normalised before it is read. These are +# documented rollback levers on an always-on child, and `OFF`, `Off` and a +# value with a space around it are the same answer as `off`: a lever that +# silently does nothing unless it is typed in lower case is worse than no lever +# at all, because it reports success. Every switch this round added on the +# Python side already reads them this way (claude_socket.channel_enabled, +# attention.py), and `${VAR,,}` is already used in this tree. +picker_switch_off() { + local value=$1 + value=${value#"${value%%[![:space:]]*}"} + value=${value%"${value##*[![:space:]]}"} + case "${value,,}" in + 0 | off | no | false) return 0 ;; + esac + return 1 +} + +picker_events_wanted() { + ! picker_switch_off "${SESSION_KIT_PICKER_EVENTS:-auto}" || return 1 + (( PICKER_SCREEN )) || return 1 + [[ -n ${SK_SHPOOL:-} && -x ${SK_SHPOOL:-} ]] || return 1 + return 0 +} + +# The capability probe. `shpool events` exists from the release that added the +# events socket; an older daemon's CLI refuses the subcommand, which is the +# answer this needs and the reason it is asked of the binary rather than +# guessed from a version string. +picker_events_supported() { + sk_timeout "${SK_EVENTS_PROBE_TIMEOUT:-5}" "$SK_SHPOOL" events --help \ + > /dev/null 2>&1 +} + +# Subscribe, and treat the subscription as the thing that can fail: the +# subscriber's exit is published as a marker file so the typing loop can see a +# dropped stream with a builtin test instead of reaping a process. +picker_events_start() { + [[ -z $PICKER_EVENTS_PID ]] || return 0 + (( PICKER_EVENTS_STARTS < PICKER_EVENTS_MAX_STARTS )) || return 1 + PICKER_EVENTS_STARTS=$(( PICKER_EVENTS_STARTS + 1 )) + new_temp login-events || return 1 + PICKER_EVENTS_FILE=$NEW_TEMP + new_temp login-events-down || return 1 + PICKER_EVENTS_DOWN=$NEW_TEMP + : > "$PICKER_EVENTS_FILE" + : > "$PICKER_EVENTS_DOWN" + { + "$SK_SHPOOL" events >> "$PICKER_EVENTS_FILE" 2>/dev/null < /dev/null + printf 'down\n' > "$PICKER_EVENTS_DOWN" + } & + PICKER_EVENTS_PID=$! + # One descriptor for the life of the subscription. Every later read picks up + # where the last one stopped, so no event is read twice and none is missed. + exec {PICKER_EVENTS_FD}< "$PICKER_EVENTS_FILE" || { + PICKER_EVENTS_FD= + picker_events_stop + return 1 + } + PICKER_EVENTS_STATE=live + picker_pulse_start || sk_log_action picker_events no_pulse || true + sk_log_action picker_events subscribed || true + return 0 +} + +# How often the attention watcher looks, validated before it is interpolated +# into the child's argv. `pulse.py --interval abc` exits 2 inside argparse +# before it watches anything, so an unparseable value KILLED the watcher in +# milliseconds -- and the picker widened its poll anyway, which is precisely +# the widened-and-blind state the round promises can never happen. The sibling +# SESSION_KIT_PICKER_EVENT_POLL_SECONDS is validated the same way in +# picker_live_interval; a typo falls back to the default rather than to +# nothing. The Python side clamps the number it is given (0.05-60s), so the +# only class this has to catch is "argparse cannot parse it at all". +PICKER_PULSE_DEFAULT_SECONDS=1 +picker_pulse_interval() { + local value=${SESSION_KIT_PICKER_PULSE_SECONDS:-} + value=${value#"${value%%[![:space:]]*}"} + value=${value%"${value##*[![:space:]]}"} + [[ $value =~ ^([0-9]+|[0-9]*\.[0-9]+)$ ]] || value=$PICKER_PULSE_DEFAULT_SECONDS + printf '%s' "$value" +} + +# Is the attention watcher still there? A background child that has exited is +# still a pid until the shell reaps it, so `kill -0` answers yes for a watcher +# that died a millisecond ago -- the state field is what tells them apart. +# pulse.py reads its own parent exactly this way (_parent_is_zombie). +picker_pulse_alive() { + [[ -n $PICKER_PULSE_PID ]] || return 1 + kill -0 "$PICKER_PULSE_PID" 2>/dev/null || return 1 + local stat_line= + if [[ -r /proc/$PICKER_PULSE_PID/stat ]]; then + read -r stat_line < "/proc/$PICKER_PULSE_PID/stat" || return 0 + # The comm field is parenthesised and may contain spaces; the state is the + # first field after the last ')'. + stat_line=${stat_line##*') '} + [[ ${stat_line:0:1} != Z ]] || return 1 + else + # No /proc (macOS): ask ps for the same letter. One subprocess, and only + # on the platform that has no cheaper answer. + local state= + state=$(ps -o state= -p "$PICKER_PULSE_PID" 2>/dev/null) || state= + state=${state//[[:space:]]/} + [[ ${state:0:1} != Z ]] || return 1 + fi + return 0 +} + +# One line per change, into the same file the daemon's events go to: the typing +# loop drains one descriptor and does not care which stream said so. +picker_pulse_start() { + [[ -z $PICKER_PULSE_PID ]] || return 0 + ! picker_switch_off "${SESSION_KIT_PICKER_PULSE:-auto}" || return 1 + [[ -n $PICKER_EVENTS_FILE && -f $PICKER_EVENTS_FILE ]] || return 1 + local tool=${SESSION_KIT_PULSE_TOOL:-"$SCRIPT_DIR/../lib/sessionkit_inventory/pulse.py"} + [[ -f $tool ]] || return 1 + local interval + interval=$(picker_pulse_interval) + # --parent-pid is this picker. picker_pulse_stop is the ordinary stop; this + # is the one that survives a picker nobody gets to trap -- an OOM kill, a + # SIGKILL -- after which nothing else would ever end this child. + python3 "$tool" --interval "$interval" \ + --parent-pid "$$" \ + >> "$PICKER_EVENTS_FILE" 2>/dev/null < /dev/null & + PICKER_PULSE_PID=$! + # A watcher that is already gone is not a watcher. The poll may only widen + # behind one that is running, so the state says live only if it is. + if ! picker_pulse_alive; then + picker_pulse_stop + return 1 + fi + PICKER_PULSE_STATE=live + return 0 +} + +picker_pulse_stop() { + [[ -n $PICKER_PULSE_PID ]] || return 0 + kill "$PICKER_PULSE_PID" 2>/dev/null || true + wait "$PICKER_PULSE_PID" 2>/dev/null || true + PICKER_PULSE_PID= + PICKER_PULSE_STATE=off +} + +picker_events_stop() { + picker_pulse_stop + if [[ -n $PICKER_EVENTS_FD ]]; then + # The braces are load-bearing. A redirection written on a bare `exec` + # applies to the SHELL and never comes back, so `exec {FD}<&- 2>/dev/null` + # closed the subscription AND pointed this picker's stderr at /dev/null + # for the rest of its life. Nothing looked wrong -- the picker still drew, + # still took keys -- but shpool's client declines to put a terminal into + # raw mode unless stdin, stdout AND stderr are all a terminal + # (libshpool/src/tty.rs, set_attach_flags), and it declines SILENTLY. So + # every session opened from that picker afterwards came up with the + # keyboard dead: keystrokes echoed by the kernel as literal ^L over the + # provider's first screen, nothing delivered until Enter. The operator hit + # exactly this at 00:46 on 2026-08-15, twelve minutes of it, and the only + # cure was `stty raw -echo` typed from another window. + # + # Grouping keeps the suppression where it was meant to be -- on this one + # close, which is all it was ever written to quieten -- and leaves fd 2 + # alone. The close still happens and a bad descriptor is still silent; + # tests/test_picker_stderr_survives.py holds all three properties. + { exec {PICKER_EVENTS_FD}<&-; } 2>/dev/null || true + PICKER_EVENTS_FD= + fi + if [[ -n $PICKER_EVENTS_PID ]]; then + # The subscriber is a subshell whose child is the long-lived `shpool + # events` run. Killing the subshell alone would leave that run attached to + # the daemon, writing into a file this function is about to unlink -- the + # same detached-collector shape picker_live_stop was written for. + local child + for child in $(pgrep -P "$PICKER_EVENTS_PID" 2>/dev/null || true); do + kill "$child" 2>/dev/null || true + done + kill "$PICKER_EVENTS_PID" 2>/dev/null || true + wait "$PICKER_EVENTS_PID" 2>/dev/null || true + PICKER_EVENTS_PID= + fi + [[ -z $PICKER_EVENTS_FILE ]] || command rm -f -- "$PICKER_EVENTS_FILE" + PICKER_EVENTS_FILE= + [[ -z $PICKER_EVENTS_DOWN ]] || command rm -f -- "$PICKER_EVENTS_DOWN" + PICKER_EVENTS_DOWN= +} + +# 0 when the estate should be re-collected NOW: either an event arrived, or the +# stream died and everything it would have said is unknown. Builtins only. +picker_events_pending() { + [[ $PICKER_EVENTS_STATE == live ]] || return 1 + local line seen=1 + if [[ -n $PICKER_EVENTS_FD ]]; then + # A line half-written when this read lands is still evidence that the + # table moved; a duplicate refresh is free, because a collection already + # in flight is not started twice. + while IFS= read -r -t 0.01 -u "$PICKER_EVENTS_FD" line || [[ -n $line ]]; do + [[ -z $line ]] || seen=0 + line="" + done + fi + # The daemon drops subscribers that fall behind and never replays what they + # missed, so a stream that ended is a resync, not a quiet estate. + if [[ -n $PICKER_EVENTS_DOWN && -s $PICKER_EVENTS_DOWN ]]; then + sk_log_action picker_events dropped || true + picker_events_stop + if picker_events_start; then + sk_log_action picker_events resubscribed_resync || true + else + PICKER_EVENTS_STATE=fallback + sk_log_action picker_events fallback_poll || true + fi + return 0 + fi + return "$seen" +} + +# Collect now, for a reason that arrived from outside: an event, an attention +# change, or a resync after a dropped stream. If a collection is already +# running it may have read the session table BEFORE this reason existed, so the +# request is remembered and answered the moment that one is adopted. The timed +# poll deliberately does NOT do this -- it has no news, and chaining it would +# turn a slow estate into one continuous collection. +picker_collect_now() { + if [[ -n $LIVE_PID ]]; then + PICKER_EVENTS_TRAILING=1 + return 0 + fi + picker_live_start || true +} + +# Subscribe once per picker, and resync immediately after: the estate can have +# moved between the snapshot on screen and the first event this stream carries. +picker_events_open() { + [[ $PICKER_EVENTS_STATE == off ]] || return 1 + picker_events_wanted || return 1 + if ! picker_events_supported; then + PICKER_EVENTS_STATE=fallback + sk_log_action picker_events unsupported || true + return 1 + fi + picker_events_start || { + PICKER_EVENTS_STATE=fallback + sk_log_action picker_events start_failed || true + return 1 + } + return 0 +} + +# How long the timed collection may wait. With a live stream the poll is the +# net under it, not the way the picker learns; without one it is the whole +# mechanism and keeps its old cadence. +picker_live_interval() { + local base=$1 widened + PICKER_LIVE_INTERVAL=$base + [[ $PICKER_EVENTS_STATE == live ]] || return 0 + # Without the attention watcher the timed poll is still the only way a + # waiting session is noticed, so it keeps the cadence it always had. This is + # the whole guarantee: the poll never widens past a stream that cannot carry + # attention. + [[ $PICKER_PULSE_STATE == live ]] || return 0 + # Asked at the moment of widening, not once at start-up: a watcher that died + # for any reason -- a bad interval, a missing interpreter, an OOM kill -- + # would otherwise leave this poll six times slower with nothing pushing + # attention changes into it, and nothing on screen or in the log to say so. + if ! picker_pulse_alive; then + picker_pulse_stop + sk_log_action picker_events pulse_down || true + return 0 + fi + widened=${SESSION_KIT_PICKER_EVENT_POLL_SECONDS:-30} + [[ $widened =~ ^[0-9]+$ ]] || widened=30 + (( widened >= base )) || widened=$base + PICKER_LIVE_INTERVAL=$widened +} + +# What the menu actually shows. Collection timestamps are deliberately absent +# so an unchanged estate never repaints. +picker_view_fingerprint() { + python3 - "$VIEW" "$PAGE" "$QUERY" <<'PY' 2>/dev/null || printf 'unknown' +import hashlib +import json +import sys + +path, page, query = sys.argv[1:4] +try: + with open(path, encoding="utf-8") as handle: + data = json.load(handle) +except (OSError, ValueError): + print("unreadable") + raise SystemExit(0) +fields = ( + "display_shpool_id", + "display_provider", + "account_alias", + "display_title", + "display_color", + "agent_status", + "availability", + "active_subagent_count", + "automatic_name_state", + "terminal_number", +) +source_rows = [ + row for row in data.get("sessions", []) if isinstance(row, dict) +] +rows = [ + [row.get(name) for name in fields] + for row in source_rows +] +shape = { + "rows": rows, + "stale": data.get("stale"), + "page": page, + "query": query, +} +digest = hashlib.sha256( + json.dumps(shape, sort_keys=True, default=str).encode("utf-8") +).hexdigest() +print(digest) +PY +} + +picker_live_start() { + [[ -z $LIVE_PID ]] || return 0 + new_temp login-live || return 1 + LIVE_FILE=$NEW_TEMP + new_temp login-live-pending || return 1 + LIVE_PENDING=$NEW_TEMP + new_temp login-live-done || return 1 + LIVE_DONE=$NEW_TEMP + : > "$LIVE_DONE" + # An exited-but-unreaped child still answers `kill -0`, so completion is + # published explicitly instead of inferred from the process table. + # + # The pending-recovery count is collected here too, as ONE integer in a file. + # It used to be fetched between a keystroke and its echo -- a whole + # shpool_status process, in the foreground of the typing loop, in -echo mode, + # for a number this same collector already holds. That was 150 ms of every + # five seconds in which characters the operator typed were invisible. + { + local status=0 + sk_timeout "${SK_REFRESH_TIMEOUT:-30}" "$STATUS_CMD" --json \ + > "$LIVE_FILE" 2>/dev/null || status=$? + recovery_count > "$LIVE_PENDING" 2>/dev/null || : > "$LIVE_PENDING" + printf '%s\n' "$status" > "$LIVE_DONE" + } & + LIVE_PID=$! +} + +picker_live_ready() { + [[ -n $LIVE_PID && -n $LIVE_DONE && -s $LIVE_DONE ]] +} + +# 0 only when a fresh snapshot was adopted AND it changes the visible list. +# The active search and page are preserved: silently resetting a filter the +# human is reading would be worse than the staleness this replaces. +picker_live_collect() { + picker_live_ready || return 1 + local status finished_marker=$LIVE_DONE finished_pending=$LIVE_PENDING + IFS= read -r status < "$LIVE_DONE" 2>/dev/null || status=1 + [[ $status =~ ^[0-9]+$ ]] || status=1 + wait "$LIVE_PID" 2>/dev/null || true + LIVE_PID= + LIVE_DONE= + LIVE_PENDING= + # Superseded temps are freed the moment they stop being referenced. The + # EXIT trap remains the backstop, but a picker that dies uncleanly must + # leak minutes of files, not days (the reaper found 36k of them). + [[ -z $finished_marker ]] || command rm -f -- "$finished_marker" + # The count the collector produced, read as a number and never recomputed + # here: this is the typing loop, and nothing in it may start a process. + if [[ -n $finished_pending ]]; then + picker_adopt_pending "$finished_pending" + command rm -f -- "$finished_pending" + fi + if (( status != 0 )) || [[ ! -s $LIVE_FILE ]]; then + [[ -z $LIVE_FILE ]] || command rm -f -- "$LIVE_FILE" + LIVE_FILE= + sk_log_action picker_refresh "background_failed_status_$status" || true + if [[ -z $LIVE_WARNING ]]; then + LIVE_WARNING="Live refresh failed. Showing the last confirmed list." + return 2 + fi + return 1 + fi + local previous_snapshot=$SNAPSHOT previous_view=$VIEW + SNAPSHOT=$LIVE_FILE + LIVE_FILE= + if ! build_view; then + [[ -z $SNAPSHOT || $SNAPSHOT == "$previous_snapshot" ]] || + command rm -f -- "$SNAPSHOT" + SNAPSHOT=$previous_snapshot + VIEW=$previous_view + sk_log_action picker_refresh background_invalid_snapshot || true + if [[ -z $LIVE_WARNING ]]; then + LIVE_WARNING="Live refresh returned bad data. Showing the last confirmed list." + return 2 + fi + return 1 + fi + [[ -z $previous_snapshot || $previous_snapshot == "$SNAPSHOT" ]] || + command rm -f -- "$previous_snapshot" + [[ -z $previous_view || $previous_view == "$VIEW" ]] || + command rm -f -- "$previous_view" + clamp_page + local fingerprint had_warning=0 + [[ -z $LIVE_WARNING ]] || had_warning=1 + LIVE_WARNING="" + fingerprint=$(picker_view_fingerprint) + [[ $fingerprint != "$LIVE_FINGERPRINT" || $had_warning == 1 ]] || return 1 + LIVE_FINGERPRINT=$fingerprint + return 0 +} + +# The one read every picker prompt is built on, and the only place the three +# ways a read can end are told apart: +# +# 0 a line was typed +# 1 the read was interrupted (Ctrl-C) +# 2 input ended (Ctrl-D on an empty line, or a closed pipe) +# +# What each of those MEANS belongs to the caller: the home menu leaves on 2, +# a modal goes back exactly one level. Deciding that here is what let a +# Ctrl-D three menus deep end the whole picker. +picker_read_raw() { + local __sk_raw_var=$1 __sk_raw_prompt=$2 __sk_raw_surface=${3:-modal} + local __sk_raw_status=0 + PICKER_INTERRUPTED=0 + # Off a terminal there is no Ctrl-C to answer and no prompt to print: a + # script feeding this picker gets the plain blocking read it always got, + # which is also the only read that can never split a slowly-arriving line. + if [[ ! -t 0 ]]; then + # The indirection is deliberate: callers pass the DESTINATION variable name. + # shellcheck disable=SC2229 + if IFS= read -r "$__sk_raw_var"; then + return 0 + fi + (( PICKER_INTERRUPTED )) || return 2 + PICKER_INTERRUPTED=0 + echo + return 1 + fi + # A plain `read` is RESTARTED after a trapped signal, bash runs the handler + # and goes straight back to waiting, so the interrupt flag set by the trap + # is never looked at until a whole line is typed. That is the entire reason + # Ctrl-C could not cancel a single prompt in this picker. A one-second bound + # gives the flag somewhere to be noticed; the line discipline is holding any + # half-typed line, so nothing typed is lost across the poll. + picker_paste_arm + printf '%s' "$__sk_raw_prompt" + picker_tick_open || true + while true; do + # Waiting and consuming are separated on purpose: only an UNTIMED read may + # take bytes off the terminal, and it runs solely after `read -t 0` says a + # whole answer is already queued -- so it returns at once and no timer can + # fire while it holds a half-taken keypress. + if picker_input_ready; then + # shellcheck disable=SC2229 + if IFS= read -r "$__sk_raw_var"; then + # A pasted block is one answer to this prompt, never a queue of them: + # the rest of the block is read here and folded into the same line, so + # no later prompt is answered by a line nobody typed. + if picker_paste_in_line "${!__sk_raw_var}"; then + picker_paste_absorb "${!__sk_raw_var}" + # shellcheck disable=SC2229 + printf -v "$__sk_raw_var" '%s' "$PICKER_PASTE_TEXT" + fi + return 0 + fi + # Readable but nothing deliverable is closed input, not a timeout. + __sk_raw_status=1 + elif [[ -n $PICKER_TICK_FD ]]; then + # One wall-clock second, same beat as the timed read this replaces. + # Input arriving mid-wait loops straight back to the take above. + __sk_raw_status=0 + picker_wait_input 1000000 || __sk_raw_status=$? + if (( __sk_raw_status == 0 )); then + continue + fi + else + # No fifo could be opened, so this is the old timed read -- and with it + # the timer race everything above exists to remove. + # The status MUST be captured in the else. After a bare `if` whose + # condition failed, $? is the status of the `if` itself -- zero -- and + # every one-second poll would read as closed input and walk out of the + # prompt on its own. The console learned this the same way. + # shellcheck disable=SC2229 + if IFS= read -r -t 1 "$__sk_raw_var"; then # tick-fallback + if picker_paste_in_line "${!__sk_raw_var}"; then + picker_paste_absorb "${!__sk_raw_var}" + # shellcheck disable=SC2229 + printf -v "$__sk_raw_var" '%s' "$PICKER_PASTE_TEXT" + fi + return 0 + else + __sk_raw_status=$? + fi + fi + if (( PICKER_INTERRUPTED )); then + PICKER_INTERRUPTED=0 + echo + return 1 + fi + # Over 128 is the timeout; anything else is input that has closed. + (( __sk_raw_status > 128 )) || return 2 + # A home menu with automatic repaint disabled still has this canonical + # one-second beat. Keep upgrade detection alive here without putting it in + # picker_modal_read, where a reload would interrupt an open prompt. + if [[ $__sk_raw_surface == home ]] && ! picker_self_upgrade; then + printf '\n%s\n%s' "$LIVE_WARNING" "$__sk_raw_prompt" + LIVE_WARNING= + fi + done +} + +# An escape sequence is ONE keystroke, not a handful of characters to type +# into a name. Arrows, Home, Delete and Page Up all arrive as ESC [ … final +# byte, and every prompt in this picker takes a number, a key, a word or a +# path -- none of which can contain one. Read the whole sequence and drop it. +# One byte of an escape sequence, waited for off-terminal and taken untimed. +# The 50ms budget is the old one: past it the sequence is over, or was never a +# sequence at all. +picker_escape_next() { + local __sk_esc_var=$1 + if [[ -z $PICKER_TICK_FD ]]; then + # shellcheck disable=SC2229 + IFS= read -r -s -n 1 -t 0.05 "$__sk_esc_var" 2>/dev/null # tick-fallback + return + fi + if ! picker_input_ready; then + picker_tick 0.05 + picker_input_ready || return 1 + fi + # shellcheck disable=SC2229 + IFS= read -r -s -n 1 "$__sk_esc_var" 2>/dev/null +} + +picker_swallow_escape() { + local extra="" code + PICKER_ESCAPE_TAIL="" + # A lone ESC is a key of its own; only [ and O introduce a sequence. + picker_escape_next extra || return 0 + [[ $extra == '[' || $extra == O ]] || return 0 + PICKER_ESCAPE_TAIL=$extra + while picker_escape_next extra; do + [[ -n $extra ]] || continue + PICKER_ESCAPE_TAIL+=$extra + # Parameter and intermediate bytes (32 through 63) continue the sequence; + # the first byte from @ to ~ ends it and is swallowed with it. + printf -v code '%d' "'$extra" 2>/dev/null || break + (( code >= 32 && code <= 63 )) || break + done +} + +# ESC[200~ is a paste beginning, not a key to drop. Answered wherever the +# picker reads characters, so no read anywhere treats pasted lines as input. +picker_escape_was_paste() { + [[ $PICKER_ESCAPE_TAIL == '[200~' ]] +} + +# No exit from this surface is allowed to be silent: "it just dropped me to a +# shell" has to be answerable from the screen as well as from the action log. +picker_eof_notice() { + echo + echo " Leaving the picker." +} + +# Home-menu read: interrupt = redraw (rc 1), end-of-input = the picker ends, +# saying so first. +picker_read() { + local __sk_read_status=0 + picker_read_raw "$1" "$2" home || __sk_read_status=$? + case "$__sk_read_status" in + 0) return 0 ;; + 1) return 1 ;; + esac + picker_eof_notice + picker_exit input_closed +} + +# Modal screens own the terminal until the person leaves them, and every way +# of leaving one is worth exactly one level. Ctrl-C cancels the prompt back to +# the menu that asked it; Ctrl-D ends the prompt the same way instead of +# killing a picker the person is three menus deep in. Both return non-zero, so +# the `|| return` guarding every call site is the live cancel path it has +# always looked like. +picker_modal_read() { + local __sk_modal_status=0 + while true; do + __sk_modal_status=0 + picker_read_raw "$1" "$2" || __sk_modal_status=$? + case "$__sk_modal_status" in + 0) + # These prompts read whole lines, so an arrow key arrives as the + # bytes it sends -- ^[[A inside a session name, a project path, or a + # confirmation word. The line is not an answer somebody typed; say so + # and ask again rather than acting on what is left of it. + if [[ ${!1} == *$'\033'* ]]; then + echo " Arrow keys do nothing here." + continue + fi + return 0 + ;; + 1) + echo " Nothing changed." + return 1 + ;; + esac + echo + echo " Back." + return 1 + done +} + +# A read-only screen whose only exit is Back. It used to accept any line and +# drop it, so a key typed there -- the number of a row that is on the screen, +# or a command meant for the list -- vanished without a word, and the person +# went back believing it had been taken. +picker_back_read() { + local __sk_back_prompt=${1:-" ↵ back ❯ "} __sk_back_answer + while true; do + picker_modal_read __sk_back_answer "$__sk_back_prompt" || return 0 + case "${__sk_back_answer,,}" in + ""|b|back|q|quit) return 0 ;; + *) echo " There is nothing to choose on this screen. ↵ goes back." ;; + esac + done +} + +# Filter-as-you-type, built on the search the picker already had. A `/` line +# that pauses mid-typing previews its own result; Enter on the same line runs +# the identical search it always did. Nothing else in the picker knows this +# happened: QUERY is the one filter, and it is restored the moment the line +# stops being a search. +PICKER_QUERY_BASE="" +PICKER_FILTER_ACTIVE=0 + +picker_filter_live_enabled() { + [[ ${SESSION_KIT_PICKER_FILTER_LIVE:-1} != 0 ]] +} + +# Show what the half-typed line would select. Returns 0 only when the visible +# list actually changed, so an unchanged view never costs a repaint. +picker_filter_preview() { + local buffer=$1 wanted previous=$QUERY + if [[ $buffer == /* ]]; then + wanted=${buffer#/} + else + wanted=$PICKER_QUERY_BASE + fi + [[ $wanted != "$QUERY" ]] || return 1 + QUERY=$wanted + PAGE=1 + if ! build_view; then + # A preview that cannot be built changes nothing at all: the previous + # list stays on screen and the typed line is left alone. + QUERY=$previous + build_view || true + return 1 + fi + PICKER_FILTER_ACTIVE=1 + return 0 +} + +# A previewed filter belongs to the line that was being typed. If that line +# turned out to be something else -- n, a number, a close -- the preview is +# undone before the command runs, so no action ever executes against a list +# the person only glanced at. +picker_filter_finish() { + local buffer=$1 + (( PICKER_FILTER_ACTIVE )) || return 0 + [[ $buffer != /* ]] || return 0 + PICKER_FILTER_ACTIVE=0 + [[ $QUERY != "$PICKER_QUERY_BASE" ]] || return 0 + QUERY=$PICKER_QUERY_BASE + PAGE=1 + build_view || true +} + +# Read and echo one character at a time. The picker owns the visible buffer, so +# a repaint can redraw the exact half-typed command instead of erasing its echo. +picker_read_live() { + local __sk_live_var=$1 __sk_live_prompt=$2 + local seconds buffer="" character="" read_status=0 collect_status + local filter_pending=0 read_timeout=1 elapsed_tenths=0 paste_seen=0 + local wait_micros=1000000 + PICKER_QUERY_BASE=$QUERY + PICKER_FILTER_ACTIVE=0 + # The page on the screen is the page this answer is about. A live repaint + # between here and Enter sets this again, and a mass close then refuses + # rather than acting on a set the person never read. + PAGE_RENDER_CHANGED=0 + seconds=$(picker_live_seconds) + if (( seconds == 0 )) || [[ ! -t 0 ]]; then + picker_read "$__sk_live_var" "$__sk_live_prompt" + return + fi + # Subscribing costs one probe and one collection, once per picker. The + # collection is the resync that closes the window between the snapshot on + # screen and the first event the stream can carry. + if picker_events_open; then + picker_collect_now + fi + PICKER_INTERRUPTED=0 + LIVE_FINGERPRINT=$(picker_view_fingerprint) + picker_paste_arm + printf '%s' "$__sk_live_prompt" + + # Start in canonical mode. Complete lines and Ctrl-D take the ordinary path, + # which preserves any later lines already queued for a submenu. Only after a + # genuine one-second partial-line timeout do we take ownership of characters + # so a repaint can reproduce them. + # The indirection is deliberate: the caller passes the DESTINATION variable + # name, which the character loop below fills with `printf -v`. + # The second itself is spent on ticks, and the take is untimed, for the + # reason at the tick fifo's definition: a timed read that is handed a line + # as its timer fires abandons it. + picker_tick_open || true + if [[ -n $PICKER_TICK_FD ]]; then + read_status=0 + picker_wait_input 1000000 || read_status=$? + if (( read_status == 0 )); then + # shellcheck disable=SC2229 + if IFS= read -r "$__sk_live_var"; then + # A paste whose first newline arrived inside the canonical second: the + # marker is on this line and the rest of the block is still queued. + # Take the terminal and read the whole block as data instead of + # returning a command the person never typed. + picker_paste_in_line "${!__sk_live_var}" || return 0 + paste_seen=1 + else + read_status=1 + fi + fi + else + # shellcheck disable=SC2229 + if IFS= read -r -t 1 "$__sk_live_var"; then # tick-fallback + picker_paste_in_line "${!__sk_live_var}" || return 0 + paste_seen=1 + else + read_status=$? + fi + fi + # The interrupt is answered BEFORE the status is classified. A read that a + # trapped signal ended returns OVER 128 -- the same range as an ordinary + # timeout -- so a flag examined only inside the `<= 128` branch is never + # examined at all, which is how Ctrl-C came to do nothing here while the + # same check in picker_read_raw worked. One condition, one order, both + # readers. + if (( paste_seen == 0 )); then + if (( PICKER_RESIZED )) && (( PICKER_INTERRUPTED == 0 )) && + (( read_status > 128 )); then + PICKER_RESIZED=0 + return 1 + fi + if (( PICKER_INTERRUPTED )); then + PICKER_INTERRUPTED=0 + picker_discard_pending_input + echo + return 1 + fi + if (( read_status <= 128 )); then + picker_eof_notice + picker_exit input_closed + fi + # This one-second timeout is the idle home-menu beat. The outer menu loop + # does not regain control while nobody types, so checking only there left + # an already-open picker pinned to its old release forever. A modal uses + # picker_modal_read instead and can never reach this check. + if ! picker_self_upgrade; then + picker_redraw_home "$__sk_live_prompt" "" + fi + fi + elapsed_tenths=10 + + PICKER_TTY_STATE=$(stty -g < /dev/tty 2>/dev/null || true) + if [[ -z $PICKER_TTY_STATE ]] || + ! stty -icanon -echo min 0 time 0 < /dev/tty 2>/dev/null; then + PICKER_TTY_STATE="" + if (( paste_seen )); then + # No character loop to hold the block in, so it becomes one literal line + # instead: its newlines still cannot run as separate commands. + picker_paste_absorb "${!__sk_live_var}" + # shellcheck disable=SC2229 + printf -v "$__sk_live_var" '%s' "$PICKER_PASTE_TEXT" + return 0 + fi + picker_read "$__sk_live_var" "$__sk_live_prompt" + return + fi + if (( paste_seen )); then + # The whole block becomes the line at the prompt, where it can be read, + # edited, or used as filter text. A paste is never acted on by itself. + picker_paste_absorb "${!__sk_live_var}" + buffer=$PICKER_PASTE_TEXT + # shellcheck disable=SC2229 + printf -v "$__sk_live_var" '%s' "" + picker_redraw_home "$__sk_live_prompt" "$buffer" + fi + # Characters typed during the canonical second are already visible. Drain + # them into our buffer without echoing them a second time. Ready-check + # before every take, one 10ms grace tick for a burst still in flight, and + # the takes untimed: the drain must not carry the timer race the reads + # above just gave up. + while true; do + character="" + if ! picker_input_ready; then + [[ -z $PICKER_TICK_FD ]] || picker_tick 0.01 + picker_input_ready || break + fi + if ! IFS= read -r -s -n 1 character; then + break + fi + if [[ -z $character || $character == $'\r' ]]; then + printf -v "$__sk_live_var" '%s' "$buffer" + echo + picker_input_restore + picker_filter_finish "$buffer" + return 0 + fi + if [[ $character == $'\033' ]]; then + picker_swallow_escape + if picker_escape_was_paste; then + picker_paste_drain + picker_paste_clean "$PICKER_PASTE_RAW" + buffer+=$PICKER_PASTE_TEXT + picker_redraw_home "$__sk_live_prompt" "$buffer" + fi + continue + fi + buffer+=$character + done + if picker_filter_live_enabled && [[ -n $buffer ]]; then + filter_pending=1 + fi + + while true; do + character="" + # A line being typed as a search is answered while it is typed: short + # waits until the person pauses, then one preview. Everything else keeps + # the original one-second cadence, so the live refresh below is unchanged. + read_timeout=1 + (( filter_pending == 0 )) || read_timeout=0.25 + # The same wait/take separation as the canonical read above: the wait is + # off-terminal, the take is untimed and runs only against queued input. + if [[ -n $PICKER_TICK_FD ]]; then + wait_micros=1000000 + [[ $read_timeout == 1 ]] || wait_micros=250000 + read_status=0 + picker_wait_input "$wait_micros" || read_status=$? + if (( read_status == 0 )) && ! IFS= read -r -s -n 1 character; then + read_status=1 + fi + else + # shellcheck disable=SC2229 + if IFS= read -r -s -n 1 -t "$read_timeout" character; then # tick-fallback + read_status=0 + else + read_status=$? + fi + fi + if (( read_status == 0 )); then + # `read -n 1` returns an empty value for the newline delimiter. + if [[ -z $character || $character == $'\r' ]]; then + printf -v "$__sk_live_var" '%s' "$buffer" + echo + picker_input_restore + picker_filter_finish "$buffer" + return 0 + fi + case "$character" in + $'\177'|$'\b') + if [[ -n $buffer ]]; then + buffer=${buffer%?} + printf '\b \b' + picker_filter_live_enabled && filter_pending=1 + fi + ;; + $'\033') + # Swallowed whole, and quietly: a key that does nothing to a line + # being typed is not an exit, and a notice printed into a half-typed + # command would corrupt the line it is complaining about. A paste + # beginning is the one sequence that is not a key: its whole block + # joins the line being typed as literal text. + picker_swallow_escape + if picker_escape_was_paste; then + picker_paste_drain + picker_paste_clean "$PICKER_PASTE_RAW" + buffer+=$PICKER_PASTE_TEXT + picker_filter_live_enabled && filter_pending=1 + picker_redraw_home "$__sk_live_prompt" "$buffer" + fi + ;; + $'\004') + if [[ -z $buffer ]]; then + picker_input_restore + picker_eof_notice + picker_exit input_closed + fi + printf -v "$__sk_live_var" '%s' "$buffer" + echo + picker_input_restore + picker_filter_finish "$buffer" + return 0 + ;; + *) + buffer+=$character + printf '%s' "$character" + picker_filter_live_enabled && filter_pending=1 + ;; + esac + continue + fi + # Same order as the read above and as picker_read_raw: the interrupt is + # answered first, and the line it interrupted is gone -- what is on screen + # after a Ctrl-C is a fresh prompt, not the command the person abandoned. + if (( PICKER_INTERRUPTED )); then + PICKER_INTERRUPTED=0 + buffer="" + printf -v "$__sk_live_var" '%s' "" + picker_discard_pending_input + echo + picker_input_restore + picker_filter_finish "" + return 1 + fi + if (( read_status <= 128 )); then + picker_input_restore + picker_eof_notice + picker_exit input_closed + fi + # In character mode this is the same idle beat, including the pause after + # a partially typed home-menu command. Restore/redraw only when a broken + # release produced a new warning; a successful upgrade never returns. + if ! picker_self_upgrade; then + picker_redraw_home "$__sk_live_prompt" "$buffer" + fi + # The person stopped typing. If the line is a search, show what it + # selects before they commit to it; the same frame batching the live + # refresh uses keeps the redraw from flickering the half-typed line. + if (( filter_pending )); then + filter_pending=0 + if picker_filter_preview "$buffer"; then + picker_redraw_home "$__sk_live_prompt" "$buffer" + fi + fi + # The window changed shape. Nothing about the estate changed, so no + # fingerprint can see it: the repaint is the answer to the signal itself. + if (( PICKER_RESIZED )); then + PICKER_RESIZED=0 + picker_redraw_home "$__sk_live_prompt" "$buffer" + fi + if picker_live_ready; then + picker_live_collect + collect_status=$? + if (( collect_status == 0 || collect_status == 2 )); then + picker_redraw_home "$__sk_live_prompt" "$buffer" + fi + fi + # The daemon said the session table moved. Collect now and restart the + # clock, so a change the operator caused is on screen in the time one + # collection takes rather than at the end of the next poll window. + if picker_events_pending; then + elapsed_tenths=0 + picker_collect_now + fi + # Something asked for a collection while the last one was in flight. Now + # that it has been adopted, answer it. + if (( PICKER_EVENTS_TRAILING )) && [[ -z $LIVE_PID ]]; then + PICKER_EVENTS_TRAILING=0 + elapsed_tenths=0 + picker_live_start || true + fi + # Counted in tenths of a second because the wait is no longer always one + # second: a short filter wait must not make the live refresh five times + # more frequent than the operator asked for. + if [[ $read_timeout == 1 ]]; then + elapsed_tenths=$(( elapsed_tenths + 10 )) + else + elapsed_tenths=$(( elapsed_tenths + 3 )) + fi + picker_live_interval "$seconds" + if (( elapsed_tenths >= PICKER_LIVE_INTERVAL * 10 )); then + elapsed_tenths=0 + picker_live_start || true + fi + done +} + +new_temp() { + local label=$1 path + path=$(mktemp "$SK_STATE_DIR/${label}.json.XXXXXX") || return 1 + chmod 600 "$path" || { + command rm -- "$path" + return 1 + } + TEMP_FILES+=("$path") + NEW_TEMP=$path +} + +# Compute-first home frame: the whole screen is rendered into the capture +# file (in this shell, so renderer state survives), then reaches the +# terminal as one write via picker_frame_emit_file, the screen is never +# erased ahead of content, so a slow render cannot flash a blank frame. +# Falls back to the erase-first path only when the capture file is missing. +picker_redraw_home() { + local prompt=$1 buffer=$2 cols= rows= + if [[ -n ${PICKER_FRAME_FILE:-} && -f ${PICKER_FRAME_FILE:-} ]]; then + # With stdout captured to a file, size probes that ioctl stdout return + # their fallback and rows lay out for the wrong terminal. tput still + # reads the real size from stderr, so pin it for the capture, freshly + # each frame, which is also what keeps a resize honest. + # Kernel winsize first (0 means never sized), tput second, exported + # size last, same chain and reasons as the picker's own frame capture. + __sk_size=$(command stty size <&2 2>/dev/null) || __sk_size= + cols=${__sk_size##* }; rows=${__sk_size%% *} + [[ $cols =~ ^[0-9]+$ && $cols -gt 0 ]] || cols= + [[ $rows =~ ^[0-9]+$ && $rows -gt 0 ]] || rows= + if [[ -z $cols ]]; then + cols=$(tput cols 2>/dev/null) && [[ $cols =~ ^[0-9]+$ ]] || cols= + fi + if [[ -z $rows ]]; then + rows=$(tput lines 2>/dev/null) && [[ $rows =~ ^[0-9]+$ ]] || rows= + fi + { COLUMNS=${cols:-${COLUMNS:-}} LINES=${rows:-${LINES:-}} render_main + printf '%s%s' "$prompt" "$buffer"; } > "$PICKER_FRAME_FILE" + picker_frame_emit_file "$PICKER_FRAME_FILE" home + else + printf '\033[?2026h\033[H\033[J' + render_main + printf '%s%s' "$prompt" "$buffer" + printf '\033[?2026l' + fi +} + +# Collect a fresh snapshot. With `keep`, the search, the page and the jump +# marker survive it: an action is not a request to change what the person was +# looking at, and returning them to page 1 of the unfiltered list mid-task is +# the same defect the background collector already refuses to commit. Without +# it -- boot, and the `r` key, which says it clears the filter -- the view +# controls reset. +refresh_snapshot() { + local mode=${1:-reset} fresh + new_temp login-snapshot || return 1 + fresh=$NEW_TEMP + # Bounded: a jammed daemon must degrade to "previous view" messaging, not + # hang the login window before any menu appears. kill-after covers a child + # that ignores the TERM. + if ! sk_timeout "${SK_REFRESH_TIMEOUT:-30}" "$STATUS_CMD" --json > "$fresh"; then + command rm -- "$fresh" + return 1 + fi + SNAPSHOT=$fresh + if [[ $mode != keep ]]; then + QUERY="" + PAGE=1 + # A jump marker points at a row in the list it was computed from. The next + # list is a different list, so the marker goes with the old one. + PICKER_JUMP_NUMBER="" + refresh_pending_cache + build_view + return + fi + refresh_pending_cache + # A filter that no longer builds is not worth losing the list over. + build_view || { + QUERY="" + PAGE=1 + PICKER_JUMP_NUMBER="" + build_view || return 1 + } + clamp_page +} + +refresh_pending_cache() { + PENDING_CACHE=$(recovery_count 2>/dev/null || printf '0') + [[ $PENDING_CACHE =~ ^[0-9]+$ ]] || PENDING_CACHE=0 +} + +# The same number, taken from what the background collector wrote. A file that +# is missing or is not a number leaves the previous count alone: a count the +# operator can act on must not blink to zero because one collection missed it. +picker_adopt_pending() { + local path=$1 value="" + [[ -s $path ]] || return 0 + IFS= read -r value < "$path" 2>/dev/null || return 0 + [[ $value =~ ^[0-9]+$ ]] || return 0 + PENDING_CACHE=$value +} + +# End the background collector before the picker goes away. `exec` keeps the +# process ID, so an in-flight collector would otherwise carry on writing a +# state snapshot into a file cleanup had just unlinked, and would then be +# inherited unreaped by the picker that replaced its parent. +picker_live_stop() { + [[ -n $LIVE_PID ]] || return 0 + local child grandchild + # The collector is a subshell whose own child is the timed status run, and + # THAT run has its own process group and its own session (sk_timeout starts + # one, so a terminal hangup never reaches it). Killing the direct child alone + # left the real work running, detached, until it finished on its own. + # sk_timeout forwards a TERM to its group; reaching the grandchildren here + # covers a wrapper that is gone or wedged. + for child in $(pgrep -P "$LIVE_PID" 2>/dev/null || true); do + for grandchild in $(pgrep -P "$child" 2>/dev/null || true); do + kill -- "-$grandchild" 2>/dev/null || kill "$grandchild" 2>/dev/null || true + done + kill "$child" 2>/dev/null || true + done + kill "$LIVE_PID" 2>/dev/null || true + wait "$LIVE_PID" 2>/dev/null || true + LIVE_PID= + [[ -z $LIVE_DONE ]] || command rm -f -- "$LIVE_DONE" + LIVE_DONE= + [[ -z $LIVE_FILE ]] || command rm -f -- "$LIVE_FILE" + LIVE_FILE= + [[ -z $LIVE_PENDING ]] || command rm -f -- "$LIVE_PENDING" + LIVE_PENDING= +} + +# A menu window can sit open across release rollovers, silently running old +# code against new state, every fix ships and the standing windows never see +# it. Replace this picker with the current release at the next safe point. +# +# What the person was looking at travels with it: the grouping, the compact +# setting, the filter and the page. An upgrade they never asked for must not +# also throw away the list they had set up. +picker_self_upgrade() { + [[ -n ${SESSION_KIT_RELEASE_DIR:-} ]] || return 0 + # Resolve both sides physically. The stable login launcher does this same + # current -> immutable release selection for a fresh window; here the old + # picker already knows its own immutable release from session_kit_common. + local root=${SESSION_KIT_ROOT:-$HOME/.local/lib/session-kit} + local current_link current running launcher release_name release_id warning_key + current_link=$root/current + running=$(cd -P -- "$SESSION_KIT_RELEASE_DIR" 2>/dev/null && pwd) || return 0 + if ! current=$(cd -P -- "$current_link" 2>/dev/null && pwd); then + # Source checkouts and isolated fixtures have no activation link at all; + # there is no attempted upgrade to diagnose in that case. + [[ -L $current_link ]] || return 0 + # A dangling activation link cannot name an executable release. Say so + # once, but do not disturb the picker that is still serving this window. + warning_key="unresolved:$(readlink -- "$current_link" 2>/dev/null || printf missing)" + if [[ ${PICKER_UPGRADE_WARNING_KEY:-} != "$warning_key" ]]; then + PICKER_UPGRADE_WARNING_KEY=$warning_key + LIVE_WARNING="The upgraded release is unavailable. Continuing with this picker." + return 1 + fi + return 0 + fi + if [[ $current == "$running" ]]; then + PICKER_UPGRADE_WARNING_KEY= + return 0 + fi + + release_name=${current##*/} + release_id=${release_name:0:12} + launcher=$current/bin/shpool_login_launcher + warning_key="broken:$current" + # A target that already failed a handoff in this window is broken until + # `current` changes again. The marker survives the recovery exec, so the + # relaunched picker warns once instead of tearing itself down every idle + # beat against the same corpse, the retry storm two review lanes built. + if [[ ${SESSION_KIT_PICKER_UPGRADE_FAILED_TARGET:-} == "$current" ]]; then + if [[ ${PICKER_UPGRADE_WARNING_KEY:-} != "$warning_key" ]]; then + PICKER_UPGRADE_WARNING_KEY=$warning_key + LIVE_WARNING="Release $release_id could not serve this window. Continuing with this picker." + return 1 + fi + return 0 + fi + # This is the release-owned launcher selected by the stable login front + # door. Validate what is cheaply knowable before stopping children or + # removing any old temp file, but no static probe can prove a program will + # SERVE (an env-shebang launcher execs fine and exits 127 a beat later), so + # the real guarantee is the supervised handoff below, not this check. + if [[ ! -f $launcher || -L $launcher || ! -x $launcher ]]; then + if [[ ${PICKER_UPGRADE_WARNING_KEY:-} != "$warning_key" ]]; then + PICKER_UPGRADE_WARNING_KEY=$warning_key + LIVE_WARNING="Release $release_id is unavailable. Continuing with this picker." + return 1 + fi + return 0 + fi + + sk_log_action picker_exit release_upgrade || true + export SESSION_KIT_PICKER_GROUP=$PICKER_GROUP_MODE + export SESSION_KIT_PICKER_COMPACT=$PICKER_COMPACT + export SESSION_KIT_PICKER_MACHINE_EXPANDED=$PICKER_MACHINE_EXPANDED + export SESSION_KIT_PICKER_RESUME_QUERY=$QUERY + export SESSION_KIT_PICKER_RESUME_PAGE=$PAGE + cleanup + printf 'Reloading the picker into release %s.\n' "$release_id" + # Supervised handoff: the new picker runs as this shell's foreground child + # and owns the terminal. If it serves, this shell is a dormant wrapper that + # propagates the eventual exit. If it dies within the probation window + # (exec failure, missing env command, startup crash), the window has NOT + # been upgraded, this shell says so, marks the target failed, and relaunches + # the release it was serving seconds ago. Announcing success is the one + # thing that never happens before the child has outlived probation. + local handoff_started handoff_ended handoff_status handoff_elapsed_ms + local probation probation_ms + probation=${SESSION_KIT_PICKER_HANDOFF_PROBATION_SECONDS:-5} + # Bounded digits, forced base ten: bare [0-9]+ accepted "08", which bash + # arithmetic reads as broken octal and ABORTS on, and a 16-digit value + # multiplied by 1000 wrapped the probation negative, both demonstrated by + # review round five, both ending the picker. Six digits is 11.5 days. + # Zero is not a probation: "0" (and "000000") disabled the guard entirely, + # so an instant status-0 corpse was announced as served and the picker + # ended with no recovery (review lane rv-rn6-1). The whole point of the + # window is catching corpses; a value that cannot catch one is invalid. + if [[ $probation =~ ^[0-9]{1,6}$ ]] && (( 10#$probation > 0 )); then + probation=$(( 10#$probation )) + else + probation=5 + fi + probation_ms=$(( probation * 1000 )) + # EPOCHREALTIME, not SECONDS, when it exists: integer seconds rounded a + # 4.2-second corpse up to the probation and accepted it as served (review + # lane rv-rn4-2). Milliseconds leave no boundary to round across. The + # separator is the locale's, a decimal comma aborted the arithmetic in + # round five, so strip every non-digit rather than assume the point. + # The expansion is guarded: EPOCHREALTIME is a Bash 5.0 variable, this + # script promises Bash 4, and under nounset a bare ${EPOCHREALTIME//...} + # ABORTS the shell after the reload line with no recovery (review lane + # rv-rn6-2). On a shell without it, the builtin printf's %(%s)T strftime + # clock (Bash 4.2) stands in, a builtin reading the system clock, never a + # shell variable. SECONDS is deliberately NOT the fallback: it is mutable + # shell state, and a hostile BASH_ENV turned it into a line counter + # (declare -n SECONDS=LINENO) that credited an instant corpse with fifteen + # seconds it never lived (review lanes rv-rn7-1/2). The whole-second + # reading is discounted by one full second below so rounding can never + # credit a corpse with time it did not live. A shell with neither clock + # reads zero elapsed and takes the recovery path, the direction that + # never announces a served picker falsely. + local handoff_clock=millisecond + handoff_started=${EPOCHREALTIME-} + handoff_started=${handoff_started//[!0-9]/} + if [[ ! $handoff_started =~ ^[0-9]{1,18}$ ]]; then + handoff_clock=second + handoff_started='' + builtin printf -v handoff_started '%(%s)T' -1 2>/dev/null || handoff_started='' + fi + "$launcher" + handoff_status=$? + if [[ $handoff_clock == millisecond ]]; then + handoff_ended=${EPOCHREALTIME-} + handoff_ended=${handoff_ended//[!0-9]/} + if [[ $handoff_ended =~ ^[0-9]{1,18}$ ]]; then + handoff_elapsed_ms=$(( (10#$handoff_ended - 10#$handoff_started) / 1000 )) + else + # A clock that cannot be read is not a served picker: zero elapsed + # takes the recovery path, which can only ever cost one relaunch, the + # failure direction that never accepts a corpse. + handoff_elapsed_ms=0 + fi + else + handoff_ended='' + builtin printf -v handoff_ended '%(%s)T' -1 2>/dev/null || handoff_ended='' + if [[ $handoff_started =~ ^[0-9]{1,18}$ && $handoff_ended =~ ^[0-9]{1,18}$ ]] \ + && (( 10#$handoff_ended > 10#$handoff_started )); then + # One whole second is surrendered before comparing: a corpse dying at + # eighty percent of the probation must not be rounded across the line + # it never reached (rv-rn4-2's credit, kept out of the fallback too). + handoff_elapsed_ms=$(( (10#$handoff_ended - 10#$handoff_started - 1) * 1000 )) + else + handoff_elapsed_ms=0 + fi + fi + # Two verdicts mean the child never served this window, whatever its exit + # status says. An instant zero exit is a corpse wearing a success code, a + # person cannot open and leave a picker faster than it draws (review lane + # rv-rn3-2's zero-exit corpse). And death by signal is never a served + # picker's own ending: a real picker handles INT by redrawing, so 128+N + # means a hang someone had to kill (the same lane's non-serving hang). + if (( handoff_elapsed_ms >= probation_ms && handoff_status < 128 )); then + # It served. Its ending, a person leaving, or its own exit, passes + # through untouched. + exit "$handoff_status" + fi + if (( handoff_status == 0 )); then + printf 'The release %s picker ended before it could serve. Reloading this one.\n' \ + "$release_id" + else + printf 'The release %s picker exited immediately (status %s). Reloading this one.\n' \ + "$release_id" "$handoff_status" + fi + sk_log_action picker_exit release_upgrade_failed || true + export SESSION_KIT_PICKER_UPGRADE_FAILED_TARGET=$current + shopt -s execfail + exec "$SESSION_KIT_RELEASE_DIR/bin/shpool_login_launcher" + printf 'No picker could start in this window. Run shpool_login by hand.\n' + exit 1 +} diff --git a/lib/sh/shpool_login_projects.sh b/lib/sh/shpool_login_projects.sh new file mode 100644 index 0000000..5de2a66 --- /dev/null +++ b/lib/sh/shpool_login_projects.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +# Picker projects menu: list, add, review discovered directories, and drop. +# Source this file; do not execute it. +# +# Source order: bin/shpool_login sources this module ahead of its first trap. +# These functions read PROJECTS_TOOL and SK_PROJECTS_FILE, resolved in that +# file and in bin/session_kit_common respectively, and call picker_read() and +# picker_clear_screen() from the live and theme modules. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +# ---- projects ---------------------------------------------------------- +# Adding a project used to mean knowing an alias, a provider keyword, and an +# absolute path, none of which the picker ever offered. It lives here rather +# than on the main screen because you add a project a few times a year. +projects_tool() { + [[ -f $PROJECTS_TOOL ]] || return 1 + python3 "$PROJECTS_TOOL" "$@" +} + +show_projects_menu() { + local snapshot answer + while true; do + new_temp login-projects-state || return 0 + snapshot=$NEW_TEMP + if ! projects_tool --json candidates > "$snapshot" 2>/dev/null; then + echo " The project list is unavailable." + return 0 + fi + echo + python3 - "$snapshot" <<'PY' +import json +import sys +import unicodedata + +def cells(text): + return sum( + 0 if unicodedata.combining(character) + else 2 if unicodedata.east_asian_width(character) in {"W", "F"} + else 1 + for character in text + ) + +def pad(text, width): + return text + " " * max(0, width - cells(text)) + +with open(sys.argv[1], encoding="utf-8") as handle: + data = json.load(handle) +configured = [row for row in data.get("configured", []) if row.get("provider") != "ignore"] +candidates = data.get("candidates", []) +ignored = data.get("ignored", []) +print(" Projects") +if configured: + alias_width = max(cells(row["alias"]) for row in configured) + for number, row in enumerate(configured, 1): + print(f" {number:2} {pad(row['alias'] + ':', alias_width + 1)} {row['cwd']}") +else: + print(" None yet.") +if candidates: + print( + f" {len(candidates)} directory(s) your providers use " + "are not on this list." + ) +if ignored: + print(f" {len(ignored)} hidden from it, and can be restored.") +PY + echo + # "Drop" read like removing a line from a list. It hides the directory for + # good, so the footer says that, and the key that takes it back is offered + # on the same line whenever there is something to take back. + local footer=" add a" has_candidates=0 has_ignored=0 + if python3 -c 'import json,sys; sys.exit(0 if json.load(open(sys.argv[1]))["candidates"] else 1)' \ + "$snapshot" 2>/dev/null; then + has_candidates=1 + footer+=" · review the rest i" + fi + footer+=" · hide d number" + if python3 -c 'import json,sys; sys.exit(0 if json.load(open(sys.argv[1]))["ignored"] else 1)' \ + "$snapshot" 2>/dev/null; then + has_ignored=1 + footer+=" · restore u" + fi + echo "$footer · ↵ back · b back" + echo + picker_modal_read answer " projects ❯ " || return 0 + case "$answer" in + ""|b|B|back|q|Q) return 0 ;; + a|A) projects_add ;; + i|I) + if (( has_candidates )); then + projects_review "$snapshot" + else + echo " Projects not on your list: none." + fi + ;; + u|U) + if (( has_ignored )); then + projects_hidden "$snapshot" + else + echo " Hidden projects: none." + fi + ;; + d|D) echo " That needs a project number, such as d 2. Numbers shown here work." ;; + d\ *|D\ *) projects_drop "${answer#* }" "$snapshot" ;; + d[0-9]*|D[0-9]*) projects_drop "${answer#[dD]}" "$snapshot" ;; + *) picker_unknown_choice "a, i, u, d number, b, and Enter" ;; + esac + done +} + +projects_add() { + local directory alias suggestion + echo + echo " Add a project" + echo " Claude, Codex, or a shell is chosen when you start a session in it." + picker_modal_read directory " Directory (Enter for $PWD) ❯ " || return 0 + [[ -n $directory ]] || directory=$PWD + if [[ $directory != /* ]]; then + echo " That is not an absolute path. Nothing changed." + return 0 + fi + if [[ ! -d $directory ]]; then + echo " That directory does not exist. Nothing changed." + return 0 + fi + suggestion=$(projects_tool --json suggest "$directory" 2>/dev/null | + python3 -c 'import json,sys; print(json.load(sys.stdin)["alias"])' 2>/dev/null) || + suggestion="" + if [[ -n $suggestion ]]; then + picker_modal_read alias " Short name [$suggestion] ❯ " || return 0 + [[ -n $alias ]] || alias=$suggestion + else + picker_modal_read alias " Short name ❯ " || return 0 + [[ -n $alias ]] || { echo " A project needs a short name. Nothing changed."; return 0; } + fi + # There is no third question here any more. A project is a directory; which + # provider opens it is answered when a session starts, and the new-session + # flow asks that first. Answering it here as well is what forced a directory + # worked on with both providers onto two rows under two short names. + if projects_tool add "$alias" "$directory"; then + sk_log_action projects_add added || true + fi +} + +projects_review() { + local snapshot=$1 answer chosen + echo + python3 - "$snapshot" <<'PY' +import json +import sys +import unicodedata + +def cells(text): + return sum( + 0 if unicodedata.combining(character) + else 2 if unicodedata.east_asian_width(character) in {"W", "F"} + else 1 + for character in text + ) + +def pad(text, width): + return text + " " * max(0, width - cells(text)) + +with open(sys.argv[1], encoding="utf-8") as handle: + candidates = json.load(handle).get("candidates", []) +print(" Not on your list") +width = max((cells(row["cwd"]) for row in candidates), default=1) +for number, row in enumerate(candidates, 1): + print( + f" {number:2} [{row['provider'].title():6}] " + f"{pad(row['cwd'], width)} → {row['alias']}" + ) +PY + echo + echo " add numbers · add all a · never list x · ↵ back · b back" + echo + picker_modal_read answer " review ❯ " || return 0 + [[ -n $answer ]] || return 0 + case "${answer,,}" in + b|back) return 0 ;; + esac + if [[ ${answer,,} != x ]]; then + projects_tool import --select "$answer" || true + return 0 + fi + # x means all of them, permanently. One keystroke used to be enough. + chosen=$(projects_tool candidates --select a 2>/dev/null) || { + echo " The project list is unavailable. Nothing changed." + return 0 + } + local -a directories=() + mapfile -t directories <<<"$chosen" + local directory count=0 offered=0 + for directory in "${directories[@]}"; do + [[ -n $directory ]] || continue + offered=$(( offered + 1 )) + done + (( offered > 0 )) || { + echo " There is nothing left to hide. Nothing changed." + return 0 + } + for directory in "${directories[@]}"; do + [[ -n $directory ]] || continue + projects_tool ignore "$directory" >/dev/null || continue + count=$(( count + 1 )) + done + printf ' Hid %s project%s. Restore them from Projects.\n' \ + "$count" "$([[ $count == 1 ]] || printf s)" +} + +projects_drop() { + local number=$1 snapshot=$2 directory + [[ $number =~ ^[0-9]+$ ]] || { + echo " That needs a project number, such as d 2. Numbers shown here work." + return 0 + } + directory=$(python3 - "$snapshot" "$number" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + data = json.load(handle) +rows = [row for row in data.get("configured", []) if row.get("provider") != "ignore"] +index = int(sys.argv[2]) +if index < 1 or index > len(rows): + raise SystemExit(2) +print(rows[index - 1]["cwd"]) +PY +) || { + echo " There is no such project on this screen. Numbers shown here work." + return 0 + } + # This never was the "drop a line" it sounded like: the directory leaves the + # list until it is restored from this same menu, which the line below says. + projects_tool ignore "$directory" || true + printf ' Hid %s. Restore it from Projects.\n' "$directory" +} + +# An ignore is a decision, not a deletion. This is where it is taken back: +# the directory becomes discoverable again and can be added like any other. +projects_hidden() { + local snapshot=$1 answer directory + echo + python3 - "$snapshot" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + ignored = json.load(handle).get("ignored", []) +print(" Hidden directories") +for number, cwd in enumerate(ignored, 1): + print(f" {number:2} {cwd}") +PY + echo + echo " restore number · ↵ back · b back" + echo + picker_modal_read answer " restore ❯ " || return 0 + [[ -n $answer ]] || return 0 + case "${answer,,}" in + b|back) return 0 ;; + esac + [[ $answer =~ ^[0-9]+$ ]] || { + echo " There is no such project on this screen. Numbers shown here work." + return 0 + } + directory=$(python3 - "$snapshot" "$answer" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + ignored = json.load(handle).get("ignored", []) +index = int(sys.argv[2]) +if index < 1 or index > len(ignored): + raise SystemExit(2) +print(ignored[index - 1]) +PY +) || { + echo " There is no such project on this screen. Numbers shown here work." + return 0 + } + if projects_tool unignore "$directory"; then + sk_log_action projects_restore restored || true + fi +} diff --git a/lib/sh/shpool_login_recovery.sh b/lib/sh/shpool_login_recovery.sh new file mode 100644 index 0000000..5f4072b --- /dev/null +++ b/lib/sh/shpool_login_recovery.sh @@ -0,0 +1,419 @@ +#!/usr/bin/env bash +# Picker recovery review: pending entries left by an interrupted session and +# the live target a recovered conversation belongs to. Source this file; do not +# execute it. +# +# Source order: bin/shpool_login sources this module ahead of its first trap. +# These functions read RECOVERY, SNAPSHOT, and SP_CMD, assigned in that file +# before the boot sequence runs, and call into the theme, live, and view +# modules. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +recovery_list() { + local fresh + new_temp login-recovery || return 1 + fresh=$NEW_TEMP + "$STATUS_CMD" --recovery-pending-list > "$fresh" || { + command rm -- "$fresh" + return 1 + } + # This screen draws and acts out of this one file, so a word cannot change + # rows underneath it. `sp restore` has no such file -- it rebuilds the list + # to act on it -- so what this screen is about to print is written down for + # it to check a typed word against. A failure here loses that check and + # nothing else, and it says so rather than passing for a screen that was + # recorded. + RECOVERY_UNRECORDED="" + "$STATUS_CMD" --recovery-remember-printed "$fresh" >/dev/null || RECOVERY_UNRECORDED=1 + RECOVERY=$fresh +} + +recovery_count() { + local payload + payload=$("$STATUS_CMD" --recovery-pending-list) || return 1 + python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("entries",[])))' \ + <<<"$payload" +} + +active_target_for_uuid() { + local provider=$1 uuid=$2 + python3 - "$SNAPSHOT" "$provider" "$uuid" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + data = json.load(handle) +wanted_provider = sys.argv[2] +wanted = sys.argv[3].casefold() +managed = [] +outside = [] +for row in data.get("sessions", []): + identity = row.get("identity") or {} + if ( + row.get("provider") == wanted_provider + and str(identity.get("uuid") or "").casefold() == wanted + and identity.get("confidence") == "exact" + ): + number = row.get("terminal_number") + if ( + isinstance(number, int) + and not isinstance(number, bool) + and number > 0 + ): + managed.append(number) +for row in data.get("outside_agents", []): + identity = row.get("identity") or {} + if ( + row.get("provider") == wanted_provider + and str(identity.get("uuid") or "").casefold() == wanted + and identity.get("confidence") == "exact" + ): + outside.append(row) +if len(managed) == 1 and not outside: + print(f"managed:{managed[0]}") +elif not managed and len(outside) == 1: + print("outside") +elif managed or outside: + print("ambiguous") +else: + print("none") +PY +} + +review_recovery() { + # Every early exit returns 0: each path below already explains itself, and + # a nonzero status made the More menu stack "Nothing to review." on top. + require_live_actions || return 0 + recovery_list || { + echo " Closed sessions are unavailable. Nothing changed." + return 0 + } + local count + count=$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("entries",[])))' "$RECOVERY") + if (( count == 0 )); then + echo " Closed sessions: none." + return 0 + fi + echo + echo " Closed sessions" + python3 - "$RECOVERY" <<'PY' +import json +import sys +import time +import unicodedata + +def event_age(row): + """The age of the row's OWN event, in the words the other surface uses. + + This screen used to read three timestamp fields the one list does not + emit, and fall back to a login time parsed out of an internal id -- so + every row said "login time unknown" while `sp recover` printed a correct + age for the same record from the same file. + """ + value = row.get("when_unix_ms") + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + return "time unknown" + seconds = max(0, (int(time.time() * 1000) - value) // 1000) + if seconds < 60: + age = "under 1 min ago" + elif seconds < 3600: + age = f"{seconds // 60} min ago" + elif seconds < 86400: + age = f"{seconds // 3600} hr ago" + else: + days = seconds // 86400 + age = f"{days} day{'s' if days != 1 else ''} ago" + return f"{'closed' if row.get('source') == 'closed' else 'lost'} {age}" + +def clean(value): + return " ".join( + "".join( + " " if unicodedata.category(character).startswith("C") else character + for character in str(value or "") + ).split() + ) + +prepared=[] +for row in json.load(open(sys.argv[1])).get("entries",[]): + provider=str(row.get("provider") or "unknown").title() + # The session's own number, the one every other screen shows it under. A + # conversation whose number was retired has none to show, and borrowing a + # live session's would be worse than a dash -- it goes by the name beside + # it, or by the time of its own event when two rows answer to one name. + number=row.get("number") + selector=str(row.get("selector") or "") + if isinstance(number,int): + label=f"{number:>4}" + elif selector.startswith("@"): + label=f"{selector:>4}" + else: + label=" —" + title=clean(row.get("display_name")) or "unnamed" + conflicts=", ".join(clean(value) for value in row.get("conflict_fields",[])) + suffix=f" [review required: conflicting {conflicts}]" if conflicts else "" + reason=clean(row.get("history_only_reason")) + prepared.append((label, provider, title, event_age(row), suffix, reason)) +for label, provider, title, age, suffix, reason in prepared: + print(f" {label} [{provider}] {title} [{age}]{suffix}".rstrip()) + if reason: + # Say it plainly rather than offering a restore that cannot work. + print(f" {reason}") +PY + echo + if [[ -n ${RECOVERY_UNRECORDED:-} ]]; then + echo " This screen could not be written down, so sp restore cannot check a word you type here against it." + fi + echo " restore numbers · one with no number goes by the selector beside it · restore all a · ↵ back · b back" + echo + local answer selected + picker_modal_read answer " closed ❯ " || return 0 + # Back is back on every screen: without this, q read as a selection and came + # back as "invalid recovery selection". `b` was the same hole, one letter + # over -- it reached the selection parser and was refused as a bad number. + [[ -n $answer ]] || return 0 + case "${answer,,}" in + b|back|q|quit) return 0 ;; + esac + selected=$(python3 - "$RECOVERY" "$answer" "$SCRIPT_DIR/../lib" <<'PY' +import json,re,sys +sys.path.insert(0, sys.argv[3]) +from sessionkit_inventory.common import CollectionError, parse_number_selection +entries=json.load(open(sys.argv[1])).get("entries",[]) +clean=lambda value: " ".join(str(value or "").split()) +# What a person types is what the row shows, and the list decides that once +# for both screens: the session's own number, or -- for a conversation whose +# number was retired -- the selector beside it. Neither is a row position, so +# neither moves when the list is rebuilt. The handle passed on to the loop is +# the row's own identity; no screen prints one and nothing accepts one as +# input. +rows=[] +for row in entries: + number=row.get("number") + handle=str(number) if isinstance(number,int) else str(row.get("short_id") or "") + if not handle: + continue + rows.append({ + "handle": handle, + "number": str(number) if isinstance(number,int) else "", + "selector": clean(row.get("selector")).casefold(), + }) +if not rows: + raise SystemExit(2) +text=clean(sys.argv[2]) +folded=text.casefold() +if folded in {"a","all"}: + print("\n".join(row["handle"] for row in rows)) + raise SystemExit(0) +# One rule, and the other surface applies the same one: a selector answers for +# exactly the row it is printed beside, or for nothing. The WHOLE word is +# tested first, because splitting it up before deciding what kind of answer it +# was made a name of digits and spaces read as a list of numbers: a +# conversation somebody called "2 4" restored sessions 2 and 4 here and the +# conversation named "2 4" on the other screen -- one typed word, two screens, +# two different conversations. +matched=[row for row in rows if row["selector"] and row["selector"] == folded] +if len(matched) > 1: + raise SystemExit(3) +if not matched: + # A rejected selection changes nothing. Catching the parser's refusal and + # then adding back every fragment that happened to be a number meant + # "2,garbage" restored session 2 and "2,,4" restored both -- a typo acting + # as a command. + parts=[] + for chunk in text.split(","): + pieces=chunk.split() + if not pieces: + raise SystemExit(2) + parts.extend(pieces) + if not parts: + raise SystemExit(2) + if all(re.fullmatch(r"\d+(-\d+)?", part) for part in parts): + numbers=[int(row["number"]) for row in rows if row["number"]] + if not numbers: + raise SystemExit(2) + asked=set() + try: + for part in parts: + asked |= { + str(value) for value in parse_number_selection(part, max(numbers)) + } + except CollectionError: + raise SystemExit(2) + matched=[row for row in rows if row["number"] in asked] + # The module refuses to let two short ids stand for one another; a + # number gets the same standard rather than first-match-wins. + if len({row["number"] for row in matched}) != len(matched): + raise SystemExit(3) +if not matched: + raise SystemExit(2) +print("\n".join(row["handle"] for row in matched)) +PY +) || { + if (( $? == 3 )); then + echo " More than one closed session answers to that, so nothing was chosen." + else + echo " There is no such closed session on this screen. Type what the row shows: its number, or the selector beside a session that has none." + fi + return + } + + local handle label name number selector encoded generation old_id provider uuid cwd actionable conflicts reason launched attempt acknowledged active_target active_number + local -a values=() + # The selection rides on fd 3 so the loop body keeps the terminal on stdin: + # with the selection on stdin, choose_number's menu read consumed the NEXT + # selected recovery number as its action (or hit EOF and killed the picker + # mid-recovery), and sp's attach saw a here-string instead of a tty. + while IFS= read -r -u 3 handle; do + [[ $handle =~ ^[0-9]+$ || $handle =~ ^[0-9a-f]{8,32}$ ]] || continue + encoded=$(python3 - "$RECOVERY" "$handle" <<'PY' +import base64,json,sys +wanted=str(sys.argv[2]) +def token(item): + number=item.get("number") + return str(number) if isinstance(number, int) else str(item.get("short_id") or "") +matching=[ + item + for item in json.load(open(sys.argv[1])).get("entries",[]) + if token(item) == wanted +] +# One handle, one conversation. Two rows answering to it is a broken list, and +# acting on the first of them is how a person gets back a session they did not +# ask for. +if len(matching) != 1: + raise SystemExit(1) +row=matching[0] +for key in ("source_generation_key","old_shpool_id","provider","uuid","cwd"): + print(base64.b64encode(str(row.get(key) or "").encode()).decode()) +number=row.get("number") +print(base64.b64encode((str(number) if isinstance(number,int) else "").encode()).decode()) +print(base64.b64encode(" ".join(str(row.get("selector") or "").split()).encode()).decode()) +print(base64.b64encode(", ".join(map(str,row.get("conflict_fields",[]))).encode()).decode()) +print(base64.b64encode(str(row.get("history_only_reason") or "").encode()).decode()) +print(base64.b64encode(" ".join(str(row.get("display_name") or "unnamed").split()).encode()).decode()) +# Keep the nonempty restorable flag last. Bash command substitution strips +# trailing blank lines, so an empty reason field cannot safely be last. +print(base64.b64encode(str(bool(row.get("restorable"))).lower().encode()).decode()) +PY +) + mapfile -t values <<<"$encoded" + if (( ${#values[@]} != 11 )); then + # The handle is never shown: for a row with no number it is the + # conversation's own id, and no screen prints one of those. + echo " That closed session is no longer on this list. Nothing changed." >&2 + continue + fi + generation=$(decode64 "${values[0]}") + old_id=$(decode64 "${values[1]}") + provider=$(decode64 "${values[2]}") + uuid=$(decode64 "${values[3]}") + cwd=$(decode64 "${values[4]}") + number=$(decode64 "${values[5]}") + selector=$(decode64 "${values[6]}") + conflicts=$(decode64 "${values[7]}") + reason=$(decode64 "${values[8]}") + name=$(decode64 "${values[9]}") + actionable=$(decode64 "${values[10]}") + # A row is answered for by what the screen showed beside it -- its number, + # or the selector it goes by. Never by the handle: for a row with no + # number that is the conversation's own id, and a uuid that happens to + # start with digits would read as a session number. + if [[ -n $number ]]; then + label=$number + else + label="\"${selector:-$name}\"" + fi + # A row that cannot come back says why, in the sentence the list already + # carries -- the same sentence `sp restore` gives for the same row. When + # the records disagree, the fields that disagree are named too. This is + # the only refusal for a row that is not restorable, so there is no second + # branch below that no payload can reach. + if [[ $actionable != true ]]; then + if [[ -n $conflicts ]]; then + printf ' Closed session %s: %s (%s). Nothing changed.\n' \ + "$label" "${reason:-its launch records disagree with each other}" \ + "$conflicts" >&2 + else + printf ' Closed session %s keeps its history only: %s\n' \ + "$label" "${reason:-there is no conversation to reopen}" >&2 + fi + continue + fi + # A conversation closed on purpose has no pending record and needs no + # acknowledgment, so the generation keys are absent by design and only + # the conversation itself has to be complete. + if [[ -z $provider || -z $uuid || -z $cwd ]]; then + printf ' Closed session %s is incomplete. Nothing changed.\n' "$label" >&2 + continue + fi + # A row that is not restorable has already been answered above: the list + # carries a sentence for every one of them, so a second branch here could + # only ever fire for a payload the projection cannot emit. + active_target=$(active_target_for_uuid "$provider" "$uuid") || active_target=ambiguous + case "$active_target" in + managed:*) + active_number=${active_target#managed:} + # Only a pending record needs a fresh live proof. Its evidence remains + # retained and is hidden by the live projection while this exact + # conversation stays open. + if [[ -z $generation || -z $old_id ]]; then + : + elif "$STATUS_CMD" --recovery-pending-ack \ + "$generation" "$old_id" "$uuid" >/dev/null 2>&1; then + printf ' Confirmed session %s is open; its recovery evidence was retained.\n' \ + "$active_number" + else + printf ' Session %s is open, but its live proof was incomplete. Its recovery evidence was retained.\n' \ + "$active_number" >&2 + fi + printf ' Closed session %s is already open as session %s. Opening it.\n' \ + "$label" "$active_number" + printf ' Use fork %s from the picker to start a separate writable fork.\n' \ + "$active_number" + QUERY=$uuid + PAGE=1 + if build_view; then + choose_number "$active_number" + else + echo " The open session could not be shown. Nothing changed." + fi + continue + ;; + outside) + printf ' Closed session %s is already open outside the kit. Nothing changed.\n' \ + "$label" + continue + ;; + ambiguous) + printf ' Closed session %s cannot be told apart from a live one. Nothing changed.\n' \ + "$label" + continue + ;; + esac + if launched=$("$SP_CMD" restore-exact "$provider" "$uuid" "$cwd"); then + printf ' Restored closed session %s.\n' "$label" + acknowledged=0 + # A row with no pending record has nothing to acknowledge; the restore + # itself is the whole of the work. + if [[ -z $generation || -z $old_id ]]; then + acknowledged=1 + fi + for attempt in {1..20}; do + (( acknowledged == 0 )) || break + if "$STATUS_CMD" --recovery-pending-ack \ + "$generation" "$old_id" "$uuid" >/dev/null 2>&1; then + acknowledged=1 + break + fi + sleep 0.25 + done + if (( acknowledged == 0 )); then + printf ' Closed session %s is restored, and its record is still waiting on live proof. The record was kept.\n' "$label" >&2 + fi + else + printf ' Could not restore closed session %s. Its record was kept.\n' "$label" >&2 + fi + done 3<<<"$selected" + refresh_after_action +} diff --git a/lib/sh/shpool_login_render.sh b/lib/sh/shpool_login_render.sh new file mode 100644 index 0000000..3804f05 --- /dev/null +++ b/lib/sh/shpool_login_render.sh @@ -0,0 +1,702 @@ +#!/usr/bin/env bash +# Picker menu rendering: the session page, the watchdog repair banner, the main +# menu, the more menu, and help. Source this file; do not execute it. +# +# Source order: bin/shpool_login sources this module ahead of its first trap. +# These functions read REPAIR_FILE, REPAIR_BANNER, REPAIR_BANNER_LINES, +# PENDING_CACHE, PAGE, PAGE_SIZE, QUERY, VIEW, and the palette globals, all +# assigned in that file before the boot sequence runs, and call into +# lib/sh/shpool_login_theme.sh and lib/sh/shpool_login_view.sh. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + + +# Navigation stays in Bash; all visual and value decisions live beside the +# renderer used by `sp list`. This thin adapter is the only old-picker render +# path. +render_page() { + PYTHONPATH="$MODULE_DIR/..${PYTHONPATH:+:$PYTHONPATH}" \ + python3 - "$VIEW" "$PAGE" "$PAGE_SIZE" "$PICKER_STYLE" \ + "${PICKER_COMPACT:-0}" "${PICKER_JUMP_NUMBER:-}" <<'PY' +import json +import shutil +import sys + +from sessionkit_inventory.render import render_picker_page + +path, page, page_size, style, compact, jump = sys.argv[1:7] +with open(path, encoding="utf-8") as handle: + inventory = json.load(handle) +print( + render_picker_page( + inventory, + page=int(page), + page_size=int(page_size), + style_enabled=style == "1", + compact=compact == "1", + jump_number=int(jump) if jump.isdigit() else 0, + columns=shutil.get_terminal_size(fallback=(100, 24)).columns, + ) +) +PY +} + +# Unresolved watchdog records. Rendering is strictly read-only; a row is +# acknowledged only by the explicit, confirmed d number action in Needs you. +# +# Each row says what actually happened, which is not always a failed repair. +# The watchdog writes three outcomes -- `repaired`, `failed`, and `reported` -- +# and `reported` means it saw something and deliberately changed nothing, +# because silence alone is never enough to act on. Every one of these rows was +# headed "automatic repair failed" regardless, so the operator's screen +# announced fifty failed repairs when not one repair had been attempted +# (2026-08-15). The heading follows the same rule. +picker_repair_failure_rows() { + python3 - "$REPAIR_FILE" <<'PY' 2>/dev/null || true +import json +import sys +import unicodedata + +def cells(text): + return sum( + 0 if unicodedata.combining(character) + else 2 if unicodedata.east_asian_width(character) in {"W", "F"} + else 1 + for character in text + ) + +def pad(text, width): + return text + " " * max(0, width - cells(text)) + +path = sys.argv[1] +try: + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + entries = data["repairs"] + if not isinstance(entries, list): + raise ValueError +except (OSError, ValueError, KeyError): + sys.exit(0) + +failed = [ + (index, item) + for index, item in enumerate(entries) + if isinstance(item, dict) + and not item.get("acknowledged") + and item.get("outcome") != "repaired" +] +if not failed: + sys.exit(0) + + +def said(outcome): + if outcome == "failed": + return "automatic repair failed" + if outcome == "reported": + return "reported quiet, no repair attempted" + return f"recorded as {outcome}" if outcome else "recorded, outcome unknown" + + +print(len(failed)) +# Position AND identity. The position alone was a promise the file no longer +# keeps: retirement deletes records from the middle of it about once a minute, +# and `picker_modal_read` blocks for as long as a person reads the screen, so a +# `d2` typed against a drawn list landed on whatever had shifted into slot 2 -- +# it acknowledged another session's warning and printed success (found in +# review, 2026-08-15). The dismissal re-checks these two fields before it +# writes, so a list that moved underneath refuses out loud instead. +for position, (index, item) in enumerate(failed, 1): + stamp = item.get("at_unix_ms") + stamp = str(stamp) if isinstance(stamp, int) and not isinstance(stamp, bool) else "" + identity = " ".join(str(item.get("old_shpool_id") or "").split()) + print(f"d{position}\t{index}\t{identity}\t{stamp}") +# Name the heading after what is actually in the list rather than after the +# worst thing it could contain. +outcomes = {str(item.get("outcome") or "") for _index, item in failed} +print(" Repair failures" if outcomes == {"failed"} else " Watchdog reports") +# Columns, not a ragged edge: pad the token and the title by terminal cells. +titles = [" ".join(str(item.get("title") or "a session").split())[:80] for _, item in failed] +title_width = max((cells(title) for title in titles), default=1) +token_width = cells(f"d{len(failed)}") +for position, (_index, item) in enumerate(failed, 1): + title = titles[position - 1] + provider = str(item.get("provider") or "unknown").title() + print(f" {pad(f'd{position}', token_width)} {pad(title, title_width)} | {provider} | {said(str(item.get('outcome') or ''))}") +PY +} + +# One in-process projection reads the counts the footer needs straight off the +# view that was just drawn. A store failure remains advisory and produces no cue. +picker_redraw_projection() { + python3 - "$VIEW" 2>/dev/null <<'PY' +import json +import sys + +view_path = sys.argv[1] +with open(view_path, encoding="utf-8") as handle: + view = json.load(handle) +print(len(view.get("outside_agents", []))) +print(int((view.get("_picker") or {}).get("unavailable_total") or 0)) +PY +} + +# The sessions whose own rows say they need you. One list feeds the count on +# the home screen and the Needs you screen, so a headline can never say zero +# while three rows say otherwise -- and it is built from the whole snapshot in +# build_view, so a search filter cannot empty it either. Only the count and +# display rows leave this helper; conversation and shpool identifiers never do. +# +# A session with no number is quarantined: it is waiting and cannot be opened, +# so it is named as unavailable rather than dropped. +picker_needs_you_rows() { + PYTHONPATH="$MODULE_DIR/..${PYTHONPATH:+:$PYTHONPATH}" \ + python3 - "$VIEW" <<'PY' 2>/dev/null || true +import json +import sys +import time +import unicodedata + +from sessionkit_inventory.labels import waiting_since + +def cells(text): + return sum( + 0 if unicodedata.combining(character) + else 2 if unicodedata.east_asian_width(character) in {"W", "F"} + else 1 + for character in text + ) + +def pad(text, width): + return text + " " * max(0, width - cells(text)) + +try: + with open(sys.argv[1], encoding="utf-8") as handle: + picker = json.load(handle).get("_picker") or {} +except (OSError, ValueError): + picker = {} +wanted = [item for item in (picker.get("attention") or []) if isinstance(item, dict)] +print(len(wanted)) +machine_count = picker.get("machine_count", 0) +machine_needs = picker.get("machine_needs_you", 0) +machine_expandable = picker.get("machine_expandable_count", 0) +if ( + isinstance(machine_count, int) + and not isinstance(machine_count, bool) + and machine_count > 0 +): + if not isinstance(machine_needs, int) or isinstance(machine_needs, bool): + machine_needs = 0 + action = "hide" if picker.get("machine_expanded") else "show" + toggle = ( + f" | x {action}" + if isinstance(machine_expandable, int) + and not isinstance(machine_expandable, bool) + and machine_expandable > 0 + else "" + ) + print( + f" {machine_count} machine session{'s' if machine_count != 1 else ''}" + f" | {machine_needs} needs you{toggle}" + ) +display_rows = [] +for item in wanted: + if item.get("subagent_orphan"): + continue + if item.get("origin") == "machine" and not picker.get("machine_expanded"): + continue + number = item.get("number") or 0 + state = str(item.get("state") or "needs you") + label = str(number) if number else "--" + title = str(item.get("title") or "a session") + provider = str(item.get("provider") or "Unknown") + since = item.get("waiting_since_unix_ms") + if isinstance(since, int) and not isinstance(since, bool) and since > 0: + age = waiting_since(since, int(time.time() * 1000)) + state = age if state == "needs you" else f"{state} | {age}" + if not number: + state = f"{state} | unavailable" + display_rows.append((label, title, provider, state)) +title_width = max((cells(title) for _label, title, _provider, _state in display_rows), default=1) +label_width = max((cells(label) for label, _title, _provider, _state in display_rows), default=1) +for label, title, provider, state in display_rows: + print(f" {pad(label, label_width)} {pad(title, title_width)} | {provider} | {state}") +PY +} + +render_main() { + local pending=$PENDING_CACHE + local extra_lines=0 + + # The home screen carried a summary line here -- "needs you: N · N sessions, + # N repair failures". It is gone by operator ruling (2026-08-15): the count it + # advertised was dominated by watchdog records that never described a failed + # repair, so the headline was louder than the truth and could not be trusted + # at a glance. The `a` screen still lists every item, and it reads the same + # helpers directly, so nothing that a person can ACT on was removed -- only + # the number above the footer. + # + # Four counts were collected solely to build it (the two row helpers and the + # two fleet flags) and are collected no longer: three interpreter starts per + # keystroke and per five-second refresh, for a line that no longer exists. + # picker_repair_failure_rows and picker_needs_you_rows survive because + # show_attention_menu calls them; picker_fleet_flags had no other caller and + # went with the line. + [[ -z $LIVE_WARNING ]] || extra_lines=$(( extra_lines + 1 )) + + local sizing + local -a sizing_lines=() + sizing=$(page_size "$pending" "$extra_lines") || sizing="" + mapfile -t sizing_lines <<<"$sizing" + PAGE_SIZE=${sizing_lines[0]:-} + # The row count comes back with the size, so clamp_page below reads it + # instead of starting an interpreter to count the same rows again. + VIEW_ITEM_COUNT=${sizing_lines[1]:-} + [[ $VIEW_ITEM_COUNT =~ ^[0-9]+$ ]] || VIEW_ITEM_COUNT= + # A failed sizing helper must degrade to a usable page, never to a bash + # division-by-zero and a traceback in the login window. + [[ $PAGE_SIZE =~ ^[0-9]+$ && $PAGE_SIZE -ge 1 ]] || PAGE_SIZE=10 + if (( ${PICKER_JUMP_PENDING:-0} )); then + local jump_page + if jump_page=$(page_for_number "$PICKER_JUMP_NUMBER"); then + PAGE=$jump_page + fi + PICKER_JUMP_PENDING=0 + fi + clamp_page + render_page + # Recorded from the same page slice that was just drawn, so a mass close can + # be checked against what a person actually read. A repaint that changes the + # set is remembered until the next prompt reads it. + local drawn_numbers + drawn_numbers=$(page_numbers) + [[ $drawn_numbers == "$PAGE_RENDERED_NUMBERS" ]] || PAGE_RENDER_CHANGED=1 + PAGE_RENDERED_NUMBERS=$drawn_numbers + [[ -z $LIVE_WARNING ]] || printf ' %s\n' "$LIVE_WARNING" + # Compact mode buys its extra rows from the group headings and this spacer. + # page_size() subtracts the same line, so the two stay in step. + [[ ${PICKER_COMPACT:-0} == 1 ]] || echo + local cols=${COLUMNS:-} + if ! [[ $cols =~ ^[0-9]+$ ]]; then + cols=$(command stty size <&2 2>/dev/null) && cols=${cols##* } || cols= + [[ $cols =~ ^[0-9]+$ && ${cols:-0} -gt 0 ]] || { + cols=$(tput cols 2>/dev/null) && [[ $cols =~ ^[0-9]+$ ]] || cols= + } + [[ $cols =~ ^[0-9]+$ ]] || cols=80 + fi + [[ $cols =~ ^[0-9]+$ ]] || cols=80 + # One footer, one shape, at every width: verb first, lowercase, middot + # separated. A narrow window drops items off the end; it never rewrites the + # ones that are left in another notation. + # + # The footer opens by saying what Enter does HERE (operator ruling, + # 2026-08-16): open the top row it just drew, named by its real number, or + # start a new session when the list is empty. The number comes from the same + # page slice the dispatcher reads, so the promise and the action agree. + # + # Width used to be four hand-sized tiers; the Enter hint's variable width + # (a 1-3 digit number) broke the narrowest one the day it landed. Now every + # segment carries its own plain-text width and the line keeps segments, + # first to last, while they fit in cols-1, measured on the words, not the + # color codes. The Enter hint is first, so it is the last thing a narrow + # window ever loses. + local enter_plain enter_colored + if enter_plain=$(first_number_on_page); then + enter_colored="$(picker_green '↵') open $enter_plain" + enter_plain="↵ open $enter_plain" + else + enter_colored="$(picker_green '↵') new" + enter_plain="↵ new" + fi + # This is the top screen, so `b` has nowhere to go but out. Where there is + # room the footer names that door, rather than leaving the only way out of + # the picker unnamed at every width, which it was. + # Priority order, not reading order: the fitting loop drops from the end, + # so position IS survival. The old tiers always kept `more` (a door to three + # screens) at widths that dropped `history` (the rarer key); this order + # keeps that promise. + local -a plain=( + "$enter_plain" "#" "kill k #" "new n" "more m" + "needs you a" "help ?" "history h #" "quit q or b" + ) + local -a colored=( + "$enter_colored" "$(picker_green '#')" "kill $(picker_green 'k #')" + "new $(picker_green n)" "more $(picker_green m)" + "needs you $(picker_green a)" "help $(picker_green '?')" + "history $(picker_green 'h #')" + "quit $(picker_green q) or $(picker_green b)" + ) + local line=" ${colored[0]}" used=$(( 2 + ${#plain[0]} )) index + for (( index = 1; index < ${#plain[@]}; index++ )); do + (( used + 3 + ${#plain[index]} <= cols - 1 )) || break + line+=" · ${colored[index]}" + used=$(( used + 3 + ${#plain[index]} )) + done + printf '%s\n' "$line" +} + +# An unknown key is a person looking for the one that works, so every surface +# answers with its own list instead of a dead end. Callers pass the keys they +# actually dispatch -- naming a key that does nothing is the same defect in a +# politer voice. +picker_unknown_choice() { + printf ' There is no such key on this screen. These work: %s.\n' "$1" +} + +# One review surface for every item that can require a decision. Opening this +# view is read-only. Items clear only through an explicit, confirmed repair +# dismissal. +show_attention_menu() { + while true; do + local repair_output="" repair_rows=0 repair_body="" counted=0 answer total + local needs_output="" needs_rows=0 needs_body="" + REPAIR_INDEX="" + + # The same list the home count is built from, so this screen can never say + # nothing needs you while the list behind it says three sessions do. + needs_output=$(picker_needs_you_rows) || needs_output="" + if [[ -n $needs_output ]]; then + needs_rows=${needs_output%%$'\n'*} + [[ $needs_rows =~ ^[0-9]+$ ]] || needs_rows=0 + needs_body=${needs_output#*$'\n'} + (( needs_rows > 0 )) || needs_body="" + fi + + repair_output=$(picker_repair_failure_rows) || repair_output="" + if [[ -n $repair_output ]]; then + repair_rows=${repair_output%%$'\n'*} + [[ $repair_rows =~ ^[0-9]+$ ]] || repair_rows=0 + repair_body=${repair_output#*$'\n'} + counted=0 + while (( counted < repair_rows )); do + REPAIR_INDEX+="${repair_body%%$'\n'*}"$'\n' + repair_body=${repair_body#*$'\n'} + counted=$(( counted + 1 )) + done + fi + + # The two one-door signals the home line counts must render HERE too, a + # headline that advertises a review the review cannot show is a dead end + # (defect found in tonight's reconciliation pass). + local fleet_body="" fleet_rows=0 + fleet_body=$(python3 - "$VIEW" 2>/dev/null <<'PY' +import glob +import json +import os +import re +import sys +import time + +# Bounded and garbage-proof -- 200 records, 64 KiB each, 256 KiB stalls file -- +# so one hostile or corrupt file can never hide the rest or stall an SSH login +# (audit finding, 2026-08-12). Plus sanitization: +# every printed field is fleet-controlled text headed for the login TTY, so +# control characters are stripped and lengths capped (audit finding, +# 2026-08-12), a title must never forge rows or clear the operator's screen. +# +# And the SAME de-duplication as the home count: this screen listed a session +# once as a row and again as its own stall flag, so the review said 3 where the +# headline said 1. One counted set, read from the view both surfaces share. +try: + with open(sys.argv[1], encoding="utf-8") as handle: + view = json.load(handle) + _picker = view.get("_picker") or {} +except (OSError, ValueError): + view = {} + _picker = {} +counted = { + str(item.get("uuid") or "") + for item in (_picker.get("attention") or []) + if isinstance(item, dict) +} - {""} +fleet = os.path.expanduser("~/.local/state/fleet") +lines = [] +now = time.time() + + +def clean(value, limit): + text = "".join( + ch for ch in str(value or "") if ch >= " " and ch != "\x7f" + ) + return text[:limit] or "?" + + +def human_text(value, limit, fallback): + text = clean(value, 4096) + if text == "?" and not str(value or "").strip(): + text = fallback + text = re.sub( + r"(?i)(?:claude:|codex:)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-" + r"[0-9a-f]{4}-[0-9a-f]{12}", + fallback, + text, + ) + text = re.sub(r"(?i)\bs\d{8}-\d{6}-\d+\b", fallback, text) + text = re.sub( + r"(?i)\b(?=[0-9a-f]{8,}\b)(?=[0-9a-f]*[a-f])(?=[0-9a-f]*[0-9])[0-9a-f]+\b", + fallback, + text, + ) + return (text or fallback)[:limit] + + +def age(seconds): + seconds = max(0, int(seconds)) + if seconds < 3600: + return f"{seconds // 60}m" + if seconds < 86400: + return f"{seconds // 3600}h {seconds % 3600 // 60}m" + return f"{seconds // 86400}d" + + +for path in sorted(glob.glob(fleet + "/inbox/*.json"))[:200]: + try: + if os.path.getsize(path) > 65536: + continue + with open(path, encoding="utf-8") as handle: + rec = json.load(handle) + if not isinstance(rec, dict) or rec.get("state") != "open": + continue + session = rec.get("session") + if not isinstance(session, dict): + session = {} + if str(session.get("uuid") or "") in counted: + continue + who = human_text(session.get("title"), 48, "a session") + waited = age(now - float(rec.get("asked_at") or now)) + header = human_text(rec.get("header") or "Decision", 60, "Decision") + lines.append(f" Question · {header} · {who} · needs you {waited}") + except (OSError, ValueError, TypeError): + continue +try: + if os.path.getsize(fleet + "/stalls.json") <= 262144: + with open(fleet + "/stalls.json", encoding="utf-8") as handle: + data = json.load(handle) + if isinstance(data, dict) and now - float( + data.get("generated_at") or 0 + ) < 300: + stalled = data.get("stalled") + subjects = _picker.get("stall_subjects") or {} + for item in (stalled if isinstance(stalled, list) else [])[:200]: + if not isinstance(item, dict): + continue + if str(item.get("key") or "") in counted: + continue + try: + since = age(now - float(item.get("since") or now)) + except (ValueError, TypeError): + since = "?" + reason = { + "unsurfaced": "not surfaced", + "unanswered": "answer overdue", + "silent": "response overdue", + "orphan": "session unavailable", + }.get(str(item.get("reason") or "").casefold(), "attention overdue") + session = subjects.get(str(item.get("key") or "")) + if isinstance(session, dict): + who = human_text( + session.get("title") or "", + 48, + "a session", + ) + provider = clean( + session.get("provider") or "", + 20, + ) or "Unknown" + subject = f"{who} · {provider}" + else: + subject = "a session" + lines.append( + f" Stalled · {subject} · " + f"{reason} · {since}" + ) +except (OSError, ValueError, TypeError): + pass +print(len(lines)) +for line in lines: + print(line) +PY +) || fleet_body="" + if [[ -n $fleet_body ]]; then + fleet_rows=${fleet_body%%$'\n'*} + [[ $fleet_rows =~ ^[0-9]+$ ]] || fleet_rows=0 + fleet_body=${fleet_body#*$'\n'} + [[ $fleet_body != "$fleet_rows" ]] || fleet_body="" + fi + + total=$(( needs_rows + repair_rows + fleet_rows )) + echo + printf ' needs you: %s\n' "$total" + echo + if [[ -n $needs_body ]]; then + echo " Sessions" + printf '%s\n' "$needs_body" + # A quarantined session is listed because it is waiting, and marked + # because there is no number to act on. + [[ $needs_body != *' | unavailable'* ]] || + echo " A session shown as -- has no shell left to open; sp help unavailable has the rest." + echo + fi + [[ -z $repair_body ]] || { printf '%s\n\n' "$repair_body"; } + [[ -z $fleet_body ]] || { printf '%s\n\n' "$fleet_body"; } + if (( total == 0 )); then + echo " Nothing needs you." + echo + fi + if (( fleet_rows > 0 )); then + echo " Answer it in its own window." + fi + # The one key this screen adds is offered only when it has something to + # act on: a line naming an action with no rows is the defect this pass is + # about, one screen further down. + (( repair_rows == 0 )) || echo " dismiss d number" + echo " ↵ back · b back · help ?" + echo + picker_modal_read answer " needs ❯ " || return 0 + case "$answer" in + ""|b|B|back|q|Q) return 0 ;; + d[0-9]*|D[0-9]*) dismiss_repair_failure "${answer#[dD]}" ;; + x|X) toggle_machine_sessions ;; + \?) show_help ;; + *) + if [[ $needs_body == *' | x '* ]]; then + picker_unknown_choice "d number, x, ?, b, and Enter" + else + picker_unknown_choice "d number, ?, b, and Enter" + fi + ;; + esac + done +} + +# Everything that used to clutter the main screen (recovery, other-provider, +# unavailable records) lives here behind one key. +show_more_menu() { + local pending other_count unavailable_count counts + local -a count_lines=() + while true; do + # Recomputed every pass: restoring recoveries from inside this menu must + # not leave it advertising counts that are no longer true. + refresh_pending_cache + pending=$PENDING_CACHE + # One read of the view for both numbers, not one interpreter each. + counts=$(picker_redraw_projection) || counts=$'0\n0' + mapfile -t count_lines <<<"$counts" + other_count=${count_lines[0]:-0} + unavailable_count=${count_lines[1]:-0} + [[ $other_count =~ ^[0-9]+$ ]] || other_count=0 + [[ $unavailable_count =~ ^[0-9]+$ ]] || unavailable_count=0 + echo + echo " More" + if (( other_count > 0 )); then + printf ' %s Outside the kit: %s session%s (read-only)\n' \ + "$(picker_green o)" "$other_count" \ + "$([[ $other_count == 1 ]] && printf '' || printf 's')" + fi + if (( pending > 0 )); then + printf ' %s Closed sessions: %s to restore\n' \ + "$(picker_green u)" "$pending" + fi + if (( unavailable_count > 0 )); then + printf ' Unavailable: %s session%s whose shell is gone. Cleanup closes them; sp help unavailable has the rest.\n' \ + "$unavailable_count" \ + "$([[ $unavailable_count == 1 ]] && printf '' || printf 's')" + fi + printf ' %s Projects: add, review, or drop the directories new sessions offer\n' \ + "$(picker_green p)" + if (( other_count == 0 && pending == 0 && unavailable_count == 0 )); then + echo " Outside the kit, closed sessions, and unavailable records: none." + fi + printf ' %s Help: every picker key on one screen\n' "$(picker_green '?')" + echo " ↵ back · b back" + echo + local answer + picker_modal_read answer " more ❯ " || return 0 + case "$answer" in + ""|b|B|back|q|Q) return 0 ;; + o|O) + if (( other_count > 0 )); then + show_other_provider_sessions + else + echo " Outside the kit: none." + fi + ;; + u|U) + # An if, not &&/||: review_recovery returning nonzero (Enter backout, + # a failed guard that already explained itself) must not append a + # contradictory "Nothing to review." + if (( pending > 0 )); then + review_recovery + else + echo " Closed sessions: none." + fi + ;; + p|P) + show_projects_menu + ;; + \?) show_help ;; + *) picker_unknown_choice "o, u, p, ?, b, and Enter" ;; + esac + done +} + +# One key table for the whole picker. The `?` screen prints it, the footer +# advertises a subset of it, and tests/test_picker_ux.py fails when a key the +# main loop dispatches is missing from it -- which is how the help and the +# menu stopped agreeing in the first place: `a` and `m` were on the footer of +# every screen and in no help text anywhere. +# +# Emitted as section<TAB>key<TAB>meaning so one list serves both surfaces. +picker_help_rows() { + cat <<'ROWS' +Sessions number Open a session, or show what can be done with one that is open elsewhere +Sessions h number Read a session's history without opening it +Sessions n New session +Sessions k numbers Kill sessions: k 5 · k 5, 6, 8 · k 4-7 · k all +Sessions name number Give a Claude or Codex conversation a name of your own +Sessions name reset number Drop that name and show the provider's again +Sessions fork number Start a separate fork of a Claude or Codex conversation +Sessions model number Move an idle Claude or Codex conversation to another model +Sessions x Show or hide machine sessions behind their counted row +Needs you a Everything that needs you +Needs you g Move to the next session that needs you, marking it with ▸ +Needs you d number Dismiss a repair that failed, from the Needs you screen +The list /text Type to filter names, providers, accounts, models, and projects +The list group Group by state, provider, or project; group provider picks one +The list c Compact rows: no headings, more sessions on the screen +The list next / prev Move between pages; > and < do the same +The list r Refresh the list and clear the filter +The list m More: sessions outside the kit, closed sessions, projects, unavailable records +The list p Projects: add, review, hide, or restore the directories new sessions offer +The list o Show sessions outside the kit (read-only) +The list u Restore a closed conversation; opening this screen changes nothing +Going back b Back, on every screen inside the picker +Going back Enter Takes the most likely choice on every screen: here the top session (a new session when the list is empty), Claude Code on New session, the recommended account or project where one is named; where nothing is choosable it goes back, and the screen's own footer says which +Quitting b Back from the top is out: quit the picker for a regular shell +Quitting q / quit / exit The same, by name +Quitting Ctrl-C Cancel the prompt you are in; on this screen, redraw it +ROWS +} + +show_help() { + echo + echo " Picker help" + local section previous="" key meaning + while IFS=$'\t' read -r section key meaning; do + [[ -n $key ]] || continue + if [[ $section != "$previous" ]]; then + echo + printf ' %s\n' "$(picker_bold "$section")" + previous=$section + fi + printf ' %-13s %s\n' "$key" "$meaning" + done < <(picker_help_rows) + echo + echo " A key takes its number with or without the space: k5 and k 5 are the same." + echo " Inside a session: Ctrl-Q leaves it running, Ctrl-D or bye closes it." + echo " History is an action on every row." + echo " Grouping and compact rows last for this window; SESSION_KIT_PICKER_GROUP and" + echo " SESSION_KIT_PICKER_COMPACT set how it starts." + echo + picker_back_read " ↵ back · b back ❯ " +} diff --git a/lib/sh/shpool_login_theme.sh b/lib/sh/shpool_login_theme.sh new file mode 100644 index 0000000..1f02413 --- /dev/null +++ b/lib/sh/shpool_login_theme.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Picker palette and terminal surface. Source this file; do not execute it. +# +# Source order: bin/shpool_login sources this module after it resolves +# SCRIPT_DIR, sources bin/session_kit_common, and assigns PICKER_SCREEN, PICKER_STYLE, +# PICKER_BOLD, PICKER_GREEN, and PICKER_RESET, and before it installs its first +# trap. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +picker_bold() { + if (( PICKER_STYLE )); then + printf '%s%s%s' "$PICKER_BOLD" "$1" "$PICKER_RESET" + else + printf '%s' "$1" + fi +} + +picker_green() { + if (( PICKER_STYLE )); then + printf '%s%s%s%s' "$PICKER_BOLD" "$PICKER_GREEN" "$1" "$PICKER_RESET" + else + printf '%s' "$1" + fi +} + +# The attachment that just ended painted its own frames; without a clear the +# menu overprints the dead session's screen and reads as corruption (seen +# live at the 2026-08-02 daemon restart). Screen control is independent of +# color: NO_COLOR removes styling, not safe cursor and clear-screen control. +picker_clear_screen() { + (( PICKER_SCREEN )) && printf '\033[2J\033[H' + return 0 +} + +# Compute-first frames: a renderer runs with stdout captured (in the current +# shell, so its state side effects survive), and the finished bytes reach the +# terminal as ONE write. In `home` mode each replaced line erases its own +# remainder and the frame ends by erasing below, nothing is blanked until +# its replacement is in the same write, so a slow renderer can never expose +# an empty screen, with or without DEC-2026 support. `append` mode emits the +# captured block in one write at the cursor, which turns a progressively +# painted menu into a single paint. +picker_frame_emit_file() { + local mode=$2 content + content=$(cat -- "$1" && printf x) || return 0 + content=${content%x} + if (( PICKER_SCREEN )) && [[ $mode == home ]]; then + content=${content//$'\n'/$'\033[K\n'} + printf '\033[?2026h\033[H%s\033[K\033[J\033[?2026l' "$content" + else + # Append mode: the capture already makes this one write; extra control + # sequences would change the screen contract for no gain. + printf '%s' "$content" + fi + return 0 +} + +terminal_height() { + local height=${LINES:-} + if [[ ! $height =~ ^[0-9]+$ ]]; then + height=$(tput lines 2>/dev/null || true) + fi + [[ $height =~ ^[0-9]+$ ]] || height=24 + # Respect genuinely short windows down to 8 rows: budgeting for 12 lines an + # 8-row split cannot show scrolled the summary and first sessions off-screen + # before the human could read them. + (( height >= 8 )) || height=8 + (( height <= 200 )) || height=200 + printf '%s\n' "$height" +} diff --git a/lib/sh/shpool_login_view.sh b/lib/sh/shpool_login_view.sh new file mode 100644 index 0000000..9becdbe --- /dev/null +++ b/lib/sh/shpool_login_view.sh @@ -0,0 +1,622 @@ +#!/usr/bin/env bash +# Picker view model: the rendered row set, page sizing, and the number-to-row +# lookup every action starts from. Source this file; do not execute it. +# +# Source order: bin/shpool_login sources this module ahead of its first trap. +# These functions read SNAPSHOT, VIEW, QUERY, PAGE, PAGE_SIZE, RECOVERY, +# PICKER_GROUP_MODE, and PICKER_COMPACT, all assigned in that file before the +# boot sequence runs, and call terminal_height() from +# lib/sh/shpool_login_theme.sh and new_temp() from lib/sh/shpool_login_live.sh. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +# The grouping a person asked for, or the default one. Every caller passes the +# validated value on, so an unknown mode can never reach the projection and +# reorder a list nobody asked to reorder. +picker_group_mode() { + case "${PICKER_GROUP_MODE:-state}" in + provider) printf 'provider' ;; + project) printf 'project' ;; + *) printf 'state' ;; + esac +} + +build_view() { + local fresh + # A new list is a new count. + VIEW_ITEM_COUNT= + new_temp login-view || return 1 + fresh=$NEW_TEMP + if ! PYTHONPATH="$MODULE_DIR/..${PYTHONPATH:+:$PYTHONPATH}" \ + python3 - "$SNAPSHOT" "$fresh" "$QUERY" "$(picker_group_mode)" \ + "${SK_PROJECTS_FILE:-}" "${PICKER_MACHINE_EXPANDED:-0}" <<'PY' +import json +import os +import re +import sys +import unicodedata + +from sessionkit_inventory.common import stall_threshold_seconds +from sessionkit_inventory.labels import IDLE, QUESTION, WAITING_ON_YOU, session_state +from sessionkit_inventory.model import ( + canonical_session_order_key, + classify_top_level_sessions, + session_is_unavailable, +) + +source, destination, query, group_mode, projects_file, expanded_text = sys.argv[1:7] +with open(source, encoding="utf-8") as handle: + data = json.load(handle) + +if group_mode not in {"state", "provider", "project"}: + group_mode = "state" +needle = query.casefold() +machine_expanded = expanded_text == "1" +provider_order = {"claude": 0, "codex": 1, "shell": 2, "unknown": 3} +provider_titles = { + "claude": "Claude", + "codex": "Codex", + "shell": "Managed shell", + "unknown": "Unknown", +} +NO_PROJECT = "No project" + +def clean(value, limit=4096): + text = str(value or "") + text = "".join( + " " if unicodedata.category(character).startswith("C") else character + for character in text + ) + return " ".join(text.split())[:limit] + +def matches(row): + identity = row.get("identity") or {} + fields = ( + row.get("shpool_id"), + row.get("provider"), + row.get("display_provider"), + row.get("account_alias"), + row.get("model"), + row.get("title"), + row.get("display_title"), + row.get("native_title"), + row.get("cwd"), + identity.get("uuid"), + row.get("terminal_number"), + *( + child.get("title") + for child in (row.get("subagents") or []) + if isinstance(child, dict) + ), + ) + return not needle or any(needle in str(value or "").casefold() for value in fields) + +def load_project_roots(path): + # projects.tsv is alias<TAB>provider<TAB>absolute path. Longest root first + # so a session opened three directories inside a project still groups with + # the project, not with its own leaf directory. + entries = [] + try: + with open(path, encoding="utf-8") as handle: + for line in handle: + parts = line.rstrip("\n").split("\t") + if len(parts) < 3: + continue + alias = clean(parts[0], 40) + root = parts[2].strip() + if not alias or not root.startswith("/"): + continue + entries.append((os.path.normpath(root), alias)) + except OSError: + return [] + entries.sort(key=lambda item: len(item[0]), reverse=True) + return entries + +project_roots = load_project_roots(projects_file) if projects_file else [] + +def project_label(row): + # The seam project identity plugs into: an inventory row that already + # knows its project names itself, and nothing here has to guess. Until + # that field exists, the answer is derived from the working directory, + # which is what the picker has always had. + candidate = row.get("project") + if isinstance(candidate, dict): + for field in ("name", "alias", "slug", "title"): + value = candidate.get(field) + if isinstance(value, str) and value.strip(): + return clean(value, 40) + for field in ("project_name", "project_alias", "project"): + value = row.get(field) + if isinstance(value, str) and value.strip(): + return clean(value, 40) + cwd = str(row.get("cwd") or "").strip() + if not cwd.startswith("/"): + return NO_PROJECT + normalized = os.path.normpath(cwd) + for root, alias in project_roots: + if normalized == root or normalized.startswith(root + os.sep): + return alias + return clean(os.path.basename(normalized), 40) or NO_PROJECT + +def provider_title(row): + key = str(row.get("display_provider") or row.get("provider") or "").casefold() + return provider_titles.get(key, provider_titles["unknown"]) + +def group_label(row): + if group_mode == "provider": + return provider_title(row) + if group_mode == "project": + return project_label(row) + return ( + "Ready" + if row.get("availability") == "ready" + else "Open elsewhere" + ) + +# The one state word a row displays is the authority for whether it is waiting, +# because that word is what the person read on the list. It is derived HERE the +# same way the list derives it -- labels.session_state, the function +# render.py already calls for the state column -- rather than read from a +# precomputed field, so the two surfaces cannot answer differently and neither +# depends on a publisher having filled one in. +# +# This used to test the raw `needs_you`/`blocking_question` flags, which is a +# different question. The 2026-08-15 ruling that a finished provider turn is +# `needs you` however the vendor spells it lives in labels.STATE_WORDS and +# never reached this screen: a Codex turn ending in `task_complete` reports +# `agent_status: idle` with `needs_you: false`, so the list said `needs you` +# for it while this screen left it out and `g` skipped past it. +# +# The test is a superset of the flags it replaced -- blocking_question yields +# `question`, and needs_you yields `needs you` or `idle` -- so no row that was +# listed before can stop being listed now. +ATTENTION_STATES = {QUESTION, WAITING_ON_YOU, IDLE} +_stall_seconds = stall_threshold_seconds() + + +def waiting_state(row): + """The word the list shows for this row.""" + + return session_state(row, stall_seconds=_stall_seconds) + + +def is_waiting(row): + """Whether this row is waiting on a person at all. + + One predicate for both the membership test and the waited-for age, because + a row on this screen with no age is the same defect twice: an unfinished + launch says `pending`, which is not one of the words above, and gating the + age separately silently dropped how long it had been pending. + """ + + return waiting_state(row) in ATTENTION_STATES or bool(row.get("setup_incomplete")) + + +rows = [] +human_rows, expandable_machine_rows, orphan_subagents = classify_top_level_sessions( + data.get("sessions", []) +) +for item in orphan_subagents: + item["origin"] = "machine" + item["_picker_subagent_orphan"] = True +all_machine_rows = expandable_machine_rows + orphan_subagents +unavailable_total = sum( + session_is_unavailable(item) + for item in human_rows + all_machine_rows + if isinstance(item, dict) +) +machine_rows = [item for item in expandable_machine_rows if matches(item)] +subagent_orphan_count = sum(matches(item) for item in orphan_subagents) +visible_sources = human_rows + ( + [item for item in expandable_machine_rows if matches(item)] + if machine_expanded + else [] +) +for original in visible_sources: + if not isinstance(original, dict): + continue + if session_is_unavailable(original): + continue + number = original.get("terminal_number") + if not matches(original): + continue + row = dict(original) + row["_picker_group_label"] = group_label(row) + # How long this session has been waiting. The renderer has always known how + # to say it -- and the field it reads was never written by anything, so a + # row waiting two hours and a row waiting two minutes read identically and + # the only number on either was its creation date. The moment a waiting + # session went quiet IS the moment it started waiting, which is the last + # output it produced. + if is_waiting(row): + waiting_since = row.get("recent_output_at_unix_ms") + if ( + isinstance(waiting_since, int) + and not isinstance(waiting_since, bool) + and waiting_since > 0 + ): + row["_picker_waiting_since_unix_ms"] = waiting_since + rows.append(row) + +def default_order(row): + return canonical_session_order_key(row) + +def grouped_order(row): + # Grouping only decides which rows sit together. Inside every group the + # order is the one the list has always used, so a person who never + # switches grouping sees the exact list they saw before this existed. + if group_mode == "provider": + return ( + provider_order.get( + row.get("display_provider") or row.get("provider"), 9 + ), + default_order(row), + ) + if group_mode == "project": + label = str(row.get("_picker_group_label") or NO_PROJECT) + return ( + 1 if label == NO_PROJECT else 0, + label.casefold(), + default_order(row), + ) + return (0, default_order(row)) + +# What needs the operator, computed from the WHOLE snapshot: a search is a +# view control, and a session waiting on a person does not stop waiting +# because the list is filtered to something else. A row whose shell generation +# could not be proven has no number to act on, and it is the row most likely +# to need attention -- it is listed as unavailable rather than dropped. +attention = [] +for original in sorted( + ( + item + for item in human_rows + all_machine_rows + if isinstance(item, dict) + and not session_is_unavailable(item) + and ( + is_waiting(item) + ) + ), + key=default_order, +): + number = original.get("terminal_number") + if isinstance(number, bool) or not isinstance(number, int) or number <= 0: + number = 0 + attention.append( + { + "number": number, + "uuid": str((original.get("identity") or {}).get("uuid") or ""), + "title": clean( + original.get("display_title") or original.get("title") or "a session", + 60, + ), + "provider": provider_title(original), + "origin": "machine" if original.get("origin") == "machine" else "human", + "subagent_orphan": original.get("_picker_subagent_orphan") is True, + "state": ( + "pending" + if original.get("setup_incomplete") + else waiting_state(original) + ), + "waiting_since_unix_ms": ( + original.get("recent_output_at_unix_ms") + if is_waiting(original) + else None + ), + } + ) + +rows.sort(key=grouped_order) +for row in rows: + row["title"] = clean(row.get("title"), 120) + row["display_title"] = clean( + row.get("display_title") or row.get("title"), 120 + ) + row["display_shpool_id"] = clean( + row.get("display_shpool_id") or row.get("shpool_id"), 96 + ) + row["native_title"] = clean(row.get("native_title"), 120) + row["account_alias"] = clean(row.get("account_alias"), 20) + row["model"] = clean(row.get("model"), 80) + row["display_model"] = clean(row.get("display_model"), 80) + row["model_state"] = clean(row.get("model_state"), 40) + +outside = [] +for original in data.get("outside_agents", []): + if not isinstance(original, dict) or not matches(original): + continue + item = dict(original) + item["title"] = clean(item.get("title"), 120) + item["display_title"] = clean( + item.get("display_title") or item.get("title"), 120 + ) + item["cwd"] = clean(item.get("cwd"), 4096) + outside.append(item) +outside.sort( + key=lambda item: ( + provider_order.get( + item.get("display_provider") or item.get("provider"), 9 + ), + str(item.get("display_title") or "").casefold(), + str((item.get("identity") or {}).get("uuid") or ""), + ) +) + +projected = dict(data) +projected["sessions"] = rows +projected["outside_agents"] = outside +stall_subjects = {} +for original in human_rows: + identity = original.get("identity") or {} + subject = { + "title": clean( + original.get("display_title") or original.get("title") or "a session", + 60, + ), + "provider": provider_title(original), + } + for key in { + str(identity.get("uuid") or ""), + str(original.get("shpool_id_raw") or ""), + str(original.get("shpool_id") or ""), + } - {""}: + stall_subjects[key] = subject +projected["_picker"] = { + "query": query, + "group_mode": group_mode, + "source_total": len(human_rows) + ( + len(expandable_machine_rows) if machine_expanded else 0 + ), + "source_outside_total": len(data.get("outside_agents", [])), + "unavailable_total": unavailable_total, + "attention": attention, + "stall_subjects": stall_subjects, + "machine_count": len(machine_rows), + "subagent_orphan_count": subagent_orphan_count, + "machine_expandable_count": sum( + matches(item) for item in expandable_machine_rows + ), + "machine_expanded": machine_expanded, + "machine_needs_you": sum(bool(is_waiting(item)) for item in machine_rows), +} +with open(destination, "w", encoding="utf-8") as handle: + json.dump(projected, handle, sort_keys=True, separators=(",", ":")) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) +os.chmod(destination, 0o600) +PY + then + command rm -- "$fresh" + return 1 + fi + VIEW=$fresh +} + +page_size() { + local pending=${1:-0} banner=${2:-0} height + height=$(terminal_height) + python3 - "$VIEW" "$height" "$pending" "$banner" \ + "${PICKER_COMPACT:-0}" <<'PY' +import json +import sys + +path, height_text, pending_text, banner_text, compact_text = sys.argv[1:6] +height = int(height_text) +has_recovery = pending_text.isdigit() and int(pending_text) > 0 +banner_lines = int(banner_text) if banner_text.isdigit() else 0 +compact = compact_text == "1" +with open(path, encoding="utf-8") as handle: + data = json.load(handle) +rows = data.get("sessions", []) +outside = data.get("outside_agents", []) +picker = data.get("_picker") or {} +total = len(rows) + +def page_lines(first, last, paged): + selected = rows[first:min(last, len(rows))] + + # Search and stale notice. The attention summary line that used to sit + # below the session list is gone (operator ruling, 2026-08-15), so nothing + # here reserves a row for it. + lines = 2 if picker.get("query") else (1 if rows else 0) + if data.get("stale"): + lines += 1 + + # Group headings are what compact mode buys back. The count here and the + # headings the renderer prints come from the same per-row label, so the + # two can never drift into a page that scrolls its own footer away. + previous_label = None + for row in selected: + label = row.get("_picker_group_label") + if not compact and label != previous_label: + lines += 1 # group heading + previous_label = label + lines += 1 + + if first == 0 and picker.get("machine_count"): + lines += 1 + + if not selected and ( + picker.get("query") + or not isinstance(picker.get("machine_count"), int) + or picker.get("machine_count", 0) <= 0 + ): + lines += 1 + if paged: + lines += 1 + lines += banner_lines # the live warning, when there is one + # Spacer, compact footer, and input prompt. Compact mode drops the spacer. + lines += 2 if compact else 3 + return lines + +# The row count is printed with the size: the next caller needs exactly this +# number, and it used to start its own interpreter to count the same rows in +# the same file. +if total == 0: + print(1) + print(0) + raise SystemExit + +for size in range(total, 0, -1): + pages = (total + size - 1) // size + if all( + page_lines(first, min(first + size, total), pages > 1) <= height + for first in range(0, total, size) + ): + print(size) + break +else: + # Very short terminals still get one item and can scroll safely. + print(1) +print(total) +PY +} + +view_item_count() { + # The count the last page sizing produced, when there has been one for this + # view. build_view clears it, so a stale number can never outlive the list it + # counted. + if [[ ${VIEW_ITEM_COUNT:-} =~ ^[0-9]+$ ]]; then + printf '%s\n' "$VIEW_ITEM_COUNT" + return 0 + fi + python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(len(d.get("sessions",[])))' "$VIEW" +} + +page_count() { + local total + total=$(view_item_count) + [[ $total =~ ^[0-9]+$ ]] || total=0 + printf '%s\n' "$(( total == 0 ? 1 : (total + PAGE_SIZE - 1) / PAGE_SIZE ))" +} + +view_index_for_number() { + local number=$1 + [[ $number =~ ^[0-9]+$ ]] || return 1 + python3 - "$VIEW" "$number" <<'PY' +import json +import sys + +path, wanted_text = sys.argv[1:3] +wanted = int(wanted_text) +with open(path, encoding="utf-8") as handle: + rows = json.load(handle).get("sessions", []) +for index, row in enumerate(rows): + if isinstance(row, dict) and row.get("terminal_number") == wanted: + print(index) + raise SystemExit(0) +raise SystemExit(1) +PY +} + +page_for_number() { + local number=$1 index + [[ $PAGE_SIZE =~ ^[0-9]+$ && $PAGE_SIZE -ge 1 ]] || return 1 + index=$(view_index_for_number "$number") || return 1 + printf '%s\n' "$(( index / PAGE_SIZE + 1 ))" +} + +clamp_page() { + local pages + pages=$(page_count) + (( PAGE >= 1 )) || PAGE=1 + (( PAGE <= pages )) || PAGE=$pages +} + +# Every number the current page is showing, in display order. `all` means +# exactly what a person can see, never a session on another page. +page_numbers() { + python3 - "$VIEW" "$PAGE" "$PAGE_SIZE" <<'PY' +import json +import sys + +path, page_text, size_text = sys.argv[1:4] +page, page_size = int(page_text), int(size_text) +with open(path, encoding="utf-8") as handle: + rows = json.load(handle).get("sessions", []) +pages = max(1, (len(rows) + page_size - 1) // page_size) +page = min(max(page, 1), pages) +first = (page - 1) * page_size +for row in rows[first : first + page_size]: + number = row.get("terminal_number") + if isinstance(number, int) and not isinstance(number, bool) and number > 0: + print(number) +PY +} + +# The terminal number of the top row of the page as it is currently sliced: +# the row Enter opens and the number the footer names. One source for both, so +# the promise and the action can never disagree. Empty view: status 1. +first_number_on_page() { + python3 - "$VIEW" "$PAGE" "$PAGE_SIZE" <<'PY' +import json +import sys + +path, page_text, size_text = sys.argv[1:4] +page, page_size = int(page_text), int(size_text) +with open(path, encoding="utf-8") as handle: + data = json.load(handle) +rows = data.get("sessions", []) +if not rows: + raise SystemExit(1) +pages = max(1, (len(rows) + page_size - 1) // page_size) +page = min(max(page, 1), pages) +number = rows[(page - 1) * page_size].get("terminal_number") +if not isinstance(number, int): + raise SystemExit(1) +print(number) +PY +} + +number_on_page() { + local number=$1 + [[ $number =~ ^[0-9]+$ ]] || return 1 + python3 - "$VIEW" "$PAGE" "$PAGE_SIZE" "$number" <<'PY' +import json +import sys + +path, page_text, size_text, wanted_text = sys.argv[1:5] +page, page_size = int(page_text), int(size_text) +wanted = int(wanted_text) +with open(path, encoding="utf-8") as handle: + data = json.load(handle) +rows = data.get("sessions", []) +item_count = len(rows) +pages = max(1, (item_count + page_size - 1) // page_size) +page = min(max(page, 1), pages) +first = (page - 1) * page_size +last = first + page_size +selected = rows[first:min(last, len(rows))] +matches = [ + row + for row in selected + if row.get("terminal_number") == wanted +] +raise SystemExit(0 if len(matches) == 1 else 1) +PY +} + +view_is_live() { + python3 - "$VIEW" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + data = json.load(handle) +raise SystemExit( + 0 + if data.get("source") == "live" and data.get("stale") is False + else 1 +) +PY +} + +require_live_actions() { + view_is_live || { + echo " Showing a cached list. Actions are off until it refreshes." + echo " Nothing changed." + return 1 + } +} diff --git a/lib/sh/sp_commands.sh b/lib/sh/sp_commands.sh new file mode 100644 index 0000000..520fd09 --- /dev/null +++ b/lib/sh/sp_commands.sh @@ -0,0 +1,1747 @@ +#!/usr/bin/env bash +# sp command implementations the dispatcher in bin/sp calls by name: go, +# takeover, close, prune, history, color, name, find, health, recover, and +# verify-start. Source this file; do not execute it. +# +# Source order: bin/sp sources this module ahead of its first trap, after +# SCRIPT_DIR, session_kit_common, and INVENTORY_CORE are in place. These +# functions call the snapshot, attach, and target helpers in lib/sh/sp_core.sh, +# the session builders in lib/sh/sp_sessions.sh, and reach the reaper through +# $SCRIPT_DIR. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +go_target() { + local selector=$1 + resolve_target "$selector" || return $? + sk_require_mutation_target || return 1 + local id=$SK_ID started=$SK_STARTED provider=$SK_PROVIDER uuid=$SK_UUID cwd=$SK_CWD + local shell_pid=$SK_SHELL_PID shell_start=$SK_SHELL_START + local provider_pid=$SK_PROVIDER_PID provider_start=$SK_PROVIDER_START + local daemon_pid=$SK_DAEMON_PID daemon_start=$SK_DAEMON_START + if [[ $SK_STATUS != disconnected && $SK_STATUS != Disconnected ]]; then + sk_die "$(sk_human_label "$SK_TITLE" "$SK_PROVIDER" "${SK_NUMBER:-}") is open elsewhere; 'sp takeover ${SK_NUMBER:-<session>}' moves it here." + return 1 + fi + sk_revalidate "$id" "$started" "$provider" "$uuid" "$shell_pid" "$shell_start" "$provider_pid" "$provider_start" "$daemon_pid" "$daemon_start" || { + sk_die "target changed before attach; no action taken" + return 1 + } + attach_id "$id" "$cwd" +} + +takeover_target() { + local selector=$1 + resolve_target "$selector" || return $? + sk_require_mutation_target || return 1 + local id=$SK_ID started=$SK_STARTED provider=$SK_PROVIDER uuid=$SK_UUID title=$SK_TITLE cwd=$SK_CWD + local shell_pid=$SK_SHELL_PID shell_start=$SK_SHELL_START + local provider_pid=$SK_PROVIDER_PID provider_start=$SK_PROVIDER_START + local daemon_pid=$SK_DAEMON_PID daemon_start=$SK_DAEMON_START + if [[ $SK_STATUS == disconnected || $SK_STATUS == Disconnected ]]; then + sk_die "$(sk_human_label "$title" "$provider" "${SK_NUMBER:-}") is ready, not open elsewhere; 'sp go ${SK_NUMBER:-<session>}' opens it here." + return 1 + fi + sk_confirm_exact "Taking over" "$id" "$title" "$provider" \ + "${SK_NUMBER:-}" || { + echo "Nothing changed." + return 1 + } + sk_revalidate "$id" "$started" "$provider" "$uuid" "$shell_pid" "$shell_start" "$provider_pid" "$provider_start" "$daemon_pid" "$daemon_start" || { + sk_die "target changed while confirming; no action taken" + return 1 + } + attach_id "$id" "$cwd" 1 +} + +# What model a session's provider is on, read from the row the list already +# shows. Empty when the session runs on the provider's own default, which is +# not something any surface here may invent. +sk_session_model() { + local snapshot_path=$1 id=$2 + python3 - "$snapshot_path" "$id" <<'MODEL_PY' 2>/dev/null +import json, sys +try: + data = json.load(open(sys.argv[1], encoding="utf-8")) +except (OSError, ValueError): + raise SystemExit(1) +for row in data.get("sessions", []): + if isinstance(row, dict) and row.get("shpool_id_raw") == sys.argv[2]: + print(row.get("model") or "") + raise SystemExit(0) +raise SystemExit(1) +MODEL_PY +} + +# The STAMP on a session, for the paths that close it and open it again. It +# reads `origin_recorded`, never `origin`: `origin` is what the row IS, and +# for an unstamped session that is whatever collection inferred about who held +# a socket at that instant. Feeding an inference back through +# SESSION_KIT_ORIGIN turns it into a permanent stamp that no later refresh can +# take back -- and a repair runs precisely when a window could not be opened, +# the state most likely to read "no window". So only a real stamp is carried; +# an unstamped session prints nothing and its caller declares nothing, which +# leaves the restore to read the ledger as it would for any other restore. +sk_session_origin() { + local snapshot_path=$1 id=$2 + python3 - "$snapshot_path" "$id" <<'ORIGIN_PY' 2>/dev/null +import json, sys +try: + data = json.load(open(sys.argv[1], encoding="utf-8")) +except (OSError, ValueError): + raise SystemExit(1) +for row in data.get("sessions", []): + if isinstance(row, dict) and row.get("shpool_id_raw") == sys.argv[2]: + value = row.get("origin_recorded") + if value in {"human", "machine"}: + print(value) + raise SystemExit(0) + raise SystemExit(1) +raise SystemExit(1) +ORIGIN_PY +} + +# One deliberate close, recorded once. A conversation gets its closed-list +# entry (so a person can bring it back) and its tombstone (so the crash queue +# never offers it back as lost work) from the same call; a plain shell has no +# conversation and gets the closed-list entry alone. The close already +# happened, so a record that cannot be written costs a diagnostic, never the +# close -- but the diagnostic has to say where the conversation actually IS, +# because that is the whole reason the person is reading it. +sk_record_close() { + local id=$1 provider=$2 uuid=$3 + if [[ ($provider == claude || $provider == codex) && -n $uuid ]]; then + python3 "$INVENTORY_CORE" close-intent record "$provider" "$uuid" \ + >/dev/null 2>&1 && return 0 + # The verb writes the findable row first and refuses the tombstone if it + # did not land. The provider transcript survives, but the fused recovery + # command also validates the broken Closed ledger and may refuse its whole + # list. Name that boundary instead of promising an offer it cannot print. + sk_die "the session closed, but it could not be added to Closed sessions, the provider conversation remains on disk, but repair the Closed sessions ledger before relying on \`sp recover\`" || true + return 0 + fi + python3 "$INVENTORY_CORE" closed-sessions record shell --session "$id" \ + >/dev/null 2>&1 && return 0 + # A plain shell has no conversation to lose, only its history line. Still + # said out loud: swallowing it made `sp recover` quietly incomplete. + sk_die "the session closed, but it could not be added to Closed sessions" || true + return 0 +} + +close_target() { + local selector=$1 + resolve_target "$selector" || return $? + sk_require_mutation_target || return 1 + local id=$SK_ID started=$SK_STARTED provider=$SK_PROVIDER uuid=$SK_UUID title=$SK_TITLE + local number=${SK_NUMBER:-} + local shell_pid=$SK_SHELL_PID shell_start=$SK_SHELL_START + local provider_pid=$SK_PROVIDER_PID provider_start=$SK_PROVIDER_START + local daemon_pid=$SK_DAEMON_PID daemon_start=$SK_DAEMON_START + sk_confirm_exact "Closing" "$id" "$title" "$provider" "$number" || { + echo "Nothing changed." + return 1 + } + exec 9>"$SK_STATE_DIR/create.lock" || return 1 + sk_lock_acquire 9 "$SK_STATE_DIR/create.lock" || { + sk_lock_release 9 + return 1 + } + sk_revalidate "$id" "$started" "$provider" "$uuid" "$shell_pid" "$shell_start" "$provider_pid" "$provider_start" "$daemon_pid" "$daemon_start" || { + sk_lock_release 9 + sk_die "target changed while confirming; no action taken" + return 1 + } + local shell_outcome manager_cleanup=0 + if ! shell_outcome=$(sk_terminate_exact_shell \ + "$daemon_pid" "$daemon_start" "$shell_pid" "$shell_start" 2>&1); then + sk_cleanup_dead_shpool_entry "$id" "$daemon_pid" "$daemon_start" \ + "$shell_pid" "$shell_start" "$started" || true + sk_lock_release 9 + sk_die "the exact session shell was not terminated ($shell_outcome)" + return 1 + fi + sk_cleanup_dead_shpool_entry "$id" "$daemon_pid" "$daemon_start" \ + "$shell_pid" "$shell_start" "$started" || manager_cleanup=$? + sk_lock_release 9 + # Same guarantee as the picker path: nothing pinned to this session's + # exact identity may outlive the close. + sk_reap_session_leftovers \ + "${provider_pid:-}" "${provider_start:-}" \ + "${shell_pid:-}" "${shell_start:-}" + # And the same record of intent. `sp close` is a person's explicit verb + # exactly as `k` is, so the conversation it ends must not come back as an + # unclaimed crash. The close already happened: a store that cannot be + # written costs a warning, never the close. + sk_record_close "$id" "$provider" "${uuid:-}" + # The copy of the code a delegated session was given goes back on the close + # itself, rather than waiting for somebody to remember `sp teardown`. + sk_release_worktree "$id" + if (( manager_cleanup != 0 )); then + sk_die "the exact shell closed, but manager entry $id remains; Session Kit could not finish the close" + return 1 + fi + if [[ -e $SK_START_DIR/$id || -L $SK_START_DIR/$id || + -e $SK_START_DIR/$id.expected || -L $SK_START_DIR/$id.expected ]]; then + if ! sk_quarantine_start_record "$id" closed >/dev/null; then + sk_die "the session was closed, but its retained launch record could not be archived" + return 1 + fi + fi + printf 'Closed %s.\n' "$(sk_human_label "${title:-}" "${provider:-}" "${number:-}")" +} + +# Move one conversation to another model. The provider is restarted on the +# model asked for and resumes the exact same conversation, so nothing about +# the work is lost -- but a restart in the middle of a turn would be, which is +# why this refuses on anything but a proven idle session. The proof is the one +# the account switch already uses: a single stable row, a conversation that is +# not mid-computation, no subagents, and a process tree with nothing +# unrecognized in it. Nothing is ever asked (D7); automation names the exact +# session, exactly as it does to close one. +change_model_target() { + local selector=$1 requested=$2 anyway=${3:-} + [[ -n $requested ]] || { + sk_die "change-model needs a session and a model" + return 2 + } + local model_anyway=0 + [[ $anyway != --model-anyway ]] || model_anyway=1 + resolve_target "$selector" || return $? + sk_require_mutation_target || return 1 + [[ $SK_PROVIDER == claude || $SK_PROVIDER == codex ]] || { + sk_die "only a Claude or Codex session runs on a model" + return 1 + } + local id=$SK_ID started=$SK_STARTED provider=$SK_PROVIDER uuid=$SK_UUID + local cwd=$SK_CWD title=$SK_TITLE number=${SK_NUMBER:-} + local account_alias=${SK_ACCOUNT_ALIAS:-} + local shell_pid=$SK_SHELL_PID shell_start=$SK_SHELL_START + local provider_pid=$SK_PROVIDER_PID provider_start=$SK_PROVIDER_START + local daemon_pid=$SK_DAEMON_PID daemon_start=$SK_DAEMON_START + [[ -n $uuid ]] || { + sk_die "that session has no conversation to carry to another model" + return 1 + } + local model + model=$(python3 "$INVENTORY_CORE" validate-worker-model \ + "$provider" "$requested" 2>/dev/null) || { + sk_die "unsupported or unsafe $provider model identifier" + return 2 + } + [[ $model == "$requested" ]] || { + sk_die "model validation did not preserve the identifier" + return 2 + } + # The same question `sp new` asks: does this machine actually answer that + # request with that model? A move onto a model that is really served by + # something else is a downgrade nobody sees, so it is refused by name and + # the conversation stays exactly where it is. + if (( model_anyway == 0 )); then + local availability_message= availability_status=0 + availability_message=$(python3 "$INVENTORY_CORE" model-availability \ + "$provider" "$model" --flag=--model-anyway 2>&1 >/dev/null) || + availability_status=$? + if (( availability_status == 3 )); then + [[ -z $availability_message ]] || printf '%s\n' "$availability_message" >&2 + sk_die "nothing changed" + return 2 + fi + if (( availability_status != 0 )); then + # Say what the check itself said before replacing it with a summary: a + # broken gate must not look like a missing one. + [[ -z $availability_message ]] || printf '%s\n' "$availability_message" >&2 + printf 'session-kit: could not check whether this machine serves %s; moving to it as asked.\n' \ + "$model" >&2 + elif [[ -n $availability_message && -t 2 ]]; then + # "Nothing here can confirm that model" is not a refusal and never stops + # the move. A person is told at the moment they asked; automation asks + # `session-kit model-availability` when it wants the verdict. + printf '%s\n' "$availability_message" >&2 + fi + fi + local current + current=$(sk_session_model "$SNAPSHOT" "$id") || current="" + local origin + # Same rule as the recovery path: only a real stamp is carried forward. An + # unstamped session declares nothing, and the restore reads the ledger. + origin=$(sk_session_origin "$SNAPSHOT" "$id") || origin= + # ``current`` is only the model recorded on the conversation's last reply. + # It cannot prove what the live process is running after an in-session + # model command. An explicit correction therefore follows the normal proof + # and restart path even when the last reply happens to name the requested + # model; silently accepting stale evidence would decline the person's act. + account_switch_stable_snapshot "$SNAPSHOT" "$id" "$uuid" "$provider" || { + sk_die "that session is working, so its model was not changed; try again when it is idle" + return 1 + } + account_switch_safe_tree "$provider" "$shell_pid" "$shell_start" \ + "$provider_pid" "$provider_start" || { + sk_die "something the kit does not recognize is running in that session, so its model was not changed" + return 1 + } + sk_confirm_exact "Moving" "$id" "$title" "$provider" "$number" || { + echo "Nothing changed." + return 1 + } + # Time passes between the first safety proof and this destructive step; a + # new turn can begin in that interval. Recollect once and prove exact + # identity, idle state, and the recognized process tree again from that same + # fresh snapshot. + exec 9>"$SK_STATE_DIR/create.lock" || return 1 + sk_lock_acquire 9 "$SK_STATE_DIR/create.lock" || { + sk_lock_release 9 + return 1 + } + make_guard_snapshot || { + sk_lock_release 9 + sk_die "guard live inventory unavailable; nothing changed" + return 1 + } + sk_snapshot_matches_exact "$SNAPSHOT" \ + "$id" "$started" "$provider" "$uuid" "$shell_pid" "$shell_start" \ + "$provider_pid" "$provider_start" "$daemon_pid" "$daemon_start" || { + sk_lock_release 9 + sk_die "session changed while preparing the model change; nothing changed" + return 1 + } + account_switch_stable_snapshot "$SNAPSHOT" "$id" "$uuid" "$provider" || { + sk_lock_release 9 + sk_die "that session started working while its model was being chosen, so nothing changed" + return 1 + } + account_switch_safe_tree "$provider" "$SK_SHELL_PID" "$SK_SHELL_START" \ + "$SK_PROVIDER_PID" "$SK_PROVIDER_START" || { + sk_lock_release 9 + sk_die "something the kit does not recognize started in that session, so its model was not changed" + return 1 + } + local shell_outcome + if ! shell_outcome=$(sk_terminate_exact_shell \ + "$daemon_pid" "$daemon_start" "$shell_pid" "$shell_start" 2>&1); then + sk_cleanup_dead_shpool_entry "$id" "$daemon_pid" "$daemon_start" \ + "$shell_pid" "$shell_start" "$started" || true + sk_lock_release 9 + sk_die "the exact session shell could not be stopped; nothing changed ($shell_outcome)" + return 1 + fi + sk_cleanup_dead_shpool_entry "$id" "$daemon_pid" "$daemon_start" \ + "$shell_pid" "$shell_start" "$started" || { + sk_lock_release 9 + return 1 + } + sk_lock_release 9 + sk_reap_session_leftovers \ + "${provider_pid:-}" "${provider_start:-}" \ + "${shell_pid:-}" "${shell_start:-}" + # The old session ended on purpose, so the crash queue must never offer its + # conversation back as lost work. The restore below takes the same + # conversation out of the closed list again. + sk_record_close "$id" "$provider" "$uuid" + if ! SESSION_KIT_ORIGIN=$origin \ + restore_exact "$provider" "$uuid" "$cwd" "$account_alias" "$model" >/dev/null; then + sk_die "the session closed, but it could not be reopened on $model. Its conversation is intact; restore it from the picker" + return 1 + fi + printf 'Restored %s on %s.\n' \ + "$(sk_human_label "${title:-}" "$provider")" "$model" +} + +# One top-level string from a JSON object, stripped of anything that is not +# printable. The verdict's sentence is printed to a terminal and handed to the +# operator's notifier, so a feed that ever carried an escape sequence must not +# be able to paint either one. +sk_json_field() { + local payload=$1 key=$2 + printf '%s' "$payload" | python3 -c ' +import json +import sys + +try: + value = json.load(sys.stdin) +except ValueError: + raise SystemExit(1) +if not isinstance(value, dict): + raise SystemExit(1) +text = value.get(sys.argv[1]) +if text is None: + raise SystemExit(1) +text = "".join(c if c.isprintable() else " " for c in str(text)) +print(" ".join(text.split())[:240]) +' "$key" +} + +# Carry one conversation off an account that has run dry, without being asked. +# +# The decision is not made here. `account auto-plan` reads the usage feed, the +# reserve, the one-move limit and the kill switch and answers switch or hold; +# this function performs a "switch" answer and nothing else. Keeping the two +# apart is what makes the rule testable without ever moving a real account. +# +# Every safety proof below is the manual switch's own, called by name: +# account_switch_stable_snapshot, account_switch_safe_tree, +# account_switch_request, account_switch_signal, account_switch_wait_alias and +# account_switch_restore_source. The automatic path can therefore never be +# laxer than the path a human drives -- it can only be refused earlier. +# +# Only a session whose provider can reload a profile in place is ever moved. +# The legacy path recreates the shell and asks the operator to reopen the same +# number in the old window; performing that while nobody is watching would +# leave a moved conversation behind an instruction no one read. +account_auto_switch_target() { + # Judging is the default; acting needs `--apply`. This verb moves a live + # conversation between paid subscriptions, and it is reachable from any + # shell in this checkout, so the form that costs money must be the one + # somebody typed on purpose rather than the one they get by accident. + local selector=$1 dry_run=1 + [[ ${2:-} != --apply ]] || dry_run= + resolve_target "$selector" || return $? + sk_require_mutation_target || return 1 + [[ $SK_PROVIDER == claude || $SK_PROVIDER == codex ]] || { + sk_die "only a Claude or Codex conversation runs on an account" + return 1 + } + local id=$SK_ID started=$SK_STARTED provider=$SK_PROVIDER uuid=$SK_UUID + local cwd=$SK_CWD title=$SK_TITLE number=${SK_NUMBER:-} + local capable=${SK_ACCOUNT_CAPABLE:-} + local model=${SK_MODEL:-} + local shell_pid=$SK_SHELL_PID shell_start=$SK_SHELL_START + local provider_pid=$SK_PROVIDER_PID provider_start=$SK_PROVIDER_START + local daemon_pid=$SK_DAEMON_PID daemon_start=$SK_DAEMON_START + [[ -n $uuid ]] || { + sk_die "that session has no conversation to carry to another account" + return 1 + } + + local verdict action reason source_alias target_alias + verdict=$(python3 "$INVENTORY_CORE" account auto-plan "$provider" "$uuid" \ + --snapshot "$SNAPSHOT" --shpool-id "$id" 2>/dev/null) || { + sk_die "the account handoff rule could not be evaluated; nothing changed" + return 1 + } + action=$(sk_json_field "$verdict" action) || action="" + reason=$(sk_json_field "$verdict" reason) || reason="" + source_alias=$(sk_json_field "$verdict" source_alias) || source_alias="" + target_alias=$(sk_json_field "$verdict" target_alias) || target_alias="" + if [[ $action != switch ]]; then + printf '%s\n' "${reason:-No account change was called for.}" + return 0 + fi + [[ $target_alias =~ ^[a-z][a-z0-9_-]{0,11}$ && + $source_alias =~ ^[a-z][a-z0-9_-]{0,11}$ && + $source_alias != "$target_alias" ]] || { + sk_die "the account handoff named an unusable account; nothing changed" + return 1 + } + if [[ $capable != true ]]; then + printf 'This conversation is on %s, which is spent, but its window cannot change account on its own. Change it from the picker when you are ready.\n' \ + "$source_alias" + return 0 + fi + if [[ -n $dry_run ]]; then + printf 'Would move %s from %s to %s. %s\n' \ + "$(sk_human_label "${title:-}" "$provider" "$number")" \ + "$source_alias" "$target_alias" "$reason" + return 0 + fi + + account_switch_stable_snapshot "$SNAPSHOT" "$id" "$uuid" "$provider" \ + "$source_alias" || { + sk_die "the conversation is working; its account was not changed" + return 1 + } + account_switch_safe_tree "$provider" "$shell_pid" "$shell_start" \ + "$provider_pid" "$provider_start" || { + sk_die "something the kit does not recognize is running in that session; its account was not changed" + return 1 + } + local switch_error + switch_error=$(mktemp) || return 1 + python3 "$INVENTORY_CORE" account launch-profile "$provider" "$target_alias" \ + >/dev/null 2>"$switch_error" || { + sk_die "$(grep -m1 . "$switch_error" 2>/dev/null || + echo "the target account could not be prepared; nothing changed")" + command rm -f -- "$switch_error" + return 1 + } + command rm -f -- "$switch_error" + + picker_lock || { + sk_die "another session action holds the lock; nothing changed" + return 1 + } + # Time passed between the first proof and this destructive step, and a new + # turn can begin in that interval. Prove exact identity, idle state and the + # recognized process tree again from one fresh snapshot. + if ! make_guard_snapshot || + ! sk_snapshot_matches_exact "$SNAPSHOT" \ + "$id" "$started" "$provider" "$uuid" "$shell_pid" "$shell_start" \ + "$provider_pid" "$provider_start" "$daemon_pid" "$daemon_start" || + ! account_switch_stable_snapshot "$SNAPSHOT" "$id" "$uuid" "$provider" \ + "$source_alias" || + ! account_switch_safe_tree "$provider" "$shell_pid" "$shell_start" \ + "$provider_pid" "$provider_start"; then + sk_lock_release 9 + sk_die "the conversation changed while its account handoff was prepared; nothing changed" + return 1 + fi + # Preparing the target profile above was a mutating call that ran a provider + # binary and can take seconds. Ask once more, read-only, whether that account + # may still be used: the operator can switch an account off at any moment, + # including inside that window, and their answer outranks the plan's. + local recheck + recheck=$(python3 "$INVENTORY_CORE" account auto-target-ok \ + "$provider" "$target_alias" 2>/dev/null) || { + sk_lock_release 9 + sk_die "$target_alias is no longer available: $(sk_json_field "${recheck:-}" reason || + echo "it is not selectable"); nothing changed" + return 1 + } + local prepared txid + prepared=$(python3 "$INVENTORY_CORE" account switch-prepare \ + "$provider" "$uuid" "$source_alias" "$target_alias" "$cwd" "$id" 2>/dev/null) || { + sk_lock_release 9 + sk_die "the account checkpoint could not be prepared; nothing changed" + return 1 + } + txid=$(sk_json_field "$prepared" txid) || txid= + [[ $txid =~ ^[0-9a-f]{32}$ ]] || { + sk_lock_release 9 + sk_die "the account checkpoint identity was invalid; nothing changed" + return 1 + } + # Reserve the one move BEFORE anything irreversible. Recording it afterwards + # meant a failure between the account commit and the ledger write left a + # conversation that had moved with a record saying it never had -- and the + # next pass then bought it a third account. From here the move is counted + # whatever happens; only a proven return to the source gives it back. + local hop_token + hop_token=$(python3 "$INVENTORY_CORE" account auto-begin "$provider" "$uuid" \ + "$source_alias" "$target_alias" --reason "$reason" 2>/dev/null) || hop_token= + [[ $hop_token =~ ^[0-9a-f]{32}$ ]] || { + sk_lock_release 9 + python3 "$INVENTORY_CORE" account switch-rollback "$txid" >/dev/null 2>&1 || true + sk_die "this move could not be counted against the one-move limit, so it was not made" + return 1 + } + if ! account_switch_request "$id" apply "$txid" "$target_alias" "$source_alias" \ + "$uuid" "$started" "$shell_pid" "$shell_start" "$model" "$provider"; then + sk_lock_release 9 + python3 "$INVENTORY_CORE" account switch-rollback "$txid" >/dev/null 2>&1 || true + python3 "$INVENTORY_CORE" account auto-release "$provider" "$uuid" \ + "$hop_token" >/dev/null 2>&1 || true + sk_die "the account handoff request could not be written; nothing changed" + return 1 + fi + if ! account_switch_signal "$provider" "$provider_pid" "$provider_start" \ + "$shell_pid" "$shell_start"; then + command rm -f -- "$SK_STATE_DIR/account-switch-requests/$id" + sk_lock_release 9 + python3 "$INVENTORY_CORE" account switch-rollback "$txid" >/dev/null 2>&1 || true + # Nothing was signalled, so the conversation never left its account. + python3 "$INVENTORY_CORE" account auto-release "$provider" "$uuid" \ + "$hop_token" >/dev/null 2>&1 || true + sk_die "the exact idle provider changed before handoff; nothing changed" + return 1 + fi + sk_lock_release 9 + if account_switch_wait_alias "$id" "$provider" "$uuid" "$target_alias" && + python3 "$INVENTORY_CORE" account sync-ui "$provider" "$uuid" \ + "$target_alias" >/dev/null && + python3 "$INVENTORY_CORE" account switch-commit "$txid" >/dev/null; then + sk_log_action account_auto_switch committed || true + python3 "$INVENTORY_CORE" account auto-commit "$provider" "$uuid" \ + "$hop_token" >/dev/null 2>&1 || true + # A machine-readable receipt for the periodic driver, which needs the + # exact identity to record what the operator is owed. Off unless asked + # for, so a person running this by hand sees one sentence. + [[ ${SESSION_KIT_AUTO_SWITCH_MARKER:-0} == 1 ]] && + printf 'SESSION_KIT_AUTO_SWITCH\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$id" "$provider" "$uuid" "$source_alias" "$target_alias" "$hop_token" >&2 + printf '%s moved from %s to %s. %s The same conversation is still open.\n' \ + "$(sk_human_label "${title:-}" "$provider" "$number")" \ + "$source_alias" "$target_alias" "$reason" + return 0 + fi + sk_log_action account_auto_switch rolled_back || true + # The target did not prove itself. Put the conversation back WITHOUT the + # destructive last resort: `account_switch_restore_source` ends by killing + # the session and re-creating it, and a conversation that has to be recovered + # is not a conversation left working. Unattended, the answer to a failed hop + # is the non-destructive rollback the managed shell is already waiting for, + # and a loud refusal if even that cannot be proven. + if account_switch_restore_source "$id" "$provider" "$uuid" "$source_alias" \ + "$target_alias" "$txid" "$cwd" "$model" --no-recreate; then + python3 "$INVENTORY_CORE" account auto-release "$provider" "$uuid" \ + "$hop_token" >/dev/null 2>&1 || true + sk_die "the target account did not prove itself; $source_alias was restored and the conversation is still open" + else + # The reservation stands: where the outcome is unproven, refusing a second + # automatic move costs a manual switch, granting one costs an account. + sk_die "the target account did not prove itself and $source_alias could not be confirmed; the conversation was left exactly as it is and no further automatic move will be made" + fi + return 1 +} + +teardown_target() { + # Close one delegated worker and prune the worktree it ran in -- the pair + # that leaves a merged workstream with nothing of its own left behind. The + # directory goes only when git agrees the branch is merged and nothing is + # uncommitted; the branch and its commits are never deleted here. + local selector= merged_into=HEAD force= + while (($#)); do + case "$1" in + --merged-into) + [[ -n ${2:-} ]] || { sk_die "--merged-into requires one git ref"; return 2; } + merged_into=$2 + shift 2 + ;; + --force) + force=1 + shift + ;; + --*) sk_die "unknown teardown option: $1"; return 2 ;; + *) + [[ -z $selector ]] || { sk_die "teardown takes one session"; return 2; } + selector=$1 + shift + ;; + esac + done + [[ -n $selector ]] || { sk_die "teardown needs one session"; return 2; } + resolve_target "$selector" || return $? + local cwd=$SK_CWD record= branch= + record=$(python3 "$INVENTORY_CORE" worktree lookup --path "$cwd") || { + sk_die "that session does not run in a Session Kit worktree; use 'sp close ${SK_NUMBER:-<session>}'" + return 1 + } + branch=$(printf '%s' "$record" | python3 -c ' +import json,sys +print(json.load(sys.stdin).get("branch") or "") +') || return 1 + [[ -n $branch ]] || { + sk_die "the worktree record for $cwd names no branch; nothing was closed" + return 1 + } + close_target "$selector" || return 1 + # The close releases a copy the kit cut itself, so by here there may be + # nothing left to prune. That is the wanted outcome, not a failure. + if ! python3 "$INVENTORY_CORE" worktree lookup --path "$cwd" >/dev/null 2>&1; then + printf 'Closed the worker and pruned the %s worktree at %s.\n' "$branch" "$cwd" + return 0 + fi + local -a teardown_argv=(python3 "$INVENTORY_CORE" worktree teardown + --path "$cwd" --merged-into "$merged_into") + [[ -z $force ]] || teardown_argv+=(--force) + "${teardown_argv[@]}" >/dev/null || { + sk_die "the worker is closed; its $branch worktree at $cwd was kept for the reason above" + return 1 + } + printf 'Closed the worker and pruned the %s worktree at %s.\n' "$branch" "$cwd" +} + +show_prune() { + local candidate_file="$SK_STATE_DIR/prune-candidates.json" + "$SCRIPT_DIR/shpool_reaper" --candidates >/dev/null || return 1 + # The nomination window is whatever SESSION_KIT_PRUNE_DAYS said when the + # list was built, and this command inherits that environment. Print the + # window the list was actually built with, never the default it may not be. + local days=7 + if [[ -s $candidate_file ]]; then + days=$(python3 -c 'import json,sys; value=json.load(open(sys.argv[1])).get("max_age_days"); print(value if isinstance(value,int) and not isinstance(value,bool) and value >= 1 else 7)' "$candidate_file" 2>/dev/null) || days=7 + fi + [[ $days =~ ^[0-9]+$ && $days -ge 1 ]] || days=7 + local window_phrase="$days days" + if [[ $days == 1 ]]; then + window_phrase="1 day" + fi + if [[ ! -s $candidate_file ]]; then + echo "Nothing changed." + return 0 + fi + local count + count=$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("candidates",[])))' "$candidate_file") + if (( count == 0 )); then + echo "Nothing changed." + return 0 + fi + printf 'Idle and empty for %s:\n' "$window_phrase" + if ! python3 - "$candidate_file" <<'PY' +import json,re,sys +rows=json.load(open(sys.argv[1])).get("candidates",[]) +pattern=re.compile(r"(?:main(?:[1-9][0-9]*)?|s[0-9]{8}-[0-9]{6}-[1-9][0-9]*(?:-[1-9][0-9]*)?)") +for row in rows: + value=row.get("shpool_id") if isinstance(row,dict) else None + if not isinstance(value,str) or len(value.encode())>128 or pattern.fullmatch(value) is None: + raise SystemExit(2) +for row in rows: + # The IDs above were validated, not displayed: this list names what is + # about to close, and a person never types one of these. + title = " ".join(str(row.get("title") or "").split()) or "empty shell" + title = "".join(character for character in title if character.isprintable()) + print(f" {title[:60]}") +PY + then + sk_die "candidate list contains an unmanaged session ID; nothing was displayed or pruned" + return 1 + fi + local index private_candidate + for (( index=0; index<count; index++ )); do + private_candidate=$(mktemp "$SK_STATE_DIR/prune-candidate.json.XXXXXX") || return 1 + if ! python3 - "$candidate_file" "$index" > "$private_candidate" <<'PY' +import json,re,sys +rows=json.load(open(sys.argv[1])).get("candidates",[]) +row=rows[int(sys.argv[2])] +required=("shpool_id","started_at_unix_ms","last_disconnected_at_unix_ms", + "shell_pid","shell_start_ticks") +if not isinstance(row,dict) or any(key not in row for key in required): + raise SystemExit(2) +session_id=row["shpool_id"] +if not isinstance(session_id,str) or len(session_id.encode())>128: + raise SystemExit(2) +if not re.fullmatch(r"(?:main(?:[1-9][0-9]*)?|s[0-9]{8}-[0-9]{6}-[1-9][0-9]*(?:-[1-9][0-9]*)?)",session_id): + raise SystemExit(2) +if not all(isinstance(row[key],int) for key in required[1:]): + raise SystemExit(2) +json.dump({key:row[key] for key in required},sys.stdout,indent=2,sort_keys=True) +sys.stdout.write("\n") +PY + then + command rm -- "$private_candidate" + sk_die "invalid or unmanaged prune candidate; no action taken" + return 1 + fi + chmod 600 "$private_candidate" 2>/dev/null || true + if ! prune_target "$private_candidate"; then + command rm -- "$private_candidate" + return 1 + fi + command rm -- "$private_candidate" + done +} + +prune_target() { + local candidate_file=$1 id + id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["shpool_id"])' "$candidate_file") || return 1 + make_guard_snapshot || return 1 + sk_resolve "$SNAPSHOT" "$id" || { + sk_die "a prune candidate no longer exists; nothing was pruned" + return 1 + } + sk_require_mutation_target || return 1 + # The candidate's empty-tree proof and this mutation guard must describe + # the same shell generation. The guard document is socket-bound; accepting + # a namesake shell proved under any other daemon would send the following + # client kill to the wrong session manager. + local candidate_shell_pid candidate_shell_start + read -r candidate_shell_pid candidate_shell_start < <( + python3 - "$candidate_file" <<'PY' +import json, sys +value = json.load(open(sys.argv[1], encoding="utf-8")) +pid = value.get("shell_pid") if isinstance(value, dict) else None +start = value.get("shell_start_ticks") if isinstance(value, dict) else None +if ( + not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0 + or not isinstance(start, int) or isinstance(start, bool) or start < 0 +): + raise SystemExit(2) +print(pid, start) +PY + ) || { + sk_die "prune candidate changed or is no longer empty; no action taken" + return 1 + } + if [[ $candidate_shell_pid != "$SK_SHELL_PID" || + $candidate_shell_start != "$SK_SHELL_START" ]]; then + sk_die "prune candidate changed or is no longer empty; no action taken" + return 1 + fi + local title=$SK_TITLE provider=$SK_PROVIDER number=${SK_NUMBER:-} + local candidate_started=$SK_STARTED + local bound_daemon_pid=$SK_DAEMON_PID bound_daemon_start=$SK_DAEMON_START + # Nothing is asked. `sp prune` closes a list, so the scripted half of the + # contract applies one candidate at a time: automation must still name the + # exact session it is acting on, and a run that names none closes none. + if [[ ${SESSION_KIT_NONINTERACTIVE:-0} == 1 || ! -t 0 ]] && + [[ ${SESSION_KIT_CONFIRM_ID:-} != "$id" ]]; then + echo "Nothing changed." + return 1 + fi + + exec 9>"$SK_STATE_DIR/create.lock" || return 1 + sk_lock_acquire 9 "$SK_STATE_DIR/create.lock" || { exec 9>&-; return 1; } + # This is the final command before exact shell termination. It proves the + # raw manager generation, disconnected status, stable shell PID/start, and + # an empty process tree using two fresh snapshots. Any ambiguity fails closed. + "$SCRIPT_DIR/shpool_reaper" --verify-candidate \ + "$candidate_file" "$SNAPSHOT" >/dev/null || { + sk_lock_release 9 + sk_die "prune candidate changed or is no longer empty; no action taken" + return 1 + } + if ! sk_shpool_holder_is "$bound_daemon_pid" "$bound_daemon_start"; then + sk_lock_release 9 + sk_die "socket holder changed before closing $id; no kill was sent and nothing was pruned" + return 1 + fi + local shell_outcome + if ! shell_outcome=$(sk_terminate_exact_shell \ + "$bound_daemon_pid" "$bound_daemon_start" \ + "$candidate_shell_pid" "$candidate_shell_start" 2>&1); then + sk_cleanup_dead_shpool_entry "$id" \ + "$bound_daemon_pid" "$bound_daemon_start" \ + "$candidate_shell_pid" "$candidate_shell_start" "$candidate_started" || true + sk_lock_release 9 + sk_die "verified shell PID $candidate_shell_pid start $candidate_shell_start survives or could not be terminated ($shell_outcome); nothing was reported closed" + return 1 + fi + sk_cleanup_dead_shpool_entry "$id" \ + "$bound_daemon_pid" "$bound_daemon_start" \ + "$candidate_shell_pid" "$candidate_shell_start" "$candidate_started" || { + sk_lock_release 9 + return 1 + } + sk_lock_release 9 + # A prune is a person closing sessions, so its conversations join the closed + # list like any other close rather than vanishing unnamed. + sk_record_close "$id" "$provider" "${SK_UUID:-}" + printf 'Closed %s.\n' "$(sk_human_label "$title" "$provider" "$number")" +} + +show_history() { + local selector=$1 + resolve_target "$selector" || return $? + show_history_id "$SK_ID" +} + +sk_page_history() { + if [[ ${SESSION_KIT_NONINTERACTIVE:-0} == 1 ]]; then + command cat -- "$@" + else + command cat -- "$@" | less -R + fi +} + +# History can also be piped as exact recorded bytes. Keep explanatory copy out +# of that data stream, but retain it on stderr for redirected recall. Callers +# print it after the pager returns so it remains visible on the terminal. +sk_history_notice() { + printf '%s\n' "$1" >&2 +} + +# Put an explanation inside the pager while it is open, then repeat it on the +# terminal after the pager exits. With redirected output, recorded bytes stay +# exact on stdout and the explanation appears once on stderr. +sk_page_history_with_notice() { + local notice=$1 + shift + if [[ ${SESSION_KIT_NONINTERACTIVE:-0} == 1 || ! -t 1 ]]; then + command cat -- "$@" + else + { printf '%s\n' "$notice"; command cat -- "$@"; } | less -R + fi + sk_history_notice "$notice" +} + +# Executable overrides exist only to isolate these paths in the test harness. +# Production always executes the tools shipped in its release directory. +sk_history_tool_path() { + local variable=$1 fallback=$2 + if sk_test_hook "$variable"; then + printf '%s\n' "$SK_TEST_HOOK" + else + printf '%s\n' "$fallback" + fi +} + +# A sidecar is current only when it is readable, nonempty, and no capture that +# feeds it is more than two seconds newer. The grace period absorbs a live +# capture append that races the successful on-demand flush by milliseconds. +# This uses the files already on disk rather than depending on a background +# renderer to invent and maintain a separate failure marker. +sk_history_sidecar_stale() { + local sidecar=$1 capture sidecar_ns capture_ns + shift + [[ -r $sidecar && -s $sidecar ]] || return 0 + sidecar_ns=$(python3 -c \ + 'import os, sys; print(os.stat(sys.argv[1]).st_mtime_ns)' \ + "$sidecar" 2>/dev/null) || return 0 + [[ $sidecar_ns =~ ^[0-9]+$ ]] || return 0 + for capture in "$@"; do + [[ -e $capture ]] || continue + capture_ns=$(python3 -c \ + 'import os, sys; print(os.stat(sys.argv[1]).st_mtime_ns)' \ + "$capture" 2>/dev/null) || return 0 + [[ $capture_ns =~ ^[0-9]+$ ]] || return 0 + (( capture_ns > sidecar_ns + 2000000000 )) && return 0 + done + return 1 +} + +show_history_id() { + local id=$1 + local render_tool transcript_tool + render_tool=$(sk_history_tool_path SESSION_KIT_JOURNAL_RENDER_TOOL \ + "${INVENTORY_CORE%/*}/sessionkit_inventory/journal_render.py") + transcript_tool=$(sk_history_tool_path SESSION_KIT_TRANSCRIPT_TEXT_TOOL \ + "${INVENTORY_CORE%/*}/sessionkit_inventory/transcript_text.py") + local -a files=() + while IFS= read -r path; do [[ -n $path ]] && files+=("$path"); done < <(history_files "$id") + + if (( ${#files[@]} > 0 )); then + # Journal exists: replay it through the screen model and page the SETTLED + # text, never the raw bytes (raw TUI repaints braid when read as text; + # operator finding 2026-08-12). Incremental: only new bytes render, so a + # fresh sidecar costs milliseconds and recall stays instant. + local journal sidecar state + if [[ -d $SK_JOURNAL_DIR/$id ]]; then + journal=$SK_JOURNAL_DIR/$id + sidecar=$journal/rendered.txt + state=$journal/rendered.state.json + else + journal=${files[0]} + sidecar=${journal%.raw}.rendered.txt + state=${journal%.raw}.rendered.state.json + fi + local render_status=127 + if [[ -f $render_tool ]]; then + # Serialised against the attach-time refill (sk_replay_history) and any + # concurrent recall: journal_render.py takes no lock of its own, and two + # concurrent renders commit the same settled lines twice, permanently. + # Wait briefly for a holder (an attach refill caps itself at 4s); a + # longer hold falls through to the stale-sidecar handling below. + # util-linux flock(1) is deliberately absent on macOS: without it, + # render unlocked -- the pre-lock behaviour -- rather than turning + # recall into the raw-capture branch (lane finding F4). + local -a render_locker=() + if command -v flock >/dev/null 2>&1; then + render_locker=(flock -w 10 "$sidecar.lock") + fi + ${render_locker[@]+"${render_locker[@]}"} python3 "$render_tool" flush \ + --journal "$journal" --out "$sidecar" --state "$state" >/dev/null 2>&1 + render_status=$? + fi + if (( render_status == 0 )) && + ! sk_history_sidecar_stale "$sidecar" "${files[@]}"; then + sk_page_history "$sidecar" + return 0 + fi + # A readable but older sidecar is still safer than raw TUI bytes. Its + # capture timestamp, outside the live-write grace period, is the + # self-contained evidence that rendering is stale; no fleet-written + # marker is required. + if [[ -r $sidecar && -s $sidecar ]]; then + sk_page_history_with_notice \ + 'History rendering needs attention; showing the last readable version.' \ + "$sidecar" + return 0 + fi + sk_page_history_with_notice \ + 'This session has no clean recording; showing the raw capture.' \ + "${files[@]}" + return 0 + fi + + # No recording exists (recording was off 2026-07-30..08-12, and sessions + # created in that window can never be re-recorded). The conversation + # transcript survives everything, render that instead. + if [[ ${SK_PROVIDER:-} == claude || ${SK_PROVIDER:-} == codex ]] && + [[ -n ${SK_UUID:-} && -f $transcript_tool ]]; then + local rendered + rendered=$(command mktemp "${TMPDIR:-/tmp}/sk-transcript.XXXXXX") || rendered= + if [[ -n $rendered ]] && + python3 "$transcript_tool" "$SK_PROVIDER" "$SK_UUID" >"$rendered" 2>/dev/null && + [[ -s $rendered ]]; then + sk_page_history_with_notice \ + 'This session was never recorded; showing its conversation instead.' \ + "$rendered" + command rm -f -- "$rendered" + return 0 + fi + [[ -z $rendered ]] || command rm -f -- "$rendered" + fi + sk_die "no live history for $(sk_human_label "$SK_TITLE" "$SK_PROVIDER" "${SK_NUMBER:-}")" + return 1 +} + +color_target() { + local selector=$1 action=$2 chosen=${3:-} + resolve_target "$selector" || return $? + sk_require_mutation_target || return 1 + [[ $SK_PROVIDER == claude || $SK_PROVIDER == codex ]] || { + sk_die "a shell session keeps its derived color" + return 1 + } + local id=$SK_ID started=$SK_STARTED provider=$SK_PROVIDER uuid=$SK_UUID + local shell_pid=$SK_SHELL_PID shell_start=$SK_SHELL_START + local provider_pid=$SK_PROVIDER_PID provider_start=$SK_PROVIDER_START + local daemon_pid=$SK_DAEMON_PID daemon_start=$SK_DAEMON_START + picker_lock || return 1 + sk_revalidate "$id" "$started" "$provider" "$uuid" \ + "$shell_pid" "$shell_start" "$provider_pid" "$provider_start" \ + "$daemon_pid" "$daemon_start" || { + sk_lock_release 9 + sk_die "target changed before color update; nothing changed" + return 1 + } + if [[ $action == delete ]]; then + python3 "$INVENTORY_CORE" color delete "$provider" "$uuid" >/dev/null || { + sk_lock_release 9 + sk_die "could not reset the session color; nothing changed" + return 1 + } + else + python3 "$INVENTORY_CORE" color set "$provider" "$uuid" "$chosen" \ + >/dev/null || { + sk_lock_release 9 + sk_die "could not set the session color; nothing changed" + return 1 + } + fi + sk_lock_release 9 + # One colour, on every surface that draws this session. The store is + # updated; a refresh republishes the colour the session's own prompt reads, + # and the marker plus one safe restart give the provider window the same + # colour instead of the one it picked for itself at boot. Never on a busy or + # attached session -- the bounce refuses anything it cannot prove idle. + make_snapshot >/dev/null 2>&1 || true + sk_mark_provider_untitled "$id" + sk_mark_provider_recolor "$id" + local restarted=0 + SK_PROOF_PROVIDER=$provider SK_PROOF_UUID=$uuid \ + SK_PROOF_PROVIDER_PID=$provider_pid SK_PROOF_PROVIDER_START=$provider_start \ + SK_PROOF_SHELL_PID=$shell_pid SK_PROOF_SHELL_START=$shell_start \ + sk_refresh_provider_title "$id" "$provider" color && restarted=1 + if [[ $action == delete ]]; then + printf 'Reset the color for %s. It goes back to its derived color.\n' \ + "$(sk_human_label "$SK_TITLE" "$provider" "${SK_NUMBER:-}")" + else + printf 'Colored %s %s.\n' \ + "$(sk_human_label "$SK_TITLE" "$provider" "${SK_NUMBER:-}")" "$chosen" + fi + # Say which of the two things happened. The line used to promise "from its + # next start" even when the safe restart two lines up had already applied + # the colour -- the same kind of copy the reconcile line was just fixed for. + if (( restarted )); then + printf 'The %s window restarted and shows it now.\n' \ + "$(sk_provider_name "$provider")" + else + printf 'The picker and the prompt show it now; the %s window shows it from its next start.\n' \ + "$(sk_provider_name "$provider")" + fi +} + +# Settle every live same-provider color collision at once, instead of waiting +# for each session to relaunch. Safe to repeat: a second run finds nothing to +# move. No target lock is taken because nothing here depends on one session +# staying put; the core takes the config lock for the write itself. +color_reconcile() { + local payload + payload=$(python3 "$INVENTORY_CORE" color reconcile) || { + sk_die "could not reconcile session colors; nothing changed" + return 1 + } + printf '%s' "$payload" | python3 -c ' +import json +import sys + +result = json.load(sys.stdin) +moved = result.get("moved") or {} +dropped = result.get("dropped") or [] +names = {"claude": "Claude", "codex": "Codex"} +for provider in sorted({key.partition(":")[0] for key in moved}): + colors = sorted( + color for key, color in moved.items() if key.startswith(provider + ":") + ) + joined = ", ".join(colors) + word = "session" if len(colors) == 1 else "sessions" + print(f"Recolored {len(colors)} {names.get(provider, provider)} {word}: {joined}") +if dropped: + if len(dropped) == 1: + print("Dropped 1 stored color that is no longer in the palette.") + else: + print( + f"Dropped {len(dropped)} stored colors that are no longer in the palette." + ) +if not moved and not dropped: + print("Nothing changed.") +elif moved: + # Name the providers this run actually recolored. The line used to say + # "Claude" whatever it had moved, so a person who recolored Codex + # sessions was told about a provider they had not touched and told + # nothing about the windows that changed. + touched = sorted({names.get(key.partition(":")[0], key.partition(":")[0]) for key in moved}) + if len(touched) == 1: + who = touched[0] + else: + who = " and ".join((", ".join(touched[:-1]), touched[-1])) + print(f"{who} windows show the new color from their next start.") +' +} + +update_ai_alias() { + local action=$1 provider=$2 uuid=$3 title=${4:-} + case "$action" in + set) + python3 "$INVENTORY_CORE" alias set "$provider" "$uuid" -- "$title" >/dev/null + ;; + delete) + python3 "$INVENTORY_CORE" alias delete "$provider" "$uuid" >/dev/null + ;; + *) + return 2 + ;; + esac +} + +name_target() { + local selector=$1 action=$2 title=${3:-} + resolve_target "$selector" || return $? + sk_require_mutation_target || return 1 + [[ $SK_CONVERSATION_PROVIDER == claude || $SK_CONVERSATION_PROVIDER == codex ]] || { + sk_die "a shell session keeps its derived name" + return 1 + } + local id=$SK_ID started=$SK_STARTED + local provider=$SK_CONVERSATION_PROVIDER uuid=$SK_CONVERSATION_UUID + local live_provider=$SK_PROVIDER live_uuid=$SK_UUID + local shell_pid=$SK_SHELL_PID shell_start=$SK_SHELL_START + local provider_pid=$SK_PROVIDER_PID provider_start=$SK_PROVIDER_START + local daemon_pid=$SK_DAEMON_PID daemon_start=$SK_DAEMON_START + picker_lock || return 1 + sk_revalidate "$id" "$started" "$live_provider" "$live_uuid" \ + "$shell_pid" "$shell_start" "$provider_pid" "$provider_start" \ + "$daemon_pid" "$daemon_start" "$provider" "$uuid" || { + sk_lock_release 9 + sk_die "target changed before name update; nothing changed" + return 1 + } + update_ai_alias "$action" "$provider" "$uuid" "$title" || { + sk_lock_release 9 + sk_die "could not update the session name; nothing changed" + return 1 + } + sk_lock_release 9 + # One name, on every surface. The alias write above also pushed the name + # into the provider's own store, but a provider window reads its bar title + # once, at start: until it restarts it keeps showing the old name, and the + # kit and the terminal disagree about what this session is called. The + # marker is the same one a launch leaves when it boots before its name + # exists, so the safe bounce below -- and the picker's, on the next open -- + # gives the window the name the kit already has. Never on a busy session: + # the bounce refuses anything that is not proven idle. + if [[ $action == set ]]; then + sk_mark_provider_untitled "$id" + SK_PROOF_PROVIDER=$live_provider SK_PROOF_UUID=$live_uuid \ + SK_PROOF_PROVIDER_PID=$provider_pid SK_PROOF_PROVIDER_START=$provider_start \ + SK_PROOF_SHELL_PID=$shell_pid SK_PROOF_SHELL_START=$shell_start \ + sk_refresh_provider_title "$id" "$live_provider" + fi + if [[ $action == delete ]]; then + printf 'Reset the local name for the %s session.\n' \ + "$(sk_provider_name "$provider")" + else + printf 'Named the %s session %s.\n' "$(sk_provider_name "$provider")" \ + "$(sk_human_label "$title" "$provider")" + fi +} + +# The marker that says "this window's bar title is older than the kit's name". +# Advisory: a marker that cannot be written costs a stale bar title until the +# next launch, never the rename. +# The marker that says "this session's colour is newer than its window". +# `sp color` asks for one safe restart immediately, and a session that is busy +# or attached rightly refuses it -- but the person still asked, and without a +# record the request died there: the next open asked only for a NAME bounce, +# which an unnamed session can never satisfy, so the window kept the colour it +# picked for itself while the picker showed the new one. Advisory, like the +# untitled marker: a marker that cannot be written costs a late recolour. +sk_mark_provider_recolor() { + local id=$1 + ( umask 077 + mkdir -p "$SK_STATE_DIR/provider-recolor" && + : > "$SK_STATE_DIR/provider-recolor/$id" + ) 2>/dev/null || true +} + +sk_mark_provider_untitled() { + local id=$1 + ( umask 077 + mkdir -p "$SK_STATE_DIR/provider-untitled" && + printf '%s\n' "$BASHPID:$RANDOM:$EPOCHREALTIME" \ + > "$SK_STATE_DIR/provider-untitled/$id" + ) 2>/dev/null || true +} + +# One safe restart so the name lands on the bar now rather than at the next +# open. Every guard inside the bounce fails toward doing nothing, and an +# attached or working session is never restarted under the person using it. +# Returns 0 when the window really was restarted, so the caller can say what +# happened rather than always promising "from its next start". +sk_refresh_provider_title() { + local id=$1 provider=$2 reason=${3:-name} + case "$provider" in + claude) picker_bounce_claude "$id" automatic "$reason" >/dev/null 2>&1 ;; + codex) picker_bounce_codex "$id" automatic "$reason" >/dev/null 2>&1 ;; + *) return 1 ;; + esac +} + +find_history() { + local query=$1 + [[ -n $query ]] || return 1 + local search_tool + search_tool=$(sk_history_tool_path SESSION_KIT_HISTORY_SEARCH_TOOL \ + "${INVENTORY_CORE%/*}/sessionkit_inventory/history_search.py") + [[ -f $search_tool ]] || { + sk_die "history search is unavailable" + return 1 + } + local inventory="${SESSION_KIT_HISTORY_INVENTORY:-$SK_STATE_DIR/inventory.json}" + python3 "$search_tool" --journal "$SK_JOURNAL_DIR" --recovery "$SK_RECOVERY_DIR" \ + --archive "$SK_ARCHIVE_DIR" --inventory "$inventory" \ + --data "${SESSION_KIT_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/session-kit}" \ + -- "$query" +} + +# Every number on this screen is labeled. The unlabeled `ps` columns it used to +# print named nothing: no reader could tell which of five figures was memory. +show_health() { + sk_require_shpool || return 1 + local daemon + daemon=$(pgrep -f '^.*/shpool daemon$' | head -1) + if [[ -z $daemon ]]; then + sk_die "the session manager is not running" + return 1 + fi + local rss threads elapsed + rss=$(ps -o rss= -p "$daemon" 2>/dev/null | tr -d ' ') + threads=$(ps -o nlwp= -p "$daemon" 2>/dev/null | tr -d ' ') + elapsed=$(ps -o etime= -p "$daemon" 2>/dev/null | tr -d ' ') + printf ' Session manager\n\n' + printf ' Running %s\n' "$(sk_elapsed_phrase "$elapsed")" + printf ' Memory %s\n' "$(sk_memory_phrase "$rss")" + [[ -z $threads ]] || printf ' Threads %s\n' "$threads" + local host_memory + host_memory=$(free -b 2>/dev/null | awk ' + $1 == "Mem:" { printf "%.0f %.0f", $7, $2 } + ') + if [[ -n $host_memory ]]; then + local host_available host_total + read -r host_available host_total <<<"$host_memory" + printf '\n Host memory %s available of %s\n' \ + "$(sk_bytes_phrase "$host_available")" "$(sk_bytes_phrase "$host_total")" + fi +} + +# ps prints etime as [[dd-]hh:]mm:ss. Say it the way a person would. +sk_elapsed_phrase() { + local raw=${1:-} + [[ -n $raw ]] || { printf '—'; return 0; } + local days=0 rest=$raw + if [[ $rest == *-* ]]; then + days=${rest%%-*} + rest=${rest#*-} + fi + local hours=0 + case "$rest" in + *:*:*) hours=${rest%%:*} ;; + esac + days=$((10#${days:-0})) + hours=$((10#${hours:-0})) + if (( days == 1 )); then + printf '1 day' + elif (( days > 1 )); then + printf '%s days' "$days" + elif (( hours == 1 )); then + printf '1 hour' + elif (( hours > 1 )); then + printf '%s hours' "$hours" + else + printf 'less than an hour' + fi +} + +sk_memory_phrase() { + local kilobytes=${1:-} + [[ $kilobytes =~ ^[0-9]+$ ]] || { printf '—'; return 0; } + sk_bytes_phrase "$(( kilobytes * 1024 ))" +} + +sk_bytes_phrase() { + local bytes=${1:-} + [[ $bytes =~ ^[0-9]+$ ]] || { printf '—'; return 0; } + awk -v bytes="$bytes" ' + BEGIN { + if (bytes >= 1073741824) { printf "%.1f GB", bytes / 1073741824 } + else if (bytes >= 1048576) { printf "%.1f MB", bytes / 1048576 } + else { printf "%.0f KB", bytes / 1024 } + }' +} + +# One feed, newest first: the conversations a person closed on purpose and the +# ones a crash took. Closing a session ends the terminal, never the +# conversation, so a deliberate close is listed here exactly like a lost one -- +# hiding it was the broken half of the promise. A plain shell has no +# conversation to reopen and says so rather than offering a restore that +# cannot work. +sk_recovery_feed() { + local allow_large=${1:-no} + local snapshot_file + snapshot_file=$(mktemp "${TMPDIR:-/tmp}/session-kit-recovery-snapshot.XXXXXX") || { + sk_die "the recovery list could not create its private snapshot file" + return 1 + } + chmod 600 "$snapshot_file" + # One core invocation validates and snapshots the Closed ledger once. The + # first line carries pending projection inputs whose tombstone decisions use + # that snapshot; all following lines are Closed rows from the same reusable + # SQLite snapshot. A concurrent forget is therefore wholly before or wholly + # after the rendered view, never between two authority reads. + local -a snapshot_args=(recovery-pending list --stream-recovery-snapshot) + [[ $allow_large != yes ]] || snapshot_args+=(--allow-large-ledger) + if ! python3 "$INVENTORY_CORE" "${snapshot_args[@]}" >"$snapshot_file"; then + rm -f -- "$snapshot_file" + printf 'session-kit: the closed-conversations list could not be read, so anything closed on purpose is missing from this feed.\n' >&2 + return 1 + fi + python3 /dev/fd/3 3<<'FEED_PY' <"$snapshot_file" +import datetime, json, os, re, sqlite3, sys, tempfile, unicodedata + +first = sys.stdin.readline() +if not first: + raise ValueError("recovery snapshot stream is empty") +envelope = json.loads(first) +base = envelope.get("recovery_projection") if isinstance(envelope, dict) else None +if not isinstance(base, dict): + raise ValueError("recovery snapshot stream has no projection header") +for diagnostic in base.get("diagnostics") or (): + if isinstance(diagnostic, str) and diagnostic: + print(f"session-kit: {diagnostic}", file=sys.stderr) +inputs = base.get("projection_inputs") or {} +aliases = inputs.get("aliases") if isinstance(inputs.get("aliases"), dict) else {} +automatic = inputs.get("automatic_titles") if isinstance(inputs.get("automatic_titles"), dict) else {} +raw_numbers = inputs.get("numbers") if isinstance(inputs.get("numbers"), dict) else {} +counts = {} +for value in raw_numbers.values(): + counts[value] = counts.get(value, 0) + 1 +numbers = {key: value for key, value in raw_numbers.items() if counts.get(value) == 1} +live = { + (str(pair[0]), str(pair[1]).casefold()) + for pair in (inputs.get("live") or []) + if isinstance(pair, list) and len(pair) == 2 and pair[0] and pair[1] +} +uuid_shape = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}") +generated = ( + re.compile(r"the(\s+\w+)?\s+session"), re.compile(r"idle shell"), + re.compile(r"(claude|codex|shell)"), + re.compile(r"(claude|codex|shell)\s+(in\s+\S+\s+at|started)\s+.+"), + re.compile(r"[a-z0-9][a-z0-9._-]*-[0-9a-f]{2,4}"), +) + +def clean(value, limit): + if not isinstance(value, str): + return "" + safe = "".join(" " if unicodedata.category(c).startswith("C") else c for c in value) + return " ".join(safe.split())[:limit] + +def exact_uuid(value): + return value.lower() if isinstance(value, str) and uuid_shape.fullmatch(value) else "" + +def real_name(row, provider, exact): + if provider == "shell": + return "" + key = f"{provider}:{exact}" + for store in (aliases, automatic): + found = clean(store.get(key), 120) + if found: + return found + title = clean(row.get("title"), 120) + source = clean(row.get("title_source"), 40).casefold() + if source: + return "" if source in {"context", "provider"} else title + folded = title.casefold() + return "" if any(shape.fullmatch(folded) for shape in generated) else title + +def closed_candidate(row): + provider = clean(row.get("provider"), 20).casefold() + exact = exact_uuid(row.get("uuid")) + if exact and (provider, exact.casefold()) in live: + return None + name = real_name(row, provider, exact) + number = numbers.get(f"ai:{provider}:{exact}") + if isinstance(number, bool) or not isinstance(number, int) or number <= 0: + number = None + restorable = bool(row.get("restorable")) and provider != "shell" and bool(exact) + return { + "source": "closed", "number": number, "short_id": exact[:8], + "provider": provider, "uuid": exact, "cwd": clean(row.get("cwd"), 4096), + "name": name, "named": bool(name), "display_name": name or "unnamed", + "restorable": restorable, + "history_only_reason": "" if restorable else ( + "a plain shell, so only its history is kept -- there is no conversation to reopen" + if provider == "shell" or not exact else + "its transcript is no longer on this machine, so only its history is kept" + ), + "when_unix_ms": row.get("closed_at_unix_ms") if isinstance(row.get("closed_at_unix_ms"), int) else 0, + "source_generation_key": "", "old_shpool_id": clean(row.get("shpool_id"), 200), + "conflict_fields": [], "selector": "", + } + +descriptor, database = tempfile.mkstemp(prefix="session-kit-recovery-feed-", suffix=".sqlite3") +os.close(descriptor) +os.chmod(database, 0o600) +connection = sqlite3.connect(database) +try: + connection.execute("PRAGMA journal_mode=OFF") + connection.execute("PRAGMA synchronous=OFF") + connection.execute("PRAGMA temp_store=FILE") + connection.execute("PRAGMA cache_size=-2048") + connection.execute("""CREATE TABLE rows ( + identity TEXT PRIMARY KEY, provider TEXT, uuid TEXT, when_ms INTEGER, + number INTEGER, display_name TEXT, name_fold TEXT, named INTEGER, + restorable INTEGER, selector TEXT, document TEXT + ) WITHOUT ROWID""") + + def identity(row): + if row.get("uuid"): + return f"ai:{row.get('provider')}:{str(row.get('uuid')).casefold()}" + old = clean(row.get("old_shpool_id"), 200) + return f"shell:{old or clean(row.get('cwd'), 4096)}:{row.get('when_unix_ms') or 0}" + + def retain(row): + if not isinstance(row, dict): + raise ValueError("recovery stream contains a non-object") + row = dict(row) + row["selector"] = "" + key = identity(row) + held_raw = connection.execute("SELECT document FROM rows WHERE identity=?", (key,)).fetchone() + if held_raw is not None: + held = json.loads(held_raw[0]) + if (row.get("when_unix_ms") or 0) > (held.get("when_unix_ms") or 0): + for field in ("source", "when_unix_ms", "cwd", "restorable", "history_only_reason"): + held[field] = row.get(field) + if row.get("named"): + for field in ("name", "named", "display_name"): + held[field] = row.get(field) + elif row.get("named") and not held.get("named"): + for field in ("name", "named", "display_name"): + held[field] = row.get(field) + for field in ("source_generation_key", "old_shpool_id", "conflict_fields"): + if row.get(field) and not held.get(field): + held[field] = row[field] + row = held + name = clean(row.get("display_name"), 120) or "unnamed" + number = row.get("number") if isinstance(row.get("number"), int) and not isinstance(row.get("number"), bool) else None + connection.execute( + "INSERT OR REPLACE INTO rows VALUES (?,?,?,?,?,?,?,?,?,?,?)", + (key, row.get("provider") or "", row.get("uuid") or "", row.get("when_unix_ms") or 0, + number, name, name.casefold(), 1 if row.get("named") else 0, + 1 if row.get("restorable") else 0, "", json.dumps(row, ensure_ascii=False, sort_keys=True)), + ) + + for row in base.get("entries") or []: + retain(row) + for line_number, raw in enumerate(sys.stdin, 2): + if not raw.strip(): + continue + try: + record = json.loads(raw) + except ValueError as exc: + raise ValueError(f"closed-session stream row {line_number} is invalid JSON") from exc + candidate = closed_candidate(record) + if candidate is not None: + retain(candidate) + connection.commit() + + # Main's cap bounds history-only reading but never restorable work. + restorable_count = connection.execute("SELECT count(*) FROM rows WHERE restorable=1").fetchone()[0] + history_keep = max(0, 500 - restorable_count) + connection.execute( + """DELETE FROM rows WHERE restorable=0 AND identity NOT IN + (SELECT identity FROM rows WHERE restorable=0 + ORDER BY when_ms DESC, coalesce(number, 2147483647), uuid LIMIT ?)""", + (history_keep,), + ) + connection.execute("CREATE TABLE tokens (folded TEXT PRIMARY KEY) WITHOUT ROWID") + connection.execute("UPDATE rows SET selector=CAST(number AS TEXT) WHERE number IS NOT NULL") + connection.execute( + "INSERT OR IGNORE INTO tokens SELECT CAST(number AS TEXT) FROM rows WHERE number IS NOT NULL" + ) + + connection.create_function("fold", 1, lambda value: str(value or "").casefold()) + connection.create_function( + "stamp", 2, + lambda when, pattern: "" if not when or when <= 0 else "@" + datetime.datetime.fromtimestamp(when / 1000).strftime(pattern), + ) + connection.execute("CREATE TABLE claims (folded TEXT PRIMARY KEY, token TEXT, claims INTEGER) WITHOUT ROWID") + + def offer(expression, parameters=()): + connection.execute("DELETE FROM claims") + connection.execute( + f"""INSERT INTO claims + SELECT fold({expression}), min({expression}), count(*) FROM rows + WHERE selector='' AND restorable=1 AND {expression}!='' + GROUP BY fold({expression})""", + parameters * 4, + ) + connection.execute("DELETE FROM claims WHERE claims!=1 OR folded IN (SELECT folded FROM tokens)") + connection.execute( + f"""UPDATE rows SET selector=(SELECT token FROM claims WHERE claims.folded=fold({expression})) + WHERE selector='' AND restorable=1 AND fold({expression}) IN (SELECT folded FROM claims)""", + parameters * 2, + ) + connection.execute("INSERT OR IGNORE INTO tokens SELECT folded FROM claims") + + offer("display_name") + offer("stamp(when_ms, ?)", ("%H:%M",)) + offer("stamp(when_ms, ?)", ("%H:%M:%S",)) + connection.commit() + for selector, document in connection.execute( + "SELECT selector, document FROM rows ORDER BY when_ms DESC, coalesce(number, 2147483647), uuid" + ): + row = json.loads(document) + row["selector"] = selector + print(json.dumps(row, ensure_ascii=False, sort_keys=True)) +finally: + connection.close() + try: + os.unlink(database) + except FileNotFoundError: + pass +FEED_PY + local feed_status=$? + rm -f -- "$snapshot_file" + if (( feed_status != 0 )); then + printf 'session-kit: the recovery list could not be rendered; no empty result should be trusted.\n' >&2 + fi + return "$feed_status" +} + +sk_render_recovery_feed() { + python3 /dev/fd/3 3<<'RENDER_PY' +import json, sys, time +now_ms = int(time.time() * 1000) +def age(when): + if not isinstance(when, int) or isinstance(when, bool) or when <= 0: + return "age unavailable" + seconds = max(0, (now_ms - when) // 1000) + if seconds < 60: return "under 1 min ago" + if seconds < 3600: return f"{seconds // 60} min ago" + if seconds < 86400: return f"{seconds // 3600} hr ago" + days = seconds // 86400 + return f"{days} day{'s' if days != 1 else ''} ago" +for raw in sys.stdin: + if not raw.strip(): + continue + row = json.loads(raw) + number = row.get("number") + print("\t".join(( + str(number) if isinstance(number, int) and not isinstance(number, bool) else "", + " ".join(str(row.get("selector") or "").split()), row.get("source") or "", + row.get("provider") or "", row.get("uuid") or "", row.get("cwd") or "", + "yes" if row.get("restorable") else "no", age(row.get("when_unix_ms")), + " ".join(str(row.get("display_name") or "").split()), + " ".join(str(row.get("history_only_reason") or "").split()), + ))) +RENDER_PY +} + +show_recovery() { + local allow_large=${1:-no} + local feed_file status line + feed_file=$(mktemp "${TMPDIR:-/tmp}/session-kit-recovery.XXXXXX") || { + sk_die "the recovery list could not create its private stream file" + return 1 + } + chmod 600 "$feed_file" + sk_recovery_feed "$allow_large" >"$feed_file" + status=$? + if (( status != 0 )); then + rm -f -- "$feed_file" + return "$status" + fi + if [[ ! -s $feed_file ]]; then + rm -f -- "$feed_file" + echo "Closed conversations: none." + return 0 + fi + # What this screen prints beside each row, written down before it is + # printed. `sp restore` rebuilds the list to act on it, so without this it + # has no way to tell that the word it was handed has changed rows since they + # read it -- and two rows can carry the same name, so the success line would + # not tell them either. + local recorded=1 + python3 "$INVENTORY_CORE" recovery-selectors remember --json-lines \ + <"$feed_file" >/dev/null || recorded=0 + local number selector source provider uuid cwd restorable event_age title reason + while IFS= read -r line; do + # A tab is IFS whitespace, so a run of them reads as one separator and an + # empty field disappears -- and a shell row's conversation field is always + # empty. Every record here is read on \034 for that reason. + line=${line//$'\t'/$'\034'} + IFS=$'\034' read -r number selector source provider uuid cwd restorable event_age title reason <<<"$line" + local note="" label="—" + [[ $source != lost ]] || note=", lost with its session" + # The number a session has everywhere else, not a position in this list. + # A conversation whose number was retired goes by the name beside it, or + # by the time of its own event when two rows answer to one name -- and the + # picker prints the same thing in the same column. Neither is a session + # identifier, which is what this column used to print. + if [[ -n $number ]]; then + label=$number + elif [[ $selector == @* ]]; then + label=$selector + fi + printf '%4s %-6s %s · %s%s\n' "$label" "$(sk_provider_name "$provider")" \ + "$title" "$event_age" "$note" + [[ -z $reason ]] || printf ' %s\n' "$reason" + done < <(sk_render_recovery_feed <"$feed_file") + rm -f -- "$feed_file" + if [[ $allow_large == yes ]]; then + printf 'Bring one back with: sp restore --allow-large-ledger number|selector, using what this list shows.\n' + else + printf 'Bring one back with: sp restore <number>, or the selector beside a session that has none.\n' + fi + (( recorded )) || printf 'session-kit: this list could not be written down, so sp restore cannot check what you type against it\n' >&2 +} + +# The same feed, acted on. The number is the session's own number, so it names +# the same conversation on every screen and does not move when the list is +# rebuilt between printing it and acting on it. +restore_listed() { + local wanted=$1 + local allow_large=${2:-no} + # Everything after the verb is one selector; normalize it exactly as the + # printed-selector record does, including a multi-word unquoted name. + wanted=$(printf '%s' "$wanted" | tr -s '[:space:]' ' ') + wanted=${wanted# } + wanted=${wanted% } + [[ -n $wanted ]] || { + sk_die "restore takes a session number from sp recover, or the selector beside a session that has none" + return 2 + } + local feed_file status line="" + feed_file=$(mktemp "${TMPDIR:-/tmp}/session-kit-restore.XXXXXX") || { + sk_die "the recovery list could not create its private stream file" + return 1 + } + chmod 600 "$feed_file" + sk_recovery_feed "$allow_large" >"$feed_file" + status=$? + if (( status != 0 )); then + rm -f -- "$feed_file" + return "$status" + fi + # A session number, or -- for a conversation whose number was retired -- the + # selector the list prints beside it, which is the name unless two rows + # answer to one name. The list settles that once, for both screens, so this + # cannot resolve a word the picker would resolve differently. Neither is a + # position, and neither is an identifier: the short id this used to accept + # was never printed anywhere, so the only way to learn it was to read the + # JSON. + wanted=$(printf '%s' "$wanted" | tr -s '[:space:]' ' ') + wanted=${wanted# } + wanted=${wanted% } + [[ -n $wanted ]] || { + sk_die "restore takes a session number from sp recover, or the selector beside a session that has none" + return 2 + } + local folded=${wanted,,} + local number selector source provider uuid cwd restorable event_age title reason + local found=0 line + local match_number="" match_source="" match_provider="" match_uuid="" match_cwd="" + local match_restorable="" match_title="" match_reason="" + while IFS= read -r line; do + [[ -n $line ]] || continue + line=${line//$'\t'/$'\034'} + IFS=$'\034' read -r number selector source provider uuid cwd restorable event_age title reason <<<"$line" + # One rule for every word, which is the rule the picker applies: the word + # answers for the row it is printed beside. A numbered row's selector IS + # its number, so a number needs no branch of its own -- and a branch of its + # own is how a conversation somebody named "2 4" ended up meaning sessions + # 2 and 4 on one screen and itself on the other. + [[ -n $selector && ${selector,,} == "$folded" ]] || continue + (( found == 0 )) || { + # Two rows answering to one selector is a broken list; restoring the + # first of them is how a person gets back a conversation they did not ask + # for. + sk_die "more than one closed session answers to that, so nothing was restored" + return 2 + } + found=1 + match_number=$number + match_source=$source + match_provider=$provider + match_uuid=$uuid + match_cwd=$cwd + match_restorable=$restorable + match_title=$title + match_reason=$reason + done < <(sk_render_recovery_feed <"$feed_file") + rm -f -- "$feed_file" + (( found )) || { + sk_die "there is no session ${wanted} on that list. Type what the row shows: its number, or the selector beside a session that has none" + return 2 + } + number=$match_number + source=$match_source + provider=$match_provider + uuid=$match_uuid + cwd=$match_cwd + restorable=$match_restorable + title=$match_title + reason=$match_reason + # The list was rebuilt to answer this, and a word like "unnamed" -- or the + # clock face a shared name falls back to -- belongs to the list rather than + # to the conversation. So before anything is restored: does this word still + # name the row it was printed beside? A word they have never been shown has no + # earlier meaning to have drifted from, and is left alone. + local verdict=0 + python3 "$INVENTORY_CORE" recovery-selectors check "$wanted" "$provider" "$uuid" \ + >/dev/null 2>&1 || verdict=$? + if (( verdict == 4 )); then + sk_die "${wanted} was printed beside a different conversation, so nothing was restored. Run sp recover and type what the list shows now" + return 1 + elif (( verdict != 0 )); then + sk_die "the kit could not check that ${wanted} still means the conversation it was printed beside, so nothing was restored" + return 1 + fi + [[ $restorable == yes ]] || { + sk_die "${reason:-that one keeps its history only; there is no conversation to restore}" + return 1 + } + [[ -d $cwd ]] || { + sk_die "the directory that session ran in is gone, so it cannot be restored" + return 1 + } + restore_exact "$provider" "$uuid" "$cwd" >/dev/null || return 1 + printf 'Restored %s.\n' "$title" +} + +verify_start_target() { + local selector=$1 + sk_require_integration || return 1 + resolve_target "$selector" || return $? + sk_require_mutation_target || return 1 + local id=$SK_ID + sk_read_start_expectation "$id" || { + sk_die "no complete retained launch proof exists for that session" + return 1 + } + local expected_provider=$SK_EXPECT_PROVIDER expected_cwd=$SK_EXPECT_CWD + local expected_uuid=$SK_EXPECT_UUID expected_started=$SK_EXPECT_STARTED + local expected_boot_id=$SK_EXPECT_BOOT_ID + local expected_shell_pid=$SK_EXPECT_SHELL_PID expected_shell_start=$SK_EXPECT_SHELL_START + local expected_daemon_pid=$SK_EXPECT_DAEMON_PID expected_daemon_start=$SK_EXPECT_DAEMON_START + local SESSION_KIT_PROVIDER_PROOF_ATTEMPTS=1 + if sk_wait_for_provider "$id" "$expected_provider" "$expected_cwd" "$expected_uuid" \ + "$expected_boot_id" "$expected_started" "$expected_shell_pid" "$expected_shell_start" \ + "$expected_daemon_pid" "$expected_daemon_start" "$SK_EXPECT_LAUNCH_MODE"; then + printf 'Verified %s started and cleared the retained launch record for %s.\n' \ + "$(sk_provider_name "$expected_provider")" \ + "$(sk_human_label "$SK_TITLE" "$expected_provider" "${SK_NUMBER:-}")" + return 0 + fi + sk_die "$(sk_provider_name "$expected_provider") is not running in that session yet; its launch record is kept. Open it from the picker and try again." +} diff --git a/lib/sh/sp_completion.bash b/lib/sh/sp_completion.bash new file mode 100644 index 0000000..5c8afab --- /dev/null +++ b/lib/sh/sp_completion.bash @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +# session-kit managed bash completion v1 +# +# Tab completion for the Session Kit commands that take arguments: sp, +# session-kit, shpool_status, shpool_reaper, and codex_resume_here. Source this +# file; do not execute it. The installer puts a copy under each of those names +# in the user's bash-completion directory, so bash-completion loads the right +# one on demand; without that package, sourcing any single copy registers all +# five. +# +# Two rules this file keeps: +# +# 1. It never runs a Session Kit command. A completion that shelled out to +# `sp list` or `shpool_status` would build a fresh inventory -- which +# writes state and can take a second -- on every Tab press, from inside +# the user's prompt. That is why terminal numbers are not completed here: +# the only honest source for them is a live snapshot, and reading one on +# Tab is a state change nobody asked for. +# 2. It stays inside Bash 3.2. macOS logins still land in the system Bash, +# and a completion file that fails to parse there breaks the prompt of a +# shell that never even ran Session Kit. So: no mapfile, no associative +# arrays, no ${var,,}. +# +# The only file it reads is the projects list, a small owner-private TSV, so +# `sp new <alias>` completes the aliases the user actually configured. +# +# shellcheck shell=bash + +_session_kit_words() { + local candidates=$1 current=${2:-} + local IFS=$' \t\n' + # Word splitting is the point: COMPREPLY is an array of candidates, and + # mapfile -- the suggested alternative -- is Bash 4 only. + # shellcheck disable=SC2207 + COMPREPLY=($(compgen -W "$candidates" -- "$current")) +} + +_session_kit_project_aliases() { + local file=${SESSION_KIT_PROJECTS_FILE:-} + if [ -z "$file" ]; then + file=${XDG_CONFIG_HOME:-$HOME/.config}/session-kit/projects.tsv + fi + [ -r "$file" ] || return 0 + # Field 1 of every non-comment line, bounded so a corrupted or enormous file + # cannot stall the prompt. + awk -F'\t' ' + /^[[:space:]]*($|#)/ { next } + $1 ~ /^[A-Za-z0-9._-]+$/ { print $1 } + NR > 500 { exit } + ' "$file" 2>/dev/null | tr '\n' ' ' +} + +_sp() { + local current previous verb + current=${COMP_WORDS[COMP_CWORD]} + previous=${COMP_WORDS[COMP_CWORD-1]} + verb=${COMP_WORDS[1]} + COMPREPLY=() + + # The verbs a person types. picker-* and restore-exact are machine verbs + # called by the picker and the managed shell with proof arguments; they are + # left out here for the same reason `sp help` leaves them out. + local verbs="list new go takeover close teardown detail health prune repair + verify-start name self-name color account worktree find history + recover help" + + if [ "$COMP_CWORD" -eq 1 ]; then + _session_kit_words "$verbs" "$current" + return 0 + fi + + case "$verb" in + help) + [ "$COMP_CWORD" -eq 2 ] || return 0 + _session_kit_words "sessions names accounts history selectors + unavailable exit-codes completion machine" "$current" + ;; + worktree) + [ "$COMP_CWORD" -eq 2 ] || return 0 + _session_kit_words "list materialize bind lookup teardown" "$current" + ;; + new) + case "$previous" in + --account | --model | --launch-key) + return 0 + ;; + --origin) + _session_kit_words "human machine" "$current" + return 0 + ;; + --prompt-file) + return 0 + ;; + esac + case "$current" in + -*) + _session_kit_words "--account --model --launch-key --origin --prompt-file --worktree" \ + "$current" + return 0 + ;; + esac + if [ "$COMP_CWORD" -eq 2 ]; then + _session_kit_words "claude codex shell $(_session_kit_project_aliases)" \ + "$current" + elif [ "$COMP_CWORD" -eq 3 ]; then + _session_kit_words "$(_session_kit_project_aliases)" "$current" + else + _session_kit_words "--account --model --launch-key --origin --prompt-file --worktree" \ + "$current" + fi + ;; + name) + [ "$COMP_CWORD" -eq 2 ] || return 0 + _session_kit_words "reset" "$current" + ;; + color) + if [ "$COMP_CWORD" -eq 2 ]; then + _session_kit_words "reset reconcile" "$current" + elif [ "$COMP_CWORD" -eq 3 ] && [ "$previous" != reset ]; then + # Both palettes: a session's provider decides which half is valid, and + # this file deliberately does not ask the manager which one it is. + _session_kit_words "red blue green yellow purple orange pink cyan + lime magenta silver sand sky sea" "$current" + fi + ;; + account) + if [ "$COMP_CWORD" -eq 2 ]; then + _session_kit_words "list adopt-default enroll verify configure-feeds" \ + "$current" + elif [ "$COMP_CWORD" -eq 3 ]; then + case "${COMP_WORDS[2]}" in + list | adopt-default | enroll | verify) + _session_kit_words "claude codex" "$current" + ;; + esac + fi + ;; + esac + return 0 +} + +_session_kit() { + local current previous verb + current=${COMP_WORDS[COMP_CWORD]} + previous=${COMP_WORDS[COMP_CWORD-1]} + verb=${COMP_WORDS[1]} + COMPREPLY=() + + if [ "$COMP_CWORD" -eq 1 ]; then + _session_kit_words "doctor projects update rollback services enable-login + disable-login uninstall help" "$current" + return 0 + fi + + case "$verb" in + doctor) + _session_kit_words "--json" "$current" + ;; + projects) + [ "$COMP_CWORD" -eq 2 ] || return 0 + _session_kit_words "discover import list add ignore unignore" "$current" + ;; + services) + [ "$COMP_CWORD" -eq 2 ] || return 0 + _session_kit_words "enable disable status" "$current" + ;; + update) + case "$previous" in + --source) + _session_kit_filedir + return 0 + ;; + --journal) + _session_kit_words "on off" "$current" + return 0 + ;; + esac + _session_kit_words "--source --enable-login --disable-login --journal" \ + "$current" + ;; + rollback) + [ "$previous" = --to ] && return 0 + _session_kit_words "--to" "$current" + ;; + uninstall) + _session_kit_words "--purge-code --purge-config" "$current" + ;; + esac + return 0 +} + +_shpool_status() { + local current + current=${COMP_WORDS[COMP_CWORD]} + COMPREPLY=() + [ "$COMP_CWORD" -eq 1 ] || return 0 + _session_kit_words "--rows --json --strict-json --guard-json --waiting-count + --detail --lookup --render-file --lookup-file --recovery-pending-list + --recovery-pending-ack --help" "$current" +} + +_shpool_reaper() { + local current + current=${COMP_WORDS[COMP_CWORD]} + COMPREPLY=() + [ "$COMP_CWORD" -eq 1 ] || return 0 + _session_kit_words "--candidates --auto-close --verify-candidate --help" \ + "$current" +} + +_codex_resume_here() { + local current previous + current=${COMP_WORDS[COMP_CWORD]} + previous=${COMP_WORDS[COMP_CWORD-1]} + COMPREPLY=() + if [ "$previous" = --cwd ]; then + _session_kit_dirs + return 0 + fi + case "$current" in + -*) _session_kit_words "--print --cwd --help" "$current" ;; + esac + return 0 +} + +# bash-completion's _filedir is not guaranteed to be loaded, so both of these +# fall back to compgen's own file and directory generators. +_session_kit_filedir() { + local current=${COMP_WORDS[COMP_CWORD]} + if declare -F _filedir >/dev/null 2>&1; then + _filedir + else + # shellcheck disable=SC2207 # see _session_kit_words + COMPREPLY=($(compgen -f -- "$current")) + fi +} + +_session_kit_dirs() { + local current=${COMP_WORDS[COMP_CWORD]} + if declare -F _filedir >/dev/null 2>&1; then + _filedir -d + else + # shellcheck disable=SC2207 # see _session_kit_words + COMPREPLY=($(compgen -d -- "$current")) + fi +} + +complete -F _sp sp +complete -F _session_kit session-kit +complete -F _shpool_status shpool_status +complete -F _shpool_reaper shpool_reaper +complete -F _codex_resume_here codex_resume_here diff --git a/lib/sh/sp_core.sh b/lib/sh/sp_core.sh new file mode 100644 index 0000000..9e19ab8 --- /dev/null +++ b/lib/sh/sp_core.sh @@ -0,0 +1,405 @@ +#!/usr/bin/env bash +# sp snapshot construction, attach primitives, and target resolution: the +# building blocks every sp action starts from. Source this file; do not execute +# it. +# +# Source order: bin/sp sources this module after it resolves SCRIPT_DIR, +# sources bin/session_kit_common, and assigns INVENTORY_CORE, and before it +# installs its first trap, so trap cleanup_snapshot EXIT always names a defined +# function. SNAPSHOT is assigned by the make_*_snapshot functions here and read +# across every other sp module. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +make_snapshot() { + sk_prepare_state || return 1 + [[ -z ${SNAPSHOT:-} || ! -f $SNAPSHOT ]] || command rm -- "$SNAPSHOT" + SNAPSHOT=$(mktemp "$SK_STATE_DIR/sp-snapshot.json.XXXXXX") || return 1 + sk_snapshot_file "$SNAPSHOT" +} + +make_strict_snapshot() { + sk_prepare_state || return 1 + [[ -z ${SNAPSHOT:-} || ! -f $SNAPSHOT ]] || command rm -- "$SNAPSHOT" + SNAPSHOT=$(mktemp "$SK_STATE_DIR/sp-strict.json.XXXXXX") || return 1 + sk_strict_snapshot_file "$SNAPSHOT" +} + +make_guard_snapshot() { + sk_prepare_state || return 1 + [[ -z ${SNAPSHOT:-} || ! -f $SNAPSHOT ]] || command rm -- "$SNAPSHOT" + SNAPSHOT=$(mktemp "$SK_STATE_DIR/sp-guard.json.XXXXXX") || return 1 + sk_guard_snapshot_file "$SNAPSHOT" +} + +cleanup_snapshot() { + if [[ -n ${SNAPSHOT:-} && -f $SNAPSHOT ]]; then + command rm -- "$SNAPSHOT" + fi +} + +# Fire-and-forget provider color push at attach time. The transcript is +# guaranteed to exist by now, unlike at creation proof, and the attach itself +# is never delayed. Codex is a clean no-op inside the propagator. The kit +# never alters the attached terminal itself; the provider applies its own +# native color from this record at the session's next start or resume. +sk_push_session_color() { + [[ -n ${1:-} && -n ${2:-} ]] || return 0 + ( python3 "$INVENTORY_CORE" color propagate "$1" "$2" >/dev/null 2>&1 \ + || true ) & +} + +# Give the attach client a terminal on stderr when the front door took it +# away, or the client will refuse to take the terminal at all -- silently. +# +# libshpool/src/tty.rs:91 (`set_attach_flags`): if ANY of stdin, stdout or +# stderr is not a tty, shpool returns a no-op guard and never sets raw mode, +# then pipes bytes anyway. The picker has already restored cooked mode by then +# (lib/sh/shpool_login_live.sh:156, before every action), so the person is left +# typing into a COOKED terminal with a live, pumping client: every keystroke +# echoes as literal text over the application's first screen, Ctrl-L and Esc do +# nothing because ICANON holds them until a newline, and nothing reaches the +# session. +# +# THIS IS THE SECOND LINE OF DEFENCE, NOT THE ROOT CAUSE. +# +# The root cause of a deaf terminal is a separate one-line bug, fixed on +# its own branch: `picker_events_stop` at +# lib/sh/shpool_login_live.sh:376 runs +# +# exec {PICKER_EVENTS_FD}<&- 2>/dev/null +# +# and in bash a redirection on a BARE `exec` sticks to the shell for good -- +# so closing the event descriptor also pointed the picker's own stderr at +# /dev/null permanently. The order that produces it: a picker open long +# enough for its event stream to drop, the stop path poisoning fd 2, a +# self-upgrade carrying /dev/null through its `exec` into the new picker, +# and the next client attaching with a non-terminal fd 2 and never +# touching the terminal. From there the session is deaf and says nothing +# about it, for as long as it takes somebody to notice. +# +# Fixing that one line closes that one source. This function closes the +# CLASS: whatever hands a non-terminal descriptor down -- a stale /dev/null +# from a bare `exec`, the TUI launcher's stderr FIFO +# (bin/shpool_login_launcher:75), or the next one nobody has found -- the +# client is given a terminal on all three before the handover, so it can +# never silently decline again. Two doors, one class of bug. +# +# Measured, not assumed. Real bin/shpool_login, real `sp`, real client, real +# pty, with the picker poisoned by its OWN code (a dropped event stream, so +# line 376 runs for real): +# +# base 68fd53e 19,532 of 19,534 samples cooked with a live client, +# 101.7 s, and the client never wrote termios at all +# with this 0 of 19,176 cooked, and the client takes the terminal +# +# The TUI door is measured too, through the real launcher and its real FIFO: +# 986 of 986 samples cooked at base, 0 of 927 here. +# +# Deliberately narrow. The first version opened on "a terminal on any one of +# the three" and rebound the other two, which took redirections away from +# callers that had asked for them -- `sp go 3 > out` wrote to the screen and +# left `out` empty. The rules now: +# +# * stderr is already a terminal -> nothing to do, nothing measured, no cost. +# * stdin or stdout is NOT a terminal -> the caller redirected a channel it +# is reading or writing. Honour it and stay out of the way. +# * otherwise point stderr at the terminal STDOUT already has -- not at +# /dev/tty. The controlling terminal is not always the terminal on fd 0/1 +# (nested ptys, which are ordinary over SSH), and binding to /dev/tty +# could put this session's errors on a different screen. +sk_bind_handoff_tty() { + [[ -t 2 ]] && return 0 + [[ -t 0 && -t 1 ]] || return 0 + exec 2>&1 +} + +# The same binding, but scoped to one attach, plus the repair net below. +# +# `attach_id` ends in `exec`, so its binding dies with the process. The picker +# door does NOT exec -- it runs the client as a child -- so an unscoped +# `exec 2>` outlived the attach and everything `sp` said afterwards bypassed +# the launcher's FIFO capture and arrived out of order on the screen. Arm +# before the client, release after it. +SK_HANDOFF_SESSION= +SK_HANDOFF_MODES= +SK_HANDOFF_ERR= + +sk_handoff_guard_arm() { + SK_HANDOFF_SESSION=${1:-} + SK_HANDOFF_MODES= + SK_HANDOFF_ERR= + # What the terminal looked like before the session took it. This is what + # gets put back if the session leaves it unusable, and it is exact -- the + # person's own settings, not a guessed `stty sane`. + if [[ -t 0 ]]; then + SK_HANDOFF_MODES=$(command stty -g <&0 2>/dev/null || true) + fi + if [[ ! -t 2 && -t 0 && -t 1 ]]; then + exec {SK_HANDOFF_ERR}>&2 + exec 2>&1 + fi +} + +# Put stderr back where the caller had it. Called on every exit path, +# including the traps, so an interrupted `sp` cannot leave the FIFO bypassed. +sk_handoff_unbind() { + if [[ -n ${SK_HANDOFF_ERR:-} ]]; then + exec 2>&"$SK_HANDOFF_ERR" + exec {SK_HANDOFF_ERR}>&- + SK_HANDOFF_ERR= + fi + return 0 +} + +# The net: after the session has handed the terminal back, check that a person +# can actually use it, and put it right if they cannot. +# +# This is the `stty raw -echo` correction the operator had to run from another +# window on 2026-08-15, done from inside the handoff instead. It runs ONLY +# after the client has exited -- never while a client owns the terminal, so it +# can never fight one -- and it does nothing at all, and says nothing, when the +# terminal comes back the way it went in. That is the common case and it costs +# one `stty -g`. +# +# When it does fire it SHOUTS, because a net that catches silently destroys the +# evidence for whoever is still hunting the cause. +sk_repair_handoff_terminal() { + local client_status=${1:-0} + [[ -n ${SK_HANDOFF_MODES:-} && -t 0 ]] || return 0 + local now + now=$(command stty -g <&0 2>/dev/null) || return 0 + [[ $now != "$SK_HANDOFF_MODES" ]] || return 0 + # `stty -g` is iflag:oflag:cflag:lflag:cc... in hex on Linux. Field four is + # the local flags, where ECHO is 0x8 and ICANON is 0x2 -- the two a person + # needs to type at a shell prompt. A format this cannot read (macOS prints + # `gfmt1:...`) is left alone rather than guessed at. + local rest=${now#*:} lflag + rest=${rest#*:} + rest=${rest#*:} + lflag=${rest%%:*} + [[ $lflag =~ ^[0-9a-fA-F]+$ ]] || return 0 + local echo_on=0 icanon_on=0 + (( 0x$lflag & 0x8 )) && echo_on=1 + (( 0x$lflag & 0x2 )) && icanon_on=1 + if (( echo_on && icanon_on )); then + return 0 + fi + command stty "$SK_HANDOFF_MODES" <&0 2>/dev/null || return 0 + sk_shout_handoff_repair "$lflag" "$echo_on" "$icanon_on" "$client_status" + return 0 +} + +# One line on the screen so the person knows what happened, one record with the +# measurements so the next investigator has something to work from. +sk_shout_handoff_repair() { + local lflag=$1 echo_on=$2 icanon_on=$3 client_status=$4 + local tty_name pgrp members stamp + tty_name=$(command tty <&0 2>/dev/null || true) + pgrp=$(command ps -o tpgid= -p $$ 2>/dev/null | command tr -cd '0-9' || true) + members=$(command ps -e -o pgid=,pid=,comm= 2>/dev/null \ + | command awk -v want="${pgrp:-none}" \ + '$1 == want { printf "%s:%s ", $2, $3 }' || true) + stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true) + printf 'session-kit: that session left this terminal unusable (no echo or no line mode); your input modes have been restored. Recorded in %s\n' \ + "$SK_STATE_DIR/handoff-repair.log" >&2 + printf '%s\tsession=%s\ttty=%s\tlflag=0x%s\tECHO=%s\tICANON=%s\tfg_pgrp=%s\tmembers=%s\tclient_status=%s\trestored=%s\n' \ + "${stamp:-unknown}" "${SK_HANDOFF_SESSION:-unknown}" \ + "${tty_name:-unknown}" "$lflag" "$echo_on" "$icanon_on" \ + "${pgrp:-unknown}" "${members:-none}" "$client_status" \ + "$SK_HANDOFF_MODES" \ + >> "$SK_STATE_DIR/handoff-repair.log" 2>/dev/null || true + sk_log_action handoff_repair fired || true + return 0 +} + +# Release everything the guard holds. Two entry points, on purpose: +# `sk_handoff_guard_release` -- the client has exited and was waited on, so +# the terminal is ours to inspect and repair. +# `sk_handoff_guard_abandon` -- a trap fired. A client may still be running, +# so stderr goes back and NOTHING touches the terminal: the one rule this +# net must never break is fighting a live client for the line discipline. +sk_handoff_guard_release() { + local client_status=${1:-0} + sk_handoff_unbind + sk_repair_handoff_terminal "$client_status" + SK_HANDOFF_SESSION= + SK_HANDOFF_MODES= + return 0 +} + +sk_handoff_guard_abandon() { + sk_handoff_unbind + SK_HANDOFF_SESSION= + SK_HANDOFF_MODES= + return 0 +} + +assert_input_modes() { + # shpool's restore replays screen contents only, never the input modes the + # application enabled, so a freshly opened window loses bracketed paste on + # reattach: terminals flag multi-line pastes as unsafe and every newline + # submits early. Re-arm bracketed paste before handing the terminal to + # shpool; the restore buffer carries no mode resets, so this survives the + # replay. The optional shpool-patch/0002 makes the daemon replay the full + # tracked mode state itself. + if [[ -t 1 ]]; then + printf '\033[?2004h' + fi +} + +# Refill the terminal's scrollback before shpool takes the screen (operator +# finding 2026-08-14). Restore mode "simple" repaints only the live frame, so +# a freshly opened window starts with no history at all -- the operator could +# scroll back "only a couple screens". Replay the tail of the session's +# SETTLED rendered journal: the same clean text `sp history` pages, never raw +# bytes (raw TUI repaints braid -- operator finding 2026-08-12). Settled-only +# also keeps the joint clean: the application repaints its own live frame +# after attach, so replaying the unsettled screen here would show every frame +# twice. Best-effort by design -- no journal, no renderer, a failed or slow +# render, or a non-terminal stdout all skip the refill rather than delay or +# block the attach; a sidecar the renderer could not freshen is replayed +# stale, which for scrollback still beats nothing. +sk_replay_history() { + local id=$1 lines=${SESSION_KIT_ATTACH_HISTORY:-500} + [[ -t 1 && -n $id ]] || return 0 + case $lines in '' | *[!0-9]*) lines=500 ;; esac + # All digits from here, but not yet a safe number: a leading zero sends + # bash arithmetic into octal ("08" printed an error onto the operator's + # terminal) and a value past 9 digits can wrap 64-bit arithmetic negative. + # Both must end as a sane bound, never an error (lane finding F3). + if (( ${#lines} > 9 )); then lines=999999999; fi + lines=$((10#$lines)) + (( lines > 0 )) || return 0 + local -a files=() + local path + while IFS= read -r path; do [[ -n $path ]] && files+=("$path"); done \ + < <(history_files "$id") + (( ${#files[@]} > 0 )) || return 0 + local journal sidecar state render_tool + if [[ -d $SK_JOURNAL_DIR/$id ]]; then + journal=$SK_JOURNAL_DIR/$id + sidecar=$journal/rendered.txt + state=$journal/rendered.state.json + else + journal=${files[0]} + sidecar=${journal%.raw}.rendered.txt + state=${journal%.raw}.rendered.state.json + fi + if sk_test_hook SESSION_KIT_JOURNAL_RENDER_TOOL; then + render_tool=$SK_TEST_HOOK + else + render_tool=${SK_INVENTORY_CORE%/*}/sessionkit_inventory/journal_render.py + fi + if [[ -f $render_tool ]]; then + # One renderer per sidecar at a time: journal_render.py takes no lock of + # its own, and two concurrent renders append the same settled lines twice + # -- permanently, because the state file commits the inflated length + # (lane finding O1). Contended means someone else is freshening this + # sidecar right now; skip and replay whatever it already holds. The + # timeout wrapper kills the whole process group, so the renderer never + # outlives the cap holding the lock. util-linux flock(1) is deliberately + # absent on macOS (sk_lock_acquire branches the same way): without it, + # render unlocked -- the pre-lock behaviour -- rather than losing the + # refill to a missing locker (lane finding F4). + local -a render_locker=() + if command -v flock >/dev/null 2>&1; then + render_locker=(flock -n "$sidecar.lock") + fi + sk_timeout 4 ${render_locker[@]+"${render_locker[@]}"} \ + python3 "$render_tool" render \ + --journal "$journal" --out "$sidecar" --state "$state" \ + >/dev/null 2>&1 || true + fi + [[ -r $sidecar && -s $sidecar ]] || return 0 + command tail -n "$lines" -- "$sidecar" 2>/dev/null || return 0 + printf '\n%s\n' \ + '── earlier output above (rendered) · complete record: sp history ──' + # Push the replay fully into scrollback before the application repaints. + # A TUI redraws its frame relative to the current cursor, so without this + # pad it paints OVER the tail of the replayed block and the visible screen + # becomes interleaved mush (operator finding X19-b, 2026-08-14: a + # transferred session showed "over written garbage"). A screenful of blank + # rows gives the repaint a clean region; the replay stays intact above it. + # Ask the terminal itself, through a dup of stdout taken OUTSIDE the + # command substitution. Inside $( ) stdout is a pipe, so tput cannot ioctl + # the terminal and silently answers the terminfo constant 24 on every + # window (lane finding X19-c: the pad was 24 rows on a 120-row window, + # leaving 96 rows of replay for the TUI to paint over). stty reads the fd + # it is given, needs no terminfo, and stays silent on failure. + local pad_rows sk_pad_tty + exec {sk_pad_tty}>&1 + pad_rows=$(command stty size <&"$sk_pad_tty" 2>/dev/null) || pad_rows="" + exec {sk_pad_tty}>&- + pad_rows=${pad_rows%% *} + case $pad_rows in '' | *[!0-9]*) pad_rows=50 ;; esac + (( ${#pad_rows} > 4 )) && pad_rows=200 + pad_rows=$((10#$pad_rows)) + # A zero or tiny reported height (an unsized pty) still needs a real pad. + (( pad_rows >= 5 )) || pad_rows=50 + (( pad_rows > 200 )) && pad_rows=200 + local padding="" + local -i pad_index + for (( pad_index = 0; pad_index < pad_rows; pad_index++ )); do + padding+=$'\n' + done + printf '%s' "$padding" +} + +attach_id() { + local id=$1 cwd=${2:-} force=${3:-0} + local -a args=(attach) + [[ $force == 1 ]] && args+=(--force) + args+=(--cmd /bin/false) + [[ -z $cwd ]] || args+=(--dir "$cwd") + args+=("$id") + # First, before anything that tests -t 1: the refill and the bracketed-paste + # re-arm below are both skipped on a non-terminal stdout, and the client + # itself refuses raw mode on a non-terminal ANY of the three. + sk_bind_handoff_tty + sk_push_session_color "${SK_PROVIDER:-}" "${SK_UUID:-}" + # `sp go` / `sp attach` / `sp takeover` typed at a shell end here; the + # picker's own open and takeover end at picker_attach_id in sp_picker.sh. + # Both doors write the kit's tab name and both refill scrollback -- a + # change to one belongs in the other (X19 lane finding F1 was exactly + # this pair drifting apart). + sk_tab_title "$(sk_human_label "${SK_TITLE:-}" "${SK_PROVIDER:-}" "${SK_NUMBER:-}")" + sk_replay_history "$id" + assert_input_modes + # `exec` replaces this process, so `trap cleanup_snapshot EXIT` never fires: + # every `sp go` and every `sp takeover` left a ~30 KB snapshot behind, forever + # -- the only leak class with no upper bound. Dropping it here covers all + # three callers rather than each remembering separately. + cleanup_snapshot + if [[ $force == 1 ]] && + ! sk_shpool_holder_is "$SK_DAEMON_PID" "$SK_DAEMON_START"; then + sk_die "the socket author changed before takeover; nothing was moved" + return 1 + fi + exec "$SK_SHPOOL" "${args[@]}" +} + +# One selector, one refusal, one status. Every verb that takes a <session> +# lands here, so a wrong number reads the same whichever verb typed it, and +# `sp help exit-codes` can say 2 and mean it. +resolve_target() { + local selector=$1 + make_snapshot || return 1 + sk_resolve "$SNAPSHOT" "$selector" || { + sk_die "no session matches that selector" + return 2 + } +} + +history_files() { + local id=$1 recovered + recovered=$(sk_recovery_journal "$id" 2>/dev/null || true) + if [[ -n $recovered && -r $recovered ]]; then + printf '%s\n' "$recovered" + elif [[ -r $SK_JOURNAL_DIR/$id.raw ]]; then + printf '%s\n' "$SK_JOURNAL_DIR/$id.raw" + elif [[ -d $SK_JOURNAL_DIR/$id ]]; then + find "$SK_JOURNAL_DIR/$id" -maxdepth 1 -type f -name 'segment-*.raw' -print | sort + fi +} diff --git a/lib/sh/sp_picker.sh b/lib/sh/sp_picker.sh new file mode 100644 index 0000000..809326b --- /dev/null +++ b/lib/sh/sp_picker.sh @@ -0,0 +1,1035 @@ +#!/usr/bin/env bash +# sp actions the login picker calls with a proof: attach, open, take over, +# close, recover, history, alias, and fork. Every one of these revalidates the +# picker proof under the create lock before it changes anything. Source this +# file; do not execute it. +# +# Source order: bin/sp sources this module ahead of its first trap, after +# SCRIPT_DIR, session_kit_common, INVENTORY_CORE, PICKER_REFUSED_STATUS, and +# PICKER_ATTACH_FAILED_STATUS are in place. These functions call the snapshot +# and attach helpers in lib/sh/sp_core.sh, the provider bounce in +# lib/sh/sp_provider_bounce.sh, and the session builders in +# lib/sh/sp_sessions.sh. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +picker_attach_id() { + local id=$1 cwd=${2:-} force=${3:-0} + local -a args=(attach) + [[ $force == 1 ]] && args+=(--force) + args+=(--cmd /bin/false) + [[ -z $cwd ]] || args+=(--dir "$cwd") + args+=("$id") + # The same terminal binding attach_id does, for the same reason: this is the + # door the login menu and the TUI picker attach through, and BOTH known ways + # of handing down a non-terminal descriptor reach it -- the stale /dev/null + # left on the picker's stderr by shpool_login_live.sh:376, and the TUI + # launcher's stderr FIFO (bin/shpool_login_launcher:75). X19 lane finding F1 + # was this pair drifting apart -- a change to one belongs in the other. + # + # SCOPED here, unlike attach_id: that one execs, so its binding dies with the + # process, while this one returns and an unscoped binding outlived the attach + # -- everything `sp` printed afterwards bypassed the launcher's capture and + # landed on the screen out of order. Armed here, released on every exit path + # below and in `sp`'s traps. + sk_handoff_guard_arm "$id" + sk_push_session_color "${SK_PROOF_PROVIDER:-}" "${SK_PROOF_UUID:-}" + # The picker's own open and takeover come through here rather than through + # attach_id, so the kit's tab name is written here too -- with the same kill + # switch and the same scrub, instead of a raw write that honoured neither. + sk_tab_title "$(sk_human_label "${SK_TITLE:-}" "${SK_PROVIDER:-}" "${SK_NUMBER:-}")" + # The scrollback refill must cover this door too: the login menu and the TUI + # picker attach through here, not through attach_id (X19 lane finding F1). + # Before the first attempt, so the 0.7s retry below never replays twice. + sk_replay_history "$id" + assert_input_modes + if [[ $force == 1 ]] && + ! sk_shpool_holder_is "$SK_PROOF_DAEMON_PID" "$SK_PROOF_DAEMON_START"; then + sk_handoff_guard_release 1 + sk_die "the socket author changed before takeover; nothing was moved" + return 1 + fi + if "$SK_SHPOOL" "${args[@]}"; then + sk_handoff_guard_release 0 + return 0 + fi + # One retry after a beat: takeovers and just-released sessions race the + # daemon's teardown of the previous client, and the action log shows those + # first attempts failing regularly. + sleep 0.7 + assert_input_modes + local sk_attach_rc=0 + if [[ $force == 1 ]] && + ! sk_shpool_holder_is "$SK_PROOF_DAEMON_PID" "$SK_PROOF_DAEMON_START"; then + sk_handoff_guard_release 1 + sk_die "the socket author changed before takeover retry; nothing was moved" + return 1 + fi + "$SK_SHPOOL" "${args[@]}" || sk_attach_rc=$? + # The client has exited and been waited on, so the terminal is ours to check. + # A session that died badly leaves it raw -- unusable, no echo -- and this is + # where that gets put right instead of the person discovering it. + sk_handoff_guard_release "$sk_attach_rc" + return $sk_attach_rc +} + +picker_action_event() { + sk_log_action "$1" "$2" || true +} + +picker_load_fresh() { + local proof=$1 + sk_prepare_state || return 1 + sk_load_picker_proof "$proof" || return 1 + make_guard_snapshot || { + sk_die "guard live inventory unavailable; nothing changed" + return 1 + } + sk_picker_proof_matches "$SNAPSHOT" || { + sk_die "displayed session changed; nothing changed" + return 1 + } +} + +picker_lock() { + exec 9>"$SK_STATE_DIR/create.lock" || return 1 + sk_lock_acquire 9 "$SK_STATE_DIR/create.lock" || { + sk_lock_release 9 + return 1 + } +} + +picker_revalidate_locked() { + make_guard_snapshot || return 1 + sk_picker_proof_matches "$SNAPSHOT" +} + +picker_refresh_title() { + local proof=$1 + picker_action_event title_refresh requested + picker_load_fresh "$proof" || { + picker_action_event title_refresh refused + return "$PICKER_REFUSED_STATUS" + } + if picker_bounce_codex "$SK_ID" explicit; then + printf 'Restarted the idle Codex provider for %s; its conversation and shell were preserved.\n' \ + "$(sk_human_label "$SK_TITLE" "$SK_PROVIDER")" + return 0 + fi + if picker_bounce_claude "$SK_ID" explicit; then + printf 'Restarted the idle Claude provider for %s; its conversation and shell were preserved.\n' \ + "$(sk_human_label "$SK_TITLE" "$SK_PROVIDER")" + return 0 + fi + picker_action_event title_refresh refused + sk_die "the provider title is not pending or the provider is not proven idle or awaiting a reply with no subagents; nothing restarted" + return "$PICKER_REFUSED_STATUS" +} + +# The picker already proved the exact displayed row. Reuse the public model +# verb for every safety check and for the actual restart; this adapter only +# converts the picker's private proof into that verb's exact selector. +picker_change_model() { + local proof=$1 requested=$2 + picker_action_event model_change requested + picker_load_fresh "$proof" || { + picker_action_event model_change refused + return "$PICKER_REFUSED_STATUS" + } + local id=$SK_ID + local status=0 + SESSION_KIT_CONFIRM_ID=$id change_model_target "$id" "$requested" || status=$? + if (( status == 0 )); then + picker_action_event model_change returned + else + picker_action_event model_change failed + fi + return "$status" +} + +picker_open() { + local proof=$1 + picker_action_event picker_open requested + picker_load_fresh "$proof" || { + picker_action_event picker_open refused + return "$PICKER_REFUSED_STATUS" + } + [[ $SK_STATUS == disconnected || $SK_STATUS == Disconnected ]] || { + sk_die "$(sk_human_label "$SK_TITLE" "$SK_PROVIDER") is already open in another window; nothing changed" + picker_action_event picker_open state_changed + return "$PICKER_REFUSED_STATUS" + } + picker_lock || { + picker_action_event picker_open refused + return "$PICKER_REFUSED_STATUS" + } + if ! picker_revalidate_locked || + [[ $SK_STATUS != disconnected && $SK_STATUS != Disconnected ]]; then + sk_lock_release 9 + sk_die "displayed session changed before open; nothing changed" + picker_action_event picker_open state_changed + return "$PICKER_REFUSED_STATUS" + fi + local id=$SK_ID cwd=$SK_CWD + # Keeping this descriptor across exec would block all managed creation for + # the lifetime of the attachment. Release it immediately before the guarded + # name-only request; --cmd /bin/false prevents a persistent raced replacement. + sk_lock_release 9 + # A name first: it is the common case and it applies the colour too, since + # the window restarts either way. If no name can be applied but a recolour + # is still waiting -- the session was busy or attached when `sp color` asked + # -- the colour gets its own restart, which is the one an unnamed session + # could never get. + if ! picker_bounce_codex "$id" automatic && ! picker_bounce_claude "$id" automatic; then + if [[ -f $SK_STATE_DIR/provider-recolor/$id ]]; then + picker_bounce_codex "$id" automatic color || + picker_bounce_claude "$id" automatic color || true + fi + fi + if picker_attach_id "$id" "$cwd"; then + picker_action_event picker_open returned + return 0 + fi + picker_action_event picker_open attach_failed + sk_die "the session manager could not connect to the selected session; its cause was not identified and nothing was called dead" + return "$PICKER_ATTACH_FAILED_STATUS" +} + +picker_takeover() { + local proof=$1 + picker_action_event picker_takeover requested + picker_load_fresh "$proof" || { + picker_action_event picker_takeover refused + return "$PICKER_REFUSED_STATUS" + } + [[ $SK_STATUS != disconnected && $SK_STATUS != Disconnected ]] || { + sk_die "$(sk_human_label "$SK_TITLE" "$SK_PROVIDER") is available; use open instead" + picker_action_event picker_takeover state_changed + return "$PICKER_REFUSED_STATUS" + } + local id=$SK_ID title=$SK_TITLE provider=$SK_PROVIDER + sk_confirm_exact "Move session to this window" "$id" "$title" "$provider" \ + "${SK_NUMBER:-}" || { + echo "Nothing changed." + picker_action_event picker_takeover cancelled + return "$PICKER_REFUSED_STATUS" + } + picker_lock || { + picker_action_event picker_takeover refused + return "$PICKER_REFUSED_STATUS" + } + if ! picker_revalidate_locked || + [[ $SK_STATUS == disconnected || $SK_STATUS == Disconnected ]]; then + sk_lock_release 9 + sk_die "displayed session changed while confirming; nothing changed" + picker_action_event picker_takeover state_changed + return "$PICKER_REFUSED_STATUS" + fi + local cwd=$SK_CWD + sk_lock_release 9 + if picker_attach_id "$id" "$cwd" 1; then + picker_action_event picker_takeover returned + return 0 + fi + picker_action_event picker_takeover attach_failed + sk_die "the session manager could not move the selected session; the other window was not described as dead" + return "$PICKER_ATTACH_FAILED_STATUS" +} + +picker_close() { + local proof=$1 + picker_action_event picker_close requested + # The final refusal block intentionally handles failure from any proof step. + # shellcheck disable=SC2015 + sk_prepare_state && sk_load_picker_proof "$proof" && make_guard_snapshot && + { sk_picker_proof_matches "$SNAPSHOT" || + sk_picker_proof_matches_dead_shell "$SNAPSHOT"; } || { + sk_die "displayed session changed; nothing changed" + picker_action_event picker_close refused + return 1 + } + local id=$SK_ID title=$SK_TITLE provider=$SK_PROVIDER + sk_confirm_exact "Close session and everything running inside it" \ + "$id" "$title" "$provider" "${SK_NUMBER:-}" || { + echo "Nothing changed." + picker_action_event picker_close cancelled + return 1 + } + picker_lock || return 1 + if ! picker_revalidate_locked && + ! sk_picker_proof_matches_dead_shell "$SNAPSHOT"; then + sk_lock_release 9 + sk_die "displayed session changed while confirming; nothing closed" + picker_action_event picker_close state_changed + return 1 + fi + local shell_outcome manager_cleanup=0 + if ! shell_outcome=$(sk_terminate_exact_shell \ + "$SK_PROOF_DAEMON_PID" "$SK_PROOF_DAEMON_START" \ + "$SK_PROOF_SHELL_PID" "$SK_PROOF_SHELL_START" 2>&1); then + sk_cleanup_dead_shpool_entry "$id" \ + "$SK_PROOF_DAEMON_PID" "$SK_PROOF_DAEMON_START" \ + "$SK_PROOF_SHELL_PID" "$SK_PROOF_SHELL_START" \ + "$SK_PROOF_STARTED" || true + sk_lock_release 9 + sk_die "the exact displayed shell was not terminated ($shell_outcome)" + picker_action_event picker_close failed + return 1 + fi + sk_cleanup_dead_shpool_entry "$id" \ + "$SK_PROOF_DAEMON_PID" "$SK_PROOF_DAEMON_START" \ + "$SK_PROOF_SHELL_PID" "$SK_PROOF_SHELL_START" \ + "$SK_PROOF_STARTED" || manager_cleanup=$? + sk_lock_release 9 + # "Everything running inside it" must be TRUE: a provider wedged on a + # terminal write survives the shell's SIGHUP and lingers outside shpool, + # blocking any later reopen of the same conversation. Reap it under its + # exact pinned identity (identity-bound no-op when it exited normally). + sk_reap_session_leftovers \ + "${SK_PROOF_PROVIDER_PID:-}" "${SK_PROOF_PROVIDER_START:-}" \ + "${SK_PROOF_SHELL_PID:-}" "${SK_PROOF_SHELL_START:-}" + # Somebody chose this. The crash queue must never offer it back as work + # that was lost, a tombstone says the difference out loud, and the close + # itself is already done, so a store that cannot be written costs a + # diagnostic, never the close. + # + # The diagnostic has to reach THEM, not just the action log. An event nobody + # reads is how `k` printed a bare "Closed …" over a conversation that had + # landed on neither surface (found in review, 2026-08-15). + if [[ ${SK_PROOF_PROVIDER:-} == claude || ${SK_PROOF_PROVIDER:-} == codex ]] && + [[ -n ${SK_PROOF_UUID:-} ]]; then + python3 "$INVENTORY_CORE" close-intent record \ + "$SK_PROOF_PROVIDER" "$SK_PROOF_UUID" >/dev/null 2>&1 || { + picker_action_event picker_close intent_unrecorded + # The provider transcript survives, but `sp recover` validates the same + # broken Closed ledger and may refuse its whole list. Say that boundary + # rather than promising an offer the command cannot print. + sk_die "the session closed, but it could not be added to Closed sessions, the provider conversation remains on disk, but repair the Closed sessions ledger before relying on \`sp recover\`" || true + } + else + # A shell close is ledgered too, so `sp recover` lists it (history only). + python3 "$INVENTORY_CORE" closed-sessions record shell --session "$id" \ + >/dev/null 2>&1 || { + picker_action_event picker_close intent_unrecorded + sk_die "the session closed, but it could not be added to Closed sessions" || true + } + fi + # A delegated session was given its own copy of the code; the close gives it + # back, and says so when it keeps one because of unmerged work. + sk_release_worktree "$id" + if (( manager_cleanup != 0 )); then + picker_action_event picker_close manager_entry_remains + sk_die "the exact shell closed, but manager entry $id remains; Session Kit could not finish the close" + return 1 + fi + picker_action_event picker_close closed + printf 'Closed %s\n' "$(sk_human_label "$title" "$SK_PROOF_PROVIDER")" +} + +# Shared tail after an exact disconnected session has been killed while the +# create lock was held. Reap anything it left holding the old terminal, then +# relaunch the same exact conversation. `restore_exact` takes the lock itself. +# True while an exact conversation is still visible anywhere in the live +# inventory, including as a process outside shpool. +conversation_is_active() { + local provider=$1 uuid=$2 stale_id=${3:-} + make_guard_snapshot || return 2 + local answer + answer=$(python3 -c ' +import json,sys +d=json.load(open(sys.argv[1])) +wanted_provider=sys.argv[2] +wanted_uuid=sys.argv[3].lower() +stale_id=sys.argv[4] +rows=[ + row for row in d.get("sessions",[]) + if not stale_id or row.get("shpool_id_raw") != stale_id +] +rows+=d.get("outside_agents",[]) +print("yes" if any( + isinstance(row,dict) + and row.get("provider")==wanted_provider + and isinstance((row.get("identity") or {}).get("uuid"),str) + and row["identity"]["uuid"].lower()==wanted_uuid + for row in rows +) else "no") +' "$SNAPSHOT" "$provider" "$uuid" "$stale_id") || return 2 + [[ $answer == yes ]] +} + +finish_recovery_after_kill() { + local id=$1 provider=$2 uuid=$3 cwd=$4 + local shell_pid=$5 shell_start=$6 provider_pid=$7 provider_start=$8 + local origin SK_RESTORE_IGNORE_STALE_ID=$id + # An unstamped session declares NOTHING rather than declaring "human": a + # declaration outranks the closed-session record, so inventing one here + # would overwrite what the ledger knows about the conversation being + # reopened. Empty leaves the restore to read that record as usual. + origin=$(sk_session_origin "$SNAPSHOT" "$id") || origin= + sk_reap_session_leftovers \ + "$provider_pid" "$provider_start" \ + "$shell_pid" "$shell_start" + # A provider wedged on a terminal write can outlive its shell by a few + # seconds. Relaunching while its identity is still visible would be refused + # as a duplicate conversation -- which would leave the session closed and + # nothing brought back. Wait for it to disappear first. + local attempt + for attempt in {1..60}; do + conversation_is_active "$provider" "$uuid" "$id" || break + sleep 1 + done + if conversation_is_active "$provider" "$uuid" "$id"; then + sk_die "the session was closed, but its conversation is still held by a live process; run 'sp recover' once it exits" + return 1 + fi + SESSION_KIT_ORIGIN=$origin restore_exact "$provider" "$uuid" "$cwd" +} + +# Picker path: the displayed session could not be opened. Bound to the private +# proof, so it can only ever act on the exact row the user selected. +picker_recover() { + local proof=$1 + picker_action_event picker_recover requested + sk_require_integration || { + picker_action_event picker_recover refused + return 1 + } + picker_load_fresh "$proof" || { + picker_action_event picker_recover refused + return 1 + } + [[ $SK_PROOF_PROVIDER == claude || $SK_PROOF_PROVIDER == codex ]] || { + sk_die "only an exact Claude or Codex conversation can be moved to a new session" + return 1 + } + local provider=$SK_PROOF_PROVIDER uuid=$SK_PROOF_UUID + [[ $uuid =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || { + sk_die "recovery requires an exact canonical conversation UUID" + return 1 + } + picker_lock || return 1 + if ! picker_revalidate_locked; then + sk_lock_release 9 + sk_die "displayed session changed before recovery; nothing changed" + picker_action_event picker_recover state_changed + return 1 + fi + if [[ $SK_STATUS != disconnected && $SK_STATUS != Disconnected ]]; then + sk_lock_release 9 + sk_die "the selected session is open in another window; recovery refused and nothing changed" + picker_action_event picker_recover attached + return 1 + fi + local id=$SK_ID cwd=$SK_CWD + local shell_pid=$SK_PROOF_SHELL_PID shell_start=$SK_PROOF_SHELL_START + local provider_pid=$SK_PROOF_PROVIDER_PID provider_start=$SK_PROOF_PROVIDER_START + [[ $cwd == /* && -d $cwd ]] || { + sk_lock_release 9 + sk_die "session directory is not an existing absolute directory" + return 1 + } + cwd=$(cd -- "$cwd" && pwd -P) || { + sk_lock_release 9 + return 1 + } + local shell_outcome + if ! shell_outcome=$(sk_terminate_exact_shell \ + "$SK_PROOF_DAEMON_PID" "$SK_PROOF_DAEMON_START" \ + "$shell_pid" "$shell_start" 2>&1); then + sk_cleanup_dead_shpool_entry "$id" \ + "$SK_PROOF_DAEMON_PID" "$SK_PROOF_DAEMON_START" \ + "$shell_pid" "$shell_start" "$SK_PROOF_STARTED" || true + sk_lock_release 9 + sk_die "the exact disconnected shell was not terminated; nothing changed ($shell_outcome)" + picker_action_event picker_recover failed + return 1 + fi + sk_cleanup_dead_shpool_entry "$id" \ + "$SK_PROOF_DAEMON_PID" "$SK_PROOF_DAEMON_START" \ + "$shell_pid" "$shell_start" "$SK_PROOF_STARTED" || { + sk_lock_release 9 + return 1 + } + sk_lock_release 9 + if finish_recovery_after_kill "$id" "$provider" "$uuid" "$cwd" \ + "$shell_pid" "$shell_start" "$provider_pid" "$provider_start"; then + picker_action_event picker_recover recovered + return 0 + fi + picker_action_event picker_recover failed + echo "The original session was already closed. The conversation is intact on disk." >&2 + echo "Retry with: sp restore-exact $provider $uuid $cwd" >&2 + return 1 +} + +picker_history() { + local proof=$1 + picker_load_fresh "$proof" || return 1 + # sk_load_picker_proof, called above, publishes this sourced-module field. + # shellcheck disable=SC2153 + show_history_id "$SK_PROOF_ID" +} + +account_switch_stable_snapshot() { + local snapshot_path=$1 id=$2 uuid=$3 provider=$4 expected_alias=${5:-} + python3 - "$snapshot_path" "$id" "$uuid" "$provider" "$expected_alias" <<'PY' +import json +import sys + +data=json.load(open(sys.argv[1], encoding="utf-8")) +matches=[row for row in data.get("sessions", []) if row.get("shpool_id_raw")==sys.argv[2]] +if len(matches) != 1: + raise SystemExit(1) +row=matches[0] +identity=row.get("identity") or {} +if ( + row.get("provider") != sys.argv[4] + or identity.get("uuid") != sys.argv[3] + or row.get("mutation_allowed") is not True + or row.get("subagents") + or int(row.get("active_subagent_count") or 0) != 0 +): + raise SystemExit(1) +if sys.argv[5] and ( + row.get("account_alias") != sys.argv[5] + or row.get("account_binding_mismatch") is True +): + raise SystemExit(1) +if str(row.get("agent_status") or "").casefold() not in { + "idle", "needs your reply", "reply optional" +}: + raise SystemExit(1) +age=row.get("recent_output_age_seconds") +if isinstance(age,int) and not isinstance(age,bool) and age < 5: + raise SystemExit(1) +PY +} + +account_switch_safe_tree() { + local provider=$1 shell_pid=$2 shell_start=$3 provider_pid=$4 provider_start=$5 + local table + table=$(mktemp "$SK_STATE_DIR/account-switch-processes.XXXXXX") || return 1 + if ! python3 "$INVENTORY_CORE" platform process-table >"$table" 2>/dev/null || + ! python3 - "$table" "$provider" "$shell_pid" "$shell_start" \ + "$provider_pid" "$provider_start" <<'PY' +import json +import os +import sys + +path,provider,shell_pid,shell_start,provider_pid,provider_start=sys.argv[1:] +shell_pid, shell_start, provider_pid, provider_start=map( + int, (shell_pid,shell_start,provider_pid,provider_start) +) +rows=json.load(open(path, encoding="utf-8")).get("processes",[]) +processes={row.get("pid"):row for row in rows if isinstance(row,dict)} +if processes.get(shell_pid,{}).get("start_ticks") != shell_start: + raise SystemExit(1) +if processes.get(provider_pid,{}).get("start_ticks") != provider_start: + raise SystemExit(1) +children={} +for pid,row in processes.items(): + children.setdefault(row.get("ppid"),[]).append(pid) +queue=list(children.get(shell_pid,[])) +seen=set() +while queue: + pid=queue.pop() + if pid in seen: + continue + seen.add(pid) + queue.extend(children.get(pid,[])) +for pid in seen: + row=processes.get(pid,{}) + argv=[str(value) for value in (row.get("cmdline") or [])] + executable=os.path.basename(argv[0]) if argv else str(row.get("comm") or "") + args=" ".join(argv[1:]) + if pid == provider_pid: + continue + allowed=( + executable in { + "script", + "mcp@latest", + "npm exec @playwright/mcp@latest", + "codex-code-mode-host", + } + or (executable in {"bash", "-bash"} and not any(value in {"-c", "-lc"} for value in argv[1:])) + or (executable == "shpool" and "attach" in argv and "--cmd" in argv) + or (executable == "node" and ("/codex" in args or "playwright-mcp" in args)) + or (provider == "codex" and executable == "codex") + or (executable in {"python", "python3"} and "provider_broker.py" in args) + # A provider's own hook children: Claude Code spawns a project's + # .claude/hooks/ scripts itself, so one of them running is the + # provider working, not an unrecognized background job. Refusing on + # them made a switch impossible in any project with a resident hook. + or (executable in {"python", "python3"} and "/.claude/hooks/" in args) + ) + if not allowed: + raise SystemExit(1) +PY + then + command rm -f -- "$table" + return 1 + fi + command rm -f -- "$table" +} + +account_switch_request() { + local id=$1 action=$2 txid=$3 alias=$4 fallback=$5 uuid=$6 + local started=$7 shell_pid=$8 shell_start=$9 + local model=${10:-} provider=${11:-} validated= + local directory="$SK_STATE_DIR/account-switch-requests" + [[ $id =~ ^(main([1-9][0-9]*)?|s[0-9]{8}-[0-9]{6}-[1-9][0-9]*(-[1-9][0-9]*)?)$ && + $action =~ ^(apply|rollback)$ && $txid =~ ^[0-9a-f]{32}$ && + $alias =~ ^[a-z][a-z0-9_-]{0,11}$ && + $fallback =~ ^[a-z][a-z0-9_-]{0,11}$ && + $uuid =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ && + $started =~ ^[1-9][0-9]*$ && $shell_pid =~ ^[1-9][0-9]*$ && + $shell_start =~ ^[1-9][0-9]*$ && $provider =~ ^(claude|codex)$ && + $model != *[$'\t\r\n\034']* ]] || return 1 + if [[ -n $model ]]; then + validated=$(python3 "$INVENTORY_CORE" validate-worker-model \ + "$provider" "$model" 2>/dev/null) || return 1 + [[ $validated == "$model" ]] || return 1 + fi + umask 077 + mkdir -p -- "$directory" || return 1 + chmod 700 -- "$directory" || return 1 + local temporary="$directory/.${id}.$$" + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$action" "$txid" "$alias" "$fallback" "$uuid" "$started" \ + "$shell_pid" "$shell_start" "$model" > "$temporary" || return 1 + chmod 600 -- "$temporary" || return 1 + mv -f -- "$temporary" "$directory/$id" +} + +account_switch_signal() { + local provider=$1 provider_pid=$2 provider_start=$3 shell_pid=$4 shell_start=$5 + local signal_pid=$provider_pid signal_start=$provider_start target + if [[ $provider == codex ]]; then + target=$(python3 "$INVENTORY_CORE" platform codex-refresh-target \ + "$shell_pid" "$shell_start" "$provider_pid" "$provider_start" 2>/dev/null) || return 1 + IFS=$'\t' read -r signal_pid signal_start <<<"$target" + fi + python3 "$INVENTORY_CORE" platform process-is \ + "$signal_pid" "$signal_start" "$provider" >/dev/null 2>&1 || return 1 + kill -TERM "$signal_pid" +} + +account_switch_wait_alias() { + local id=$1 provider=$2 uuid=$3 alias=$4 attempt fresh + for attempt in {1..150}; do + fresh=$(mktemp "$SK_STATE_DIR/account-switch-wait.XXXXXX") || return 1 + if sk_guard_snapshot_file "$fresh" 2>/dev/null && + python3 - "$fresh" "$id" "$provider" "$uuid" "$alias" <<'PY' +import json,sys +data=json.load(open(sys.argv[1], encoding="utf-8")) +rows=[row for row in data.get("sessions",[]) if row.get("shpool_id_raw")==sys.argv[2]] +if len(rows)!=1: + raise SystemExit(1) +row=rows[0] +identity=row.get("identity") or {} +raise SystemExit(0 if ( + row.get("provider")==sys.argv[3] + and identity.get("uuid")==sys.argv[4] + and row.get("account_alias")==sys.argv[5] + and row.get("account_switch_capable") is True +) else 1) +PY + then + command rm -f -- "$fresh" + return 0 + fi + command rm -f -- "$fresh" + sleep 0.2 + done + return 1 +} + +account_switch_restore_source() { + local id=$1 provider=$2 uuid=$3 source_alias=$4 target_alias=$5 txid=$6 cwd=$7 + # `--no-recreate` stops before the destructive last resort below. A person + # driving the picker is present and wants the conversation back whatever it + # costs, so the default is unchanged; an unattended caller must not close a + # live session and hope the recreate lands, because the promise it is held + # to is that a failed handoff leaves the operator still working. + # The opt-out is read from anywhere in the argument list, never from one + # fixed position. Carrying the model added an argument in front of it, and a + # positional read would have turned that refactor into a silent re-arming of + # the kill below -- with every source-text test still green, because the flag + # is still written at the call site. Position is not what makes it safe. + local model=${8:-} + local SK_RESTORE_IGNORE_STALE_ID= + local allow_recreate=1 argument + for argument in "$@"; do + [[ $argument != --no-recreate ]] || allow_recreate=0 + done + # An older caller that puts the flag where the model now goes must not have + # it forwarded onward as a model identifier. + [[ $model != --no-recreate ]] || model= + local fresh current_alias current_pid current_start current_shell_pid current_shell_start + local current_started + local current_daemon_pid current_daemon_start + fresh=$(mktemp "$SK_STATE_DIR/account-switch-rollback.XXXXXX") || return 1 + if sk_guard_snapshot_file "$fresh" 2>/dev/null && sk_resolve "$fresh" "$id" && + [[ $SK_PROVIDER == "$provider" && $SK_UUID == "$uuid" ]]; then + current_alias=$SK_ACCOUNT_ALIAS + current_started=$SK_STARTED + current_pid=$SK_PROVIDER_PID + current_start=$SK_PROVIDER_START + current_shell_pid=$SK_SHELL_PID + current_shell_start=$SK_SHELL_START + current_daemon_pid=$SK_DAEMON_PID + current_daemon_start=$SK_DAEMON_START + command rm -f -- "$fresh" + if [[ $current_alias == "$source_alias" ]]; then + return 0 + fi + if [[ $current_alias == "$target_alias" ]] && + account_switch_request "$id" rollback "$txid" "$source_alias" \ + "$target_alias" "$uuid" "$SK_STARTED" "$current_shell_pid" \ + "$current_shell_start" "$model" "$provider" && + account_switch_signal "$provider" "$current_pid" "$current_start" \ + "$current_shell_pid" "$current_shell_start" && + account_switch_wait_alias "$id" "$provider" "$uuid" "$source_alias"; then + return 0 + fi + else + command rm -f -- "$fresh" + fi + # The managed provider did not survive long enough to consume a rollback + # request. Recreate only this exact shell and conversation on the source. + (( allow_recreate )) || return 1 + [[ $current_shell_pid =~ ^[0-9]+$ && $current_shell_start =~ ^[0-9]+$ && + $current_daemon_pid =~ ^[0-9]+$ && $current_daemon_start =~ ^[0-9]+$ ]] || + return 1 + sk_terminate_exact_shell "$current_daemon_pid" "$current_daemon_start" \ + "$current_shell_pid" "$current_shell_start" >/dev/null 2>&1 || return 1 + sk_cleanup_dead_shpool_entry "$id" "$current_daemon_pid" "$current_daemon_start" \ + "$current_shell_pid" "$current_shell_start" "$current_started" || return 1 + SK_RESTORE_IGNORE_STALE_ID=$id + sk_reap_session_leftovers "${current_pid:-}" "${current_start:-}" \ + "${current_shell_pid:-}" "${current_shell_start:-}" + python3 "$INVENTORY_CORE" account switch-rollback "$txid" >/dev/null 2>&1 || true + restore_exact "$provider" "$uuid" "$cwd" "$source_alias" "$model" >/dev/null 2>&1 +} + +picker_account_switch() { + local proof=$1 target_alias=$2 + picker_action_event account_switch requested + [[ ! -e $SK_STATE_DIR/account-switching-off ]] || { + sk_die "account switching is disabled by its kill switch; nothing changed" + return "$PICKER_REFUSED_STATUS" + } + [[ $target_alias =~ ^[a-z][a-z0-9_-]{0,11}$ ]] || { + sk_die "account alias is invalid; nothing changed" + return "$PICKER_REFUSED_STATUS" + } + picker_load_fresh "$proof" || return "$PICKER_REFUSED_STATUS" + [[ $SK_PROVIDER == claude || $SK_PROVIDER == codex ]] || { + sk_die "only an exact Claude or Codex conversation can change account" + return "$PICKER_REFUSED_STATUS" + } + local provider=$SK_PROVIDER uuid=$SK_UUID id=$SK_ID cwd=$SK_CWD model=$SK_MODEL + local SK_RESTORE_IGNORE_STALE_ID= + [[ $uuid =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || return "$PICKER_REFUSED_STATUS" + account_switch_stable_snapshot "$SNAPSHOT" "$id" "$uuid" "$provider" || { + sk_die "the conversation is working or its state is unproven; nothing changed" + return "$PICKER_REFUSED_STATUS" + } + account_switch_safe_tree "$provider" "$SK_PROOF_SHELL_PID" "$SK_PROOF_SHELL_START" \ + "$SK_PROOF_PROVIDER_PID" "$SK_PROOF_PROVIDER_START" || { + sk_die "the managed shell has an active or unrecognized background child; nothing changed" + return "$PICKER_REFUSED_STATUS" + } + local source_json source_alias + source_json=$(python3 "$INVENTORY_CORE" account source "$provider" "$uuid" 2>/dev/null) || { + sk_die "the conversation's current account could not be uniquely proven; nothing changed" + return "$PICKER_REFUSED_STATUS" + } + source_alias=$(python3 -c 'import json,sys; print(json.loads(sys.stdin.read()).get("alias", ""))' <<<"$source_json" 2>/dev/null) || return "$PICKER_REFUSED_STATUS" + [[ $source_alias =~ ^[a-z][a-z0-9_-]{0,11}$ && $source_alias != "$target_alias" ]] || { + sk_die "the selected account is already active or the source changed; nothing changed" + return "$PICKER_REFUSED_STATUS" + } + local switch_error + switch_error=$(mktemp) || return "$PICKER_REFUSED_STATUS" + python3 "$INVENTORY_CORE" account launch-profile "$provider" "$target_alias" \ + >/dev/null 2>"$switch_error" || { + sk_die "$(grep -m1 . "$switch_error" 2>/dev/null || + echo "the target account could not be prepared; nothing changed")" + command rm -f -- "$switch_error" + return "$PICKER_REFUSED_STATUS" + } + command rm -f -- "$switch_error" + sk_confirm_exact "Change account from $source_alias to $target_alias" \ + "$id" "$SK_TITLE" "$provider" "${SK_NUMBER:-}" || { + echo "Nothing changed." + return "$PICKER_REFUSED_STATUS" + } + picker_lock || return "$PICKER_REFUSED_STATUS" + if ! picker_revalidate_locked || + ! account_switch_stable_snapshot "$SNAPSHOT" "$id" "$uuid" "$provider" || + ! account_switch_safe_tree "$provider" "$SK_PROOF_SHELL_PID" "$SK_PROOF_SHELL_START" \ + "$SK_PROOF_PROVIDER_PID" "$SK_PROOF_PROVIDER_START"; then + sk_lock_release 9 + sk_die "the displayed conversation changed; nothing changed" + return "$PICKER_REFUSED_STATUS" + fi + local prepared txid + prepared=$(python3 "$INVENTORY_CORE" account switch-prepare \ + "$provider" "$uuid" "$source_alias" "$target_alias" "$cwd" "$id" 2>/dev/null) || { + sk_lock_release 9 + sk_die "the account checkpoint could not be prepared; nothing changed" + return "$PICKER_REFUSED_STATUS" + } + txid=$(python3 -c 'import json,sys; print(json.loads(sys.stdin.read()).get("txid", ""))' <<<"$prepared" 2>/dev/null) || txid= + [[ $txid =~ ^[0-9a-f]{32}$ ]] || { + sk_lock_release 9 + sk_die "the account checkpoint identity was invalid; nothing changed" + return "$PICKER_REFUSED_STATUS" + } + local capable=$SK_ACCOUNT_CAPABLE + local shell_pid=$SK_PROOF_SHELL_PID shell_start=$SK_PROOF_SHELL_START + local provider_pid=$SK_PROOF_PROVIDER_PID provider_start=$SK_PROOF_PROVIDER_START + if [[ $capable == true ]]; then + account_switch_request "$id" apply "$txid" "$target_alias" "$source_alias" \ + "$uuid" "$SK_PROOF_STARTED" "$shell_pid" "$shell_start" "$model" \ + "$provider" || { + sk_lock_release 9 + python3 "$INVENTORY_CORE" account switch-rollback "$txid" >/dev/null 2>&1 || true + sk_die "the account handoff request could not be written; nothing changed" + return "$PICKER_REFUSED_STATUS" + } + if ! account_switch_signal "$provider" "$provider_pid" "$provider_start" \ + "$shell_pid" "$shell_start"; then + command rm -f -- "$SK_STATE_DIR/account-switch-requests/$id" + sk_lock_release 9 + python3 "$INVENTORY_CORE" account switch-rollback "$txid" >/dev/null 2>&1 || true + sk_die "the exact idle provider changed before handoff; nothing changed" + return "$PICKER_REFUSED_STATUS" + fi + sk_lock_release 9 + if account_switch_wait_alias "$id" "$provider" "$uuid" "$target_alias" && + python3 "$INVENTORY_CORE" account sync-ui "$provider" "$uuid" \ + "$target_alias" >/dev/null && + python3 "$INVENTORY_CORE" account switch-commit "$txid" >/dev/null; then + picker_action_event account_switch committed + printf 'Changed session %s from %s to %s. The conversation and shell were kept.\n' \ + "${SK_NUMBER:-$id}" "$source_alias" "$target_alias" + return 0 + fi + if account_switch_restore_source "$id" "$provider" "$uuid" "$source_alias" \ + "$target_alias" "$txid" "$cwd" "$model"; then + sk_die "the target account did not prove itself; the original account was restored" + picker_action_event account_switch rolled_back + else + sk_die "the target account did not prove itself and the original account could not be verified; use sp recover before opening it" + picker_action_event account_switch rollback_failed + fi + return 1 + fi + + # Legacy shells cannot reload a new profile in place. Their first switch is + # the approved one-time recreation; the exact provider UUID keeps the same + # terminal number, while the old window returns to Session Kit. + # + # Read the model BEFORE the kill: it is on the live provider's command line, + # so it stops existing the moment the process does. A recreation that does + # not carry it hands the conversation back on the provider's own default, + # which is the kit changing a model by itself with nothing on screen. + local carried_model= + carried_model=$(sk_session_model "$SNAPSHOT" "$id") || carried_model= + # Two branches carried the model across this handoff and neither is + # redundant: `carried_model` is read from the live provider's own command + # line just above, which is the truthful answer, while `$model` is what the + # picker's row said on entry. Prefer the live reading and fall back to the + # row, so a failed read degrades to a worse answer rather than to none -- + # silently starting a conversation on a different model is the trap this + # whole gate exists to close. + [[ -n $carried_model ]] || carried_model=$model + local shell_outcome + if ! shell_outcome=$(sk_terminate_exact_shell \ + "$SK_PROOF_DAEMON_PID" "$SK_PROOF_DAEMON_START" \ + "$shell_pid" "$shell_start" 2>&1); then + sk_cleanup_dead_shpool_entry "$id" \ + "$SK_PROOF_DAEMON_PID" "$SK_PROOF_DAEMON_START" \ + "$shell_pid" "$shell_start" "$SK_PROOF_STARTED" || true + sk_lock_release 9 + python3 "$INVENTORY_CORE" account switch-rollback "$txid" >/dev/null 2>&1 || true + sk_die "the legacy shell could not be closed safely; nothing changed ($shell_outcome)" + return "$PICKER_REFUSED_STATUS" + fi + sk_cleanup_dead_shpool_entry "$id" \ + "$SK_PROOF_DAEMON_PID" "$SK_PROOF_DAEMON_START" \ + "$shell_pid" "$shell_start" "$SK_PROOF_STARTED" || { + sk_lock_release 9 + return "$PICKER_REFUSED_STATUS" + } + SK_RESTORE_IGNORE_STALE_ID=$id + sk_lock_release 9 + sk_reap_session_leftovers "$provider_pid" "$provider_start" "$shell_pid" "$shell_start" + local attempt + for attempt in {1..60}; do + conversation_is_active "$provider" "$uuid" "$id" || break + sleep 0.2 + done + if conversation_is_active "$provider" "$uuid" "$id" || + ! python3 "$INVENTORY_CORE" account switch-apply "$txid" >/dev/null 2>&1; then + python3 "$INVENTORY_CORE" account switch-rollback "$txid" >/dev/null 2>&1 || true + restore_exact "$provider" "$uuid" "$cwd" "$source_alias" "$carried_model" \ + >/dev/null 2>&1 || true + sk_die "the legacy account handoff failed; Session Kit attempted to restore the original account" + return 1 + fi + local restored_id + restored_id=$(restore_exact "$provider" "$uuid" "$cwd" "$target_alias" \ + "$carried_model") || { + python3 "$INVENTORY_CORE" account switch-rollback "$txid" >/dev/null 2>&1 || true + restore_exact "$provider" "$uuid" "$cwd" "$source_alias" "$carried_model" \ + >/dev/null 2>&1 || true + sk_die "the target account did not start; Session Kit attempted to restore the original account" + return 1 + } + if ! python3 "$INVENTORY_CORE" account sync-ui "$provider" "$uuid" \ + "$target_alias" >/dev/null || + ! python3 "$INVENTORY_CORE" account switch-commit "$txid" >/dev/null; then + sk_die "the target account started but final verification failed; it and the pending checkpoint were left in place because no exact process proof was available for a safe rollback close" + return 1 + fi + picker_action_event account_switch committed_legacy + printf 'Changed session %s from %s to %s. Reopen the same number once in the original window.\n' \ + "${SK_NUMBER:-$restored_id}" "$source_alias" "$target_alias" + # The model is part of what the operator is looking at. Say which one came + # back, and say it plainly when none could be carried, rather than leaving + # them to notice later that the session is answering differently. + if [[ -n $carried_model ]]; then + printf 'It comes back on %s, the model it was on.\n' "$carried_model" + else + printf 'It was not on a named model, so it comes back on %s'"'"'s own default.\n' \ + "$provider" + fi +} + +picker_alias() { + local proof=$1 action=$2 title=${3:-} + picker_load_fresh "$proof" || return 1 + [[ $SK_PROOF_PROVIDER == claude || $SK_PROOF_PROVIDER == codex ]] || { + sk_die "managed shell labels are derived and cannot be renamed" + return 1 + } + picker_lock || return 1 + if ! picker_revalidate_locked; then + sk_lock_release 9 + sk_die "displayed session changed before name update; nothing changed" + return 1 + fi + update_ai_alias "$action" "$SK_PROOF_PROVIDER" "$SK_PROOF_UUID" "$title" || { + sk_lock_release 9 + sk_die "could not update the exact session name; nothing changed" + return 1 + } + sk_lock_release 9 + if [[ $action == delete ]]; then + printf 'Reset the local name for the exact %s session\n' "$SK_PROOF_PROVIDER" + else + printf 'Named the exact %s session %s\n' "$SK_PROOF_PROVIDER" \ + "$(sk_human_label "$title" "$SK_PROOF_PROVIDER")" + fi +} + +picker_fork() { + local proof=$1 + sk_require_integration || return 1 + picker_load_fresh "$proof" || return 1 + [[ $SK_PROOF_PROVIDER == claude || $SK_PROOF_PROVIDER == codex ]] || { + sk_die "only exact Claude or Codex sessions can be forked" + return 1 + } + local provider=$SK_PROOF_PROVIDER source_uuid=$SK_PROOF_UUID + [[ $source_uuid =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || { + sk_die "fork requires an exact canonical source UUID" + return 1 + } + picker_lock || return 1 + if ! sk_require_integration; then + sk_lock_release 9 + return 1 + fi + if ! picker_revalidate_locked; then + sk_lock_release 9 + sk_die "displayed session changed before fork; nothing launched" + return 1 + fi + local cwd=$SK_CWD + [[ $cwd == /* && -d $cwd ]] || { + sk_lock_release 9 + sk_die "fork source directory is not an existing absolute directory" + return 1 + } + cwd=$(cd -- "$cwd" && pwd -P) || { + sk_lock_release 9 + return 1 + } + + local id + id=$(sk_allocate_id) || { + sk_lock_release 9 + sk_die "could not allocate a unique fork session ID" + return 1 + } + sk_write_start_record "$id" "$provider" "$cwd" "$source_uuid" fork || { + sk_lock_release 9 + sk_die "could not write the exact fork launch record" + return 1 + } + local creation_floor_ms + creation_floor_ms=$(sk_now_unix_ms) || { + sk_lock_release 9 + return 1 + } + if ! sk_attach_new_unique "$id" "$cwd"; then + id=$SK_ATTACHED_ID + local quarantine + quarantine=$(sk_quarantine_start_record "$id" attach-failed 2>/dev/null || true) + sk_lock_release 9 + sk_die "shpool did not confirm fork shell creation; launch record quarantined at ${quarantine:-$SK_START_DIR/failed}" + return 1 + fi + id=$SK_ATTACHED_ID + if ! sk_capture_session_generation "$id" "$creation_floor_ms"; then + local quarantine + quarantine=$(sk_quarantine_start_record "$id" generation-unproven 2>/dev/null || true) + sk_lock_release 9 + sk_die "the fork shell may be open, but its exact generation was not proven; unarmed record quarantined at ${quarantine:-$SK_START_DIR/failed}" + return 1 + fi + local created_started=$SK_CREATED_STARTED + local created_boot_id=$SK_CREATED_BOOT_ID + local created_shell_pid=$SK_CREATED_SHELL_PID created_shell_start=$SK_CREATED_SHELL_START + local created_daemon_pid=$SK_CREATED_DAEMON_PID created_daemon_start=$SK_CREATED_DAEMON_START + # A fork is stamped only after its exact shell generation is captured. A + # momentarily unstamped row stays visible; a name-only stamp could hide a + # different session that later reuses this manager name. + sk_record_origin "$id" "$(sk_environment_origin)" "$created_started" \ + "$created_shell_pid" "$created_shell_start" + sk_write_generation_record "$id" "$provider" "$cwd" "$source_uuid" \ + "$created_boot_id" "$created_started" "$created_shell_pid" "$created_shell_start" \ + "$created_daemon_pid" "$created_daemon_start" fork || { + local quarantine + quarantine=$(sk_quarantine_start_record "$id" arming-failed 2>/dev/null || true) + sk_lock_release 9 + sk_die "fork generation could not be armed; record quarantined at ${quarantine:-$SK_START_DIR/failed}" + return 1 + } + sk_lock_release 9 + if ! sk_wait_for_provider "$id" "$provider" "$cwd" "$source_uuid" \ + "$created_boot_id" "$created_started" "$created_shell_pid" "$created_shell_start" \ + "$created_daemon_pid" "$created_daemon_start" fork; then + sk_die "the fork session remains open, but a distinct exact $provider conversation was not proven; fork launch record retained" + return 1 + fi + printf 'Forked %s into a separate %s session\n' \ + "$(sk_human_label "$SK_TITLE" "$provider")" "$provider" + # No identifier here or anywhere else a person reads: the fork's exact + # proof lives in its 0600 launch record and the JSON views. +} diff --git a/lib/sh/sp_provider_bounce.sh b/lib/sh/sp_provider_bounce.sh new file mode 100644 index 0000000..e6e34e3 --- /dev/null +++ b/lib/sh/sp_provider_bounce.sh @@ -0,0 +1,405 @@ +#!/usr/bin/env bash +# The one safe provider bounce, per provider: terminate an exact idle TUI that +# booted before its title existed so the session shell relaunches the same +# conversation named and themed. Every guard fails open toward no bounce. +# Source this file; do not execute it. +# +# Source order: bin/sp sources this module ahead of its first trap, after +# SCRIPT_DIR, session_kit_common, and INVENTORY_CORE are in place. These +# functions read the SK_PROOF_* variables that picker_load_fresh() in +# lib/sh/sp_picker.sh exports before it calls them. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +# The bounce marker, written whole or not written at all. +# +# The file has three readable states and each one means something different to +# a different reader: absent is "no bounce", naming a conversation is "bounce +# this one, nobody has yet", and EMPTY is the session shell's receipt that it +# has taken the instruction and is relaunching. Collection removes a marker +# only in that last state, because emptiness is the only one that proves the +# bounce already happened. +# +# A plain `> file` redirect truncates before it writes, so it passes through +# the empty state on its way to the full one. A collection landing in that +# split second reads the receipt for a bounce nobody has performed, removes +# the marker, and the shell later finds no instruction where it was told to +# look -- it takes the ordinary provider exit and the session closes. A rename +# has no such moment: the marker appears already complete. +sk_write_bounce_marker() { + local id=$1 uuid=$2 + local directory="$SK_STATE_DIR/provider-bounce" + local temporary reservation generation + ( umask 077; mkdir -p "$directory" ) 2>/dev/null || return 1 + # Repairs a directory an older release created before the umask was right; + # a marker readable by anything else is state somebody else can forge. + chmod 700 "$directory" 2>/dev/null || true + # Created 0600 by mktemp, and dot-prefixed so it is never mistaken for a + # session ID; the marker sweep removes a stray one after the same grace it + # gives every other file here. + # The reservation remains for this session's lifetime. Therefore mktemp can + # never hand a later request the same receipt generation while any stale + # collector for this session can still be in flight. + reservation=$(mktemp \ + "$directory/.generation.$id.taken.XXXXXXXXXXXX" 2>/dev/null) || return 1 + generation=${reservation##*.} + temporary=$(mktemp "$directory/.$id.XXXXXXXXXXXX" 2>/dev/null) || { + command rm -f -- "$reservation" 2>/dev/null + return 1 + } + # The third line names this request generation. The non-hex padding keeps + # that generation beyond the first 64 bytes, so provider shells already + # running the previous release still parse the UUID exactly and fall back to + # their safe legacy receipt name. + if ! printf '%s\nxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n%s\n' \ + "$uuid" "$generation" > "$temporary" 2>/dev/null || + ! mv -f -- "$temporary" "$directory/$id" 2>/dev/null; then + command rm -f -- "$temporary" "$reservation" 2>/dev/null + return 1 + fi + return 0 +} + +# One safe provider bounce: a new-mode Codex booted before its thread title +# existed, so its status bar shows the conversation ID forever. When the human +# opens such a session and a title now exists, terminate the exact idle TUI; +# the session shell relaunches the SAME conversation immediately (marker +# handshake with the bashrc), so the window opens named and themed. Guards, +# all fail-open toward "no bounce": untitled-boot marker present (only +# new-mode Codex sessions ever write one, so parked or pre-existing lanes are +# structurally excluded), a title in Codex's thread store, no subagents, not +# mid-turn, quiet for two minutes, and an exact PID+start-time match before +# the signal. App-server sessions keep the server and socket alive and restart +# only their one generation-bound remote TUI. +picker_bounce_codex() { + local id=$1 mode=${2:-automatic} reason=${3:-name} + [[ $mode == automatic || $mode == explicit ]] || return 2 + [[ $reason == name || $reason == color ]] || return 2 + [[ ${SK_PROOF_PROVIDER:-} == codex ]] || return 1 + local uuid=${SK_PROOF_UUID:-} + [[ -n $uuid ]] || return 1 + local untitled="$SK_STATE_DIR/provider-untitled/$id" + [[ -f $untitled && ! -L $untitled ]] || return 1 + # Active markers never age out. The inventory reaper quarantines a marker + # only after a fresh shpool snapshot proves that exact session is absent. + local pid=${SK_PROOF_PROVIDER_PID:-} start=${SK_PROOF_PROVIDER_START:-} + [[ $pid =~ ^[0-9]+$ && $start =~ ^[0-9]+$ ]] || return 1 + local refresh_target refresh_pid refresh_start + refresh_target=$(python3 "$INVENTORY_CORE" platform codex-refresh-target \ + "${SK_PROOF_SHELL_PID:-}" "${SK_PROOF_SHELL_START:-}" \ + "$pid" "$start" 2>/dev/null) || return 1 + IFS=$'\t' read -r refresh_pid refresh_start <<<"$refresh_target" + [[ $refresh_pid =~ ^[0-9]+$ && $refresh_start =~ ^[0-9]+$ ]] || return 1 + python3 - "$SNAPSHOT" "$id" "$uuid" "$pid" "$start" "$mode" <<'PY' || return 1 +import json, sys + +snapshot, shpool_id, uuid, pid_text, start_text, mode = sys.argv[1:7] +try: + data = json.load(open(snapshot, encoding="utf-8")) + row = next( + s for s in data.get("sessions", []) + if s.get("shpool_id") == shpool_id + ) +except (OSError, ValueError, StopIteration): + raise SystemExit(1) +if mode == "automatic" and row.get("availability") != "ready": + raise SystemExit(1) +identity = row.get("identity") or {} +if ( + identity.get("uuid") != uuid + or str(identity.get("pid")) != pid_text + or str(identity.get("process_start_ticks")) != start_text +): + raise SystemExit(1) +if row.get("subagents"): + raise SystemExit(1) +# Positive proof of a stable state, never "not working": "working" is +# mid-computation and "state unavailable" proves nothing, so both refuse. +# "needs your reply"/"reply optional" are TERM-safe wait states, the +# turn's computation is complete and only a human reply advances it. A +# research-style session spends its whole life in "needs your reply", so +# an idle-only rule left those bars unnamed forever; the question text +# survives the resume in the transcript (only the interactive widget is +# lost, and the thread accepts the answer as a plain message). Automatic +# mode still requires availability "ready", so an attached window is +# never restarted under the human. +if str(row.get("agent_status") or "").casefold() not in ( + "idle", + "needs your reply", + "reply optional", +): + raise SystemExit(1) +# With journals disabled the output age is unknown for every session; the +# rollout-derived turn state above is then the idleness signal. A known +# recent age still blocks a bounce during active use. +age = row.get("recent_output_age_seconds") +if isinstance(age, int) and not isinstance(age, bool) and age < 120: + raise SystemExit(1) +raise SystemExit(0) +PY + python3 "$INVENTORY_CORE" platform process-is \ + "$refresh_pid" "$refresh_start" codex \ + >/dev/null 2>&1 || return 1 + # Only a real name is worth a restart FOR A NAME: Codex seeds threads.title + # with the raw first prompt, and bouncing on that echo burns the one-shot + # restart before the true title exists. A refusal here leaves the untitled + # marker in place so a later reopen retries once the thread is really named. + # + # A colour change is a different question with a different answer. The + # window's theme is chosen at launch from the conversation's stored colour, + # so a recolour needs a restart whether or not anybody has named the thread + # -- and requiring a title here left every unnamed session stuck showing the + # colour it picked for itself. The colour must exist, or the restart would + # change nothing; the name is irrelevant to it. + local bounce_title="" + if [[ $reason == color ]]; then + # The colour has to be one a restart can actually paint: Codex reads its + # theme from the command line the launcher builds, so the kit theme file + # for this colour must exist. `color effective` alone proves nothing -- + # it falls back to a hash of the identity and succeeds for any uuid. + python3 "$INVENTORY_CORE" color bounce-ready codex "$uuid" >/dev/null 2>&1 || return 1 + # Still mirror a real name into the session index when one exists, since + # the relaunched status bar reads only that; its absence never blocks the + # recolour, and whether one existed decides the marker below. + bounce_title=$(python3 "$INVENTORY_CORE" codex-bounce-title "$uuid" 2>/dev/null) || + bounce_title="" + else + bounce_title=$(python3 "$INVENTORY_CORE" codex-bounce-title "$uuid" 2>/dev/null) || return 1 + [[ -n $bounce_title ]] || return 1 + fi + sk_write_bounce_marker "$id" "$uuid" || return 1 + # The decision above ran on a snapshot taken before the create lock was + # released; re-prove idleness on FRESH state immediately before the TERM, + # or a turn that started meanwhile gets killed mid-work. + local recheck + recheck=$(mktemp "$SK_STATE_DIR/bounce-recheck.json.XXXXXX") || { + command rm -f -- "$SK_STATE_DIR/provider-bounce/$id" + return 1 + } + if ! sk_guard_snapshot_file "$recheck" 2>/dev/null || + ! python3 - "$recheck" "$id" "$uuid" "$pid" "$start" "$mode" <<'PY' +import json, sys + +try: + data = json.load(open(sys.argv[1], encoding="utf-8")) + row = next( + s for s in data.get("sessions", []) + if s.get("shpool_id") == sys.argv[2] + ) +except (OSError, ValueError, StopIteration): + raise SystemExit(1) +uuid, pid_text, start_text, mode = sys.argv[3:7] +identity = row.get("identity") or {} +if ( + identity.get("uuid") != uuid + or str(identity.get("pid")) != pid_text + or str(identity.get("process_start_ticks")) != start_text +): + raise SystemExit(1) +if mode == "automatic" and row.get("availability") != "ready": + raise SystemExit(1) +if row.get("subagents"): + raise SystemExit(1) +# Same stable-state set as the pre-lock decision: refuse only +# mid-computation ("working") and unproven state. +if str(row.get("agent_status") or "").casefold() not in ( + "idle", + "needs your reply", + "reply optional", +): + raise SystemExit(1) +# The same quiet test as the first pass, on fresh state. This recheck exists +# to catch work that STARTED during the race window, and output arriving in +# the last two minutes is exactly that evidence -- a status that still reads +# idle while the window is printing is the case this refuses. +age = row.get("recent_output_age_seconds") +if isinstance(age, int) and not isinstance(age, bool) and age < 120: + raise SystemExit(1) +raise SystemExit(0) +PY + then + command rm -f -- "$recheck" "$SK_STATE_DIR/provider-bounce/$id" + return 1 + fi + if ! python3 "$INVENTORY_CORE" platform process-is \ + "$refresh_pid" "$refresh_start" codex \ + >/dev/null 2>&1; then + command rm -f -- "$recheck" "$SK_STATE_DIR/provider-bounce/$id" + return 1 + fi + command rm -f -- "$recheck" + if kill -TERM "$refresh_pid" 2>/dev/null; then + # The marker is a one-shot: it is written when a window boots before its + # name exists, and every later remedy -- the automatic retry at reopen and + # the picker's "apply the pending title" action -- requires it. Spending it + # on a restart that applied no name would strand an unnamed session's + # status bar on its conversation ID for the rest of that window's life, + # with both remedies gone. Only a restart that carried a name clears it. + if [[ -n $bounce_title ]]; then + command rm -f -- "$untitled" + fi + # Whatever the reason, the window has restarted, so it has read the + # colour the kit holds. A pending recolour is no longer pending. + command rm -f -- "$SK_STATE_DIR/provider-recolor/$id" + picker_action_event title_refresh provider_restarted + else + command rm -f -- "$SK_STATE_DIR/provider-bounce/$id" + return 1 + fi + return 0 +} + +# The Claude variant of the one safe provider bounce: a Claude window +# renames only through its SessionStart/prompt hooks, so a session named +# AFTER boot with no prompt since (ask, read, close) shows a stale title +# until the provider restarts. Guards mirror the Codex bounce; the +# claude-bounce-title verb additionally proves no prompt followed the name +# intent (exit 3 = the window already renamed live, so the marker is +# dropped without any restart). +picker_bounce_claude() { + local id=$1 mode=${2:-automatic} reason=${3:-name} + [[ $mode == automatic || $mode == explicit ]] || return 2 + [[ $reason == name || $reason == color ]] || return 2 + [[ ${SK_PROOF_PROVIDER:-} == claude ]] || return 1 + local uuid=${SK_PROOF_UUID:-} + [[ -n $uuid ]] || return 1 + local untitled="$SK_STATE_DIR/provider-untitled/$id" + [[ -f $untitled && ! -L $untitled ]] || return 1 + local pid=${SK_PROOF_PROVIDER_PID:-} start=${SK_PROOF_PROVIDER_START:-} + [[ $pid =~ ^[0-9]+$ && $start =~ ^[0-9]+$ ]] || return 1 + python3 - "$SNAPSHOT" "$id" "$uuid" "$pid" "$start" "$mode" <<'PY' || return 1 +import json, sys + +snapshot, shpool_id, uuid, pid_text, start_text, mode = sys.argv[1:7] +try: + data = json.load(open(snapshot, encoding="utf-8")) + row = next( + s for s in data.get("sessions", []) + if s.get("shpool_id") == shpool_id + ) +except (OSError, ValueError, StopIteration): + raise SystemExit(1) +if mode == "automatic" and row.get("availability") != "ready": + raise SystemExit(1) +identity = row.get("identity") or {} +if ( + identity.get("uuid") != uuid + or str(identity.get("pid")) != pid_text + or str(identity.get("process_start_ticks")) != start_text +): + raise SystemExit(1) +if row.get("subagents"): + raise SystemExit(1) +# Same stable-state set as the Codex bounce: refuse only mid-computation +# and unproven state. +if str(row.get("agent_status") or "").casefold() not in ( + "idle", + "needs your reply", + "reply optional", +): + raise SystemExit(1) +age = row.get("recent_output_age_seconds") +if isinstance(age, int) and not isinstance(age, bool) and age < 120: + raise SystemExit(1) +raise SystemExit(0) +PY + python3 "$INVENTORY_CORE" platform process-is \ + "$pid" "$start" claude \ + >/dev/null 2>&1 || return 1 + local bounce_title bounce_rc + if [[ $reason == color ]]; then + # A recolour is not a rename. Claude reads its colour from the transcript + # record at start or resume, so the restart is what applies it, and an + # unnamed session needs it exactly as much as a named one -- requiring a + # name intent here is what left unnamed sessions uncolourable. What must + # exist is the RECORD the resumed window will read: without it the restart + # costs the person their window and paints nothing. + python3 "$INVENTORY_CORE" color bounce-ready claude "$uuid" >/dev/null 2>&1 || return 1 + # Whether this restart will also apply a name decides the marker below; + # it never blocks the recolour. + bounce_title=$(python3 "$INVENTORY_CORE" claude-bounce-title "$uuid" 2>/dev/null) || + bounce_title="" + else + bounce_title=$(python3 "$INVENTORY_CORE" claude-bounce-title "$uuid" 2>/dev/null) + bounce_rc=$? + if (( bounce_rc == 3 )); then + # A real prompt followed the name intent, so the live window already + # carries the name; the pending-boot marker is stale, never the title. + command rm -f -- "$untitled" + return 1 + fi + [[ $bounce_rc -eq 0 && -n $bounce_title ]] || return 1 + fi + sk_write_bounce_marker "$id" "$uuid" || return 1 + local recheck + recheck=$(mktemp "$SK_STATE_DIR/bounce-recheck.json.XXXXXX") || { + command rm -f -- "$SK_STATE_DIR/provider-bounce/$id" + return 1 + } + if ! sk_guard_snapshot_file "$recheck" 2>/dev/null || + ! python3 - "$recheck" "$id" "$uuid" "$pid" "$start" "$mode" <<'PY' +import json, sys + +try: + data = json.load(open(sys.argv[1], encoding="utf-8")) + row = next( + s for s in data.get("sessions", []) + if s.get("shpool_id") == sys.argv[2] + ) +except (OSError, ValueError, StopIteration): + raise SystemExit(1) +uuid, pid_text, start_text, mode = sys.argv[3:7] +identity = row.get("identity") or {} +if ( + identity.get("uuid") != uuid + or str(identity.get("pid")) != pid_text + or str(identity.get("process_start_ticks")) != start_text +): + raise SystemExit(1) +if mode == "automatic" and row.get("availability") != "ready": + raise SystemExit(1) +if row.get("subagents"): + raise SystemExit(1) +if str(row.get("agent_status") or "").casefold() not in ( + "idle", + "needs your reply", + "reply optional", +): + raise SystemExit(1) +# The same quiet test as the first pass, on fresh state. This recheck exists +# to catch work that STARTED during the race window, and output arriving in +# the last two minutes is exactly that evidence -- a status that still reads +# idle while the window is printing is the case this refuses. +age = row.get("recent_output_age_seconds") +if isinstance(age, int) and not isinstance(age, bool) and age < 120: + raise SystemExit(1) +raise SystemExit(0) +PY + then + command rm -f -- "$recheck" "$SK_STATE_DIR/provider-bounce/$id" + return 1 + fi + if ! python3 "$INVENTORY_CORE" platform process-is \ + "$pid" "$start" claude \ + >/dev/null 2>&1; then + command rm -f -- "$recheck" "$SK_STATE_DIR/provider-bounce/$id" + return 1 + fi + command rm -f -- "$recheck" + if kill -TERM "$pid" 2>/dev/null; then + # Same one-shot rule as the Codex bounce: a restart that applied no name + # leaves the marker in place, so the pending-title remedies survive. + if [[ -n $bounce_title ]]; then + command rm -f -- "$untitled" + fi + # Whatever the reason, the window has restarted, so it has read the + # colour the kit holds. A pending recolour is no longer pending. + command rm -f -- "$SK_STATE_DIR/provider-recolor/$id" + picker_action_event title_refresh provider_restarted + else + command rm -f -- "$SK_STATE_DIR/provider-bounce/$id" + return 1 + fi + return 0 +} diff --git a/lib/sh/sp_sessions.sh b/lib/sh/sp_sessions.sh new file mode 100644 index 0000000..6c6c8d7 --- /dev/null +++ b/lib/sh/sp_sessions.sh @@ -0,0 +1,1521 @@ +#!/usr/bin/env bash +# sp session builders: repair a broken session, pre-bake a Claude conversation, +# create a new session, and restore an exact recorded one. Source this file; do +# not execute it. +# +# Source order: bin/sp sources this module ahead of its first trap, after +# SCRIPT_DIR, session_kit_common, and INVENTORY_CORE are in place. These +# functions call the snapshot and attach helpers in lib/sh/sp_core.sh and read +# SNAPSHOT, which those helpers assign. +# +# Globals the entry script owns are assigned there, not here. +# shellcheck disable=SC2154 + +# Watchdog path: same repair, selected by session ID and never interactive. +# Who asked for a session. A declaration still wins -- the picker's repair +# paths pass the original session's origin, and `--origin` is an operator's +# override -- but nothing else is taken on trust. +# +# Asking a caller to volunteer "I am a machine" only works on callers that +# remember to, and agents do not: every session an agent started came back a +# person's, so the picker filled with work the operator never opened (87 and 88 were +# their examples). When nobody declares, LOOK at who is calling instead. Every +# managed session exports SHPOOL_SESSION_NAME into everything it runs, and the +# login shell the picker runs in does not export it at all. So a session +# created from inside a session was created by an agent -- whatever provider +# it starts, whatever verb it used -- and one created from the picker is the operator's. +# That reading needs no cooperation from the caller, which is the point. +sk_environment_origin() { + local value=${SESSION_KIT_ORIGIN:-} + if [[ $value == human || $value == machine ]]; then + printf '%s\n' "$value" + return 0 + fi + # A name that is not a session ID proves nothing about the caller, so it + # reads as a person's: unproven stays visible, the safe way to be wrong. + if [[ ${SHPOOL_SESSION_NAME:-} =~ ^s[0-9]{8}-[0-9]{6}-[1-9][0-9]*(-[1-9][0-9]*)?$ ]]; then + printf 'machine\n' + else + printf 'human\n' + fi +} + +# The origin a conversation already had, from the record written when it +# closed. A restore brings back an EXISTING session, so its origin is a fact +# to carry, not a fresh judgement about whoever runs the restore: the recovery +# sweep runs from the login shell, and reading the caller there turned every +# restored machine session back into one of the operator's rows. +# +# Prints exactly one word, and the caller decides what each one means: +# +# machine / human the ledger recorded this conversation's origin. It is +# the answer, in BOTH directions -- the record of what a +# session WAS outranks any reading of who is restoring it. +# none the ledger read, and it has never heard of this +# conversation. Nothing is known, so the caller reads the +# environment as it would for a brand new session. +# unknown the ledger did NOT read. That is not the same fact as +# "no record", and treating it as one is how an +# unreadable file decides a row: the caller must take the +# visible side. +sk_recorded_origin() { + local provider=$1 uuid=$2 + # The library directory, not INVENTORY_CORE: the core path is redirectable, + # and what is wanted here is the reader that owns the closed-session ledger. + python3 - "$SCRIPT_DIR/../lib" "$provider" "$uuid" <<'PY' 2>/dev/null || printf 'unknown\n' +import os +from pathlib import Path +import sys + +library, provider, uuid = sys.argv[1:4] +sys.path.insert(0, os.fspath(Path(library).resolve())) +try: + from sessionkit_inventory import closed_sessions + + # Asked before the rows, because every reader in that module degrades an + # unreadable ledger to "no rows" -- right for a list, wrong for deciding + # whose session this is. + if not closed_sessions.ledger_is_readable(): + print("unknown") + raise SystemExit(0) + # Unfiltered on purpose. The picker's list drops conversations whose + # transcript this machine can no longer read, because it cannot promise to + # restore them -- but a restore is already under way here, and whether the + # session was a machine's is a fact about what it WAS, not about whether + # its transcript survives. + rows = closed_sessions.load_closed() +except (ImportError, OSError, ValueError): + # The ledger is the thing that says a session was an agent's. Answering + # "no record" when it could not be read is what let an unreadable file + # launder an agent's session into the person's list, and the same silence + # in the other direction would hide one of the person's own. + print("unknown") + raise SystemExit(0) +if not isinstance(rows, list): + print("unknown") + raise SystemExit(0) +for row in rows: + if not isinstance(row, dict) or row.get("provider") != provider: + continue + if str(row.get("uuid") or "").casefold() != uuid.casefold(): + continue + origin = row.get("origin") + print(origin if origin in ("human", "machine") else "unknown") + raise SystemExit(0) +print("none") +PY +} + +# Stamping is advisory: a session that could not be stamped is listed as a +# person's, which is the safe direction, and says so once. +sk_record_origin() { + # A collision retry has only a fresh manager name, not a verified process + # generation. The shared attach helper still offers that two-argument + # callback for the older stamping contract; defer it, because every creator + # below records the origin after exact generation capture. + if (( $# == 2 )); then + return 0 + fi + (( $# == 5 )) || return 1 + local id=$1 origin=$2 started_at=$3 shell_pid=$4 shell_start_ticks=$5 + [[ $origin == human || $origin == machine ]] || origin=human + SESSION_KIT_ORIGIN_STARTED_AT_UNIX_MS=$started_at \ + SESSION_KIT_ORIGIN_SHELL_PID=$shell_pid \ + SESSION_KIT_ORIGIN_SHELL_START_TICKS=$shell_start_ticks \ + python3 "$INVENTORY_CORE" origin record "$id" "$origin" >/dev/null 2>&1 && return 0 + [[ $origin == human ]] || + printf 'session-kit: the session could not be stamped as a machine session, so it is listed as yours.\n' >&2 + return 0 +} + +repair_target() { + local selector=$1 + picker_action_event repair requested + sk_require_integration || { + picker_action_event repair refused + return 1 + } + make_guard_snapshot || { + picker_action_event repair refused + return 1 + } + sk_resolve "$SNAPSHOT" "$selector" || { + sk_die "no session matches that selector" + return 2 + } + sk_require_mutation_target || return 1 + [[ $SK_PROVIDER == claude || $SK_PROVIDER == codex ]] || { + sk_die "only a Claude or Codex session can be repaired" + return 1 + } + local id=$SK_ID provider=$SK_PROVIDER uuid=$SK_UUID cwd=$SK_CWD started=$SK_STARTED + local shell_pid=$SK_SHELL_PID shell_start=$SK_SHELL_START + local provider_pid=$SK_PROVIDER_PID provider_start=$SK_PROVIDER_START + local daemon_pid=$SK_DAEMON_PID daemon_start=$SK_DAEMON_START + [[ $uuid =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || { + sk_die "repair requires a canonical conversation UUID" + return 1 + } + [[ $cwd == /* && -d $cwd ]] || { + sk_die "session directory is not an existing absolute directory" + return 1 + } + cwd=$(cd -- "$cwd" && pwd -P) || return 1 + picker_lock || { + picker_action_event repair refused + return 1 + } + sk_revalidate "$id" "$started" "$provider" "$uuid" \ + "$shell_pid" "$shell_start" "$provider_pid" "$provider_start" \ + "$daemon_pid" "$daemon_start" || { + sk_lock_release 9 + sk_die "session changed while preparing the repair; nothing changed" + picker_action_event repair state_changed + return 1 + } + if [[ $SK_STATUS != disconnected && $SK_STATUS != Disconnected ]]; then + sk_lock_release 9 + sk_die "session is open in another window; repair refused and nothing changed" + picker_action_event repair attached + return 1 + fi + if ! sk_shpool_holder_is "$daemon_pid" "$daemon_start"; then + sk_lock_release 9 + sk_die "socket holder changed before repair close; no kill was sent and nothing changed" + picker_action_event repair state_changed + return 1 + fi + local shell_outcome + if ! shell_outcome=$(sk_terminate_exact_shell "$daemon_pid" "$daemon_start" \ + "$shell_pid" "$shell_start" 2>&1); then + sk_cleanup_dead_shpool_entry "$id" "$daemon_pid" "$daemon_start" \ + "$shell_pid" "$shell_start" "$started" || true + sk_lock_release 9 + sk_die "exact repair shell PID $shell_pid start $shell_start was not terminated ($shell_outcome); automatic repair stopped before leftover cleanup" + picker_action_event repair failed + return 1 + fi + sk_cleanup_dead_shpool_entry "$id" "$daemon_pid" "$daemon_start" \ + "$shell_pid" "$shell_start" "$started" || { + sk_lock_release 9 + return 1 + } + sk_lock_release 9 + if finish_recovery_after_kill "$id" "$provider" "$uuid" "$cwd" \ + "$shell_pid" "$shell_start" "$provider_pid" "$provider_start"; then + picker_action_event repair recovered + return 0 + fi + picker_action_event repair failed + echo "session-kit: the session was already closed. Its conversation is intact; restore it from the picker." >&2 + return 1 +} + +# Whether the throwaway has written the color record we asked it for. Cheap +# enough to ask ten times a second; the authoritative JSON check runs once, +# afterwards, in sk_prebake_claude. +sk_prebake_color_landed() { + local transcript=$1 color=$2 + [[ -s $transcript ]] || return 1 + grep -as -e '"agent-color"' -- "$transcript" 2>/dev/null | + grep -qas -e "\"$color\"" 2>/dev/null +} + +# Every process still carrying this conversation ID, gone. The ID was minted +# seconds ago and appears in nothing else on the machine, so this can only +# match the throwaway pair this function started -- never a real session. +sk_prebake_reap() { + local minted=$1 + [[ $minted =~ ^[0-9a-fA-F-]{36}$ ]] || return 0 + python3 - "$minted" <<'PY' >/dev/null 2>&1 || true +import os +import signal +import sys +import time + +needle = sys.argv[1].encode() +me = os.getpid() + + +def targets(): + found = [] + for entry in os.listdir("/proc"): + if not entry.isdigit() or int(entry) == me: + continue + try: + with open(f"/proc/{entry}/cmdline", "rb") as handle: + argv = handle.read() + except OSError: + continue + if needle in argv and (b"claude" in argv or b"script" in argv): + found.append(int(entry)) + return found + + +# SIGTERM first so the provider closes its transcript cleanly; SIGKILL only +# for whatever ignores it. +for number in (signal.SIGTERM, signal.SIGKILL): + pids = targets() + if not pids: + break + for pid in pids: + try: + os.kill(pid, number) + except OSError: + pass + for _ in range(20): + time.sleep(0.1) + if not targets(): + break +PY +} + +# End an in-flight pre-bake, from a signal handler or an exit trap. Safe to +# call at any time: it does nothing unless a throwaway is actually running. +# `sp` installs it on INT, TERM, HUP and EXIT, because a Ctrl-C tears the +# shell down before sk_prebake_claude can reach its own reap. +sk_prebake_abort() { + local pending=${SK_PREBAKE_IN_FLIGHT:-} + [[ -n $pending ]] || return 0 + SK_PREBAKE_IN_FLIGHT= + sk_prebake_reap "$pending" + python3 "$INVENTORY_CORE" color conversation-release claude "$pending" \ + "${SK_PREBAKE_COLOR:-}" >/dev/null 2>&1 || true + return 0 +} + +# Drive the throwaway TUI's stdin, and stop the moment its color record +# exists. +# +# Order matters here, and getting it backwards cost the whole feature once. +# This waited for the throwaway's TRANSCRIPT FILE to appear before it typed +# anything -- but real Claude Code does not create that file until it writes +# its first record, and in a throwaway the first record is the one `/color` +# itself produces. So the feeder was waiting for a file that only its own +# keystrokes could create: it burned the entire deadline, returned without +# ever typing, and every Claude session paid ~16 seconds of silence for a +# colour that never landed. Measured against claude 2.1.233, typing first and +# then polling: the transcript appears ~0.02s after the keystrokes and the +# colour record ~0.03s after that. +# +# So: settle, type, and only then look for the record. +sk_prebake_feed() { + local color=$1 root=$2 minted=$3 deadline=$4 + local transcript= waited=0 sends=0 settle=0 + local limit=$((deadline * 10)) + # Tenths of a second before the first keystroke. The TUI puts the terminal + # into raw mode as it comes up and that switch discards input already + # queued, so anything typed before it is simply gone. Measured with a single + # send and no re-send: 0.5s never landed, 1.0s was eaten once in three, 1.5s + # landed every time. Hence this settle AND the bounded re-send below. + local first=${SESSION_KIT_PREBAKE_SETTLE:-15} + [[ $first =~ ^[0-9]+$ ]] && ((first >= 1 && first < limit)) || first=15 + while ((waited < first)); do + sleep 0.1 2>/dev/null || return 0 + ((waited++)) + done + while ((waited < limit)); do + # Re-send ONLY while the throwaway has still written nothing. A transcript + # is proof the keystrokes arrived; typing a second `/color` on top of an + # input line the TUI is already holding is the one way a re-send could + # build a line that is not a slash command, and the trailing carriage + # return would then SUBMIT it as a prompt on the person's own account. + if [[ -z $transcript ]] && ((sends < 6)); then + printf '/color %s\r' "$color" + ((sends++)) + fi + settle=0 + while ((settle < 15 && waited < limit)); do + sleep 0.1 2>/dev/null || return 0 + ((waited++)) + ((settle++)) + if [[ -z $transcript ]]; then + transcript=$(compgen -G "$root/projects/*/$minted.jsonl" 2>/dev/null | head -1) + fi + if [[ -n $transcript ]] && sk_prebake_color_landed "$transcript" "$color"; then + # Done. Closing stdin is not enough -- the real TUI keeps running -- + # so the throwaway is ended here rather than by the timeout. + sk_prebake_reap "$minted" + return 0 + fi + done + # A throwaway that has already exited is never going to write one. Waiting + # out the whole deadline for it costs the person the same silence as a + # hang -- a provider that cannot start (no credentials, wrong profile) + # should cost the seconds it took to fail, not the ceiling. + if command -v pgrep >/dev/null 2>&1 && + ! pgrep -f -- "$minted" >/dev/null 2>&1; then + return 0 + fi + done + return 0 +} + +# First-window color: boot a hidden throwaway TUI on a minted conversation ID, +# let it write its own native color record by typing /color into ITSELF (no +# human terminal is ever touched), then launch the real session as a resume of +# that conversation so the color is native from the first frame. Fail-open: +# any problem falls back to the plain new-session flow. Kill switches: +# SESSION_KIT_NO_PREBAKE=1 or $SK_STATE_DIR/prebake-off. +sk_prebake_claude() { + SK_PREBAKE_UUID= + # Three outcomes, told apart because they mean different things to the + # person: `off` is a choice (kill switch, or no tooling to do it with) and + # says nothing; `failed` means the kit tried to give this window its color + # from the first frame and could not, which the caller reports; `ready` + # worked. + SK_PREBAKE_STATUS=off + [[ ${SESSION_KIT_NO_PREBAKE:-0} != 1 && ! -e $SK_STATE_DIR/prebake-off ]] || + return 1 + command -v script >/dev/null 2>&1 || return 1 + command -v claude >/dev/null 2>&1 || return 1 + SK_PREBAKE_STATUS=failed + # An account session pre-bakes inside ITS OWN profile. This step used to be + # skipped outright whenever an account was named, which is why a session on + # a chosen profile opened with no colour while a default-profile one opened + # with its colour already in the first frame. The throwaway conversation has + # to be minted where the real session will resume it: one written to the + # ambient profile is invisible to a session that runs with CLAUDE_CONFIG_DIR + # set, so the resume would fail and take the colour with it. + # + # With no account named, the throwaway inherits whatever profile this shell + # already runs under, so the root to look in is that one -- not a hardcoded + # $HOME/.claude. An operator who sets CLAUDE_CONFIG_DIR himself (a + # documented setup, not an edge case) previously had the throwaway write + # into THEIR profile while the kit looked in $HOME/.claude, found nothing, and + # reported the step as failed after paying for all of it. + local prebake_root=${CLAUDE_CONFIG_DIR:-$HOME/.claude} + # Pinned per command, never exported: an `export` here survived every later + # `return 1` and left the ambient profile of the whole rest of `sp new` -- + # color propagate, account bind, snapshot, attach -- pointing at one + # account's directory. + local -a prebake_env=(-u CLAUDE_CODE_CHILD_SESSION -u CLAUDE_CODE_SESSION_ID) + if [[ -n ${requested_account:-} ]]; then + # `account launch-profile` already ran and was validated above, so the + # profile is read from that answer rather than asked for a second time. + local prebake_profile + prebake_profile=$(python3 -c 'import json,sys +value = json.loads(sys.stdin.read()) +profile = value.get("profile_dir") +if not isinstance(profile, str) or not profile.startswith("/"): + raise SystemExit(1) +print(profile)' <<<"${account_profile_json:-}" 2>/dev/null) || return 1 + [[ -d $prebake_profile && ! -L $prebake_profile ]] || return 1 + prebake_env+=("CLAUDE_CONFIG_DIR=$prebake_profile") + prebake_root=$prebake_profile + fi + # This runs BEFORE any other creation output. TTY-gated: background callers + # keep clean output. + [[ -t 1 ]] && + printf 'Starting a Claude session. This takes a few seconds.\n' + local minted color transcript + minted=$(python3 -c 'import uuid; print(uuid.uuid4())' 2>/dev/null) || return 1 + color=$(python3 "$INVENTORY_CORE" color conversation-pick claude "$minted" \ + 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["color"])') || return 1 + # The ceiling, not the cost: the feeder stops as soon as the record lands. + local deadline=${SESSION_KIT_PREBAKE_DEADLINE:-14} + [[ $deadline =~ ^[0-9]+$ ]] && ((deadline >= 3 && deadline <= 60)) || deadline=14 + # Named for `sk_prebake_abort`, which `sp`'s signal traps call: a Ctrl-C + # ends this shell before the reap below can be reached. + SK_PREBAKE_IN_FLIGHT=$minted + SK_PREBAKE_COLOR=$color + ( cd "$cwd" 2>/dev/null && + # A Ctrl-C while the person waits for this must take the throwaway with + # it, and so must a hard kill of this shell. + export SK_TIMEOUT_TIE_CHILD=1 && + sk_prebake_feed "$color" "$prebake_root" "$minted" "$deadline" | + if [[ $(sk_platform) == Darwin ]]; then + sk_timeout "$deadline" env "${prebake_env[@]}" \ + script -q /dev/null claude --session-id "$minted" + else + sk_timeout "$deadline" env "${prebake_env[@]}" \ + script -qec "claude --session-id $minted" /dev/null + fi + ) >/dev/null 2>&1 || true + # Belt and braces: whatever happened above -- landed, timed out, interrupted + # -- nothing carrying this conversation ID is left running. A four-day-old + # throwaway pair was found alive on the operator box holding ~250 MB and an + # account profile, with nothing on the machine able to reap it. + sk_prebake_reap "$minted" + SK_PREBAKE_IN_FLIGHT= + if ! transcript=$(compgen -G "$prebake_root/projects/*/$minted.jsonl" | head -1) || + [[ -z $transcript || ! -s $transcript || -L $transcript ]]; then + python3 "$INVENTORY_CORE" color conversation-release claude "$minted" "$color" \ + >/dev/null 2>&1 || true + return 1 + fi + if ! python3 - "$transcript" "$color" <<'PY' >/dev/null 2>&1 +import json, sys +path, expected = sys.argv[1:] +matched = False +# Bytes, decoded per line: the throwaway was just stopped, so its last record +# can be half-written, and text mode raises UnicodeDecodeError from the +# iteration itself -- which would fail a prebake that actually worked. +with open(path, "rb") as stream: + for line in stream: + try: + item = json.loads(line.decode("utf-8", "strict")) + except (UnicodeDecodeError, ValueError): + continue + if not isinstance(item, dict): + continue + if item.get("type") == "agent-color" and item.get("agentColor") == expected: + matched = True +raise SystemExit(0 if matched else 1) +PY + then + python3 "$INVENTORY_CORE" color conversation-release claude "$minted" "$color" \ + >/dev/null 2>&1 || true + return 1 + fi + SK_PREBAKE_UUID=$minted + SK_PREBAKE_STATUS=ready +} + +# Copy a private prompt into the paired-launch namespace. The source remains in +# place until the separate audit append succeeds, so an audit failure can never +# erase both copies. +sk_stage_prompt_handoff() { + local source=$1 destination=$2 + SK_STAGED_PROMPT_IDENTITY=$(python3 - "$source" "$destination" <<'PY' +import os +from pathlib import Path +import stat +import sys + +source, destination = map(Path, sys.argv[1:]) +if not source.is_absolute(): + source = Path.cwd() / source +flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + +def open_parent(path, *, create=False, private=False): + if not path.is_absolute() or ".." in path.parts: + raise OSError("unsafe prompt path") + descriptor = os.open("/", os.O_RDONLY | os.O_DIRECTORY) + try: + parts = path.parent.parts[1:] + for index, component in enumerate(parts): + try: + child = os.open( + component, + os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0), + dir_fd=descriptor, + ) + except FileNotFoundError: + if not create: + raise + os.mkdir(component, 0o700, dir_fd=descriptor) + os.fsync(descriptor) + child = os.open( + component, + os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0), + dir_fd=descriptor, + ) + info = os.fstat(child) + if not stat.S_ISDIR(info.st_mode): + os.close(child) + raise OSError("unsafe prompt ancestor") + if private and index == len(parts) - 1 and ( + info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) != 0o700 + ): + os.close(child) + raise OSError("unsafe prompt handoff directory") + os.close(descriptor) + descriptor = child + return descriptor + except BaseException: + os.close(descriptor) + raise + +source_parent = open_parent(source) +try: + source_fd = os.open(source.name, flags, dir_fd=source_parent) +except OSError: + os.close(source_parent) + raise SystemExit(1) +destination_fd = -1 +destination_parent = -1 +try: + info = os.fstat(source_fd) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_uid != os.geteuid() + or info.st_nlink != 1 + or stat.S_IMODE(info.st_mode) != 0o600 + or not 0 < info.st_size <= 1024 * 1024 + ): + raise OSError("unsafe prompt") + payload = os.read(source_fd, info.st_size + 1) + if len(payload) != info.st_size or b"\0" in payload or b"\x1b" in payload: + raise OSError("unsafe prompt bytes") + payload.decode("utf-8") + destination_parent = open_parent(destination, create=True, private=True) + create_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + create_flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + destination_fd = os.open(destination.name, create_flags, 0o600, dir_fd=destination_parent) + os.fchmod(destination_fd, 0o600) + written = 0 + while written < len(payload): + written += os.write(destination_fd, payload[written:]) + os.fsync(destination_fd) + destination_info = os.fstat(destination_fd) + if destination_info.st_size != len(payload): + raise OSError("short prompt write") + os.fsync(destination_parent) + path_info = os.stat(source.name, dir_fd=source_parent, follow_symlinks=False) + if (path_info.st_dev, path_info.st_ino) != (info.st_dev, info.st_ino): + raise OSError("prompt pathname changed") + print(f"{info.st_dev}:{info.st_ino}") +except (OSError, UnicodeError): + try: + if destination_parent >= 0: + os.unlink(destination.name, dir_fd=destination_parent) + except OSError: + pass + raise SystemExit(1) +finally: + if destination_fd >= 0: + os.close(destination_fd) + if destination_parent >= 0: + os.close(destination_parent) + os.close(source_fd) + os.close(source_parent) +PY +) || return 1 + [[ $SK_STAGED_PROMPT_IDENTITY =~ ^[0-9]+:[0-9]+$ ]] +} + +sk_finalize_prompt_source() { + local source=$1 identity=$2 + python3 - "$source" "$identity" <<'PY' +import os +from pathlib import Path +import sys + +source = Path(sys.argv[1]) +expected = tuple(map(int, sys.argv[2].split(":"))) +info = source.lstat() +if (info.st_dev, info.st_ino) != expected: + raise SystemExit(1) +source.unlink() +directory = os.open(source.parent, os.O_RDONLY | os.O_DIRECTORY) +try: + os.fsync(directory) +finally: + os.close(directory) +PY +} + +sk_abort_prompt_stage() { + local source=$1 destination=$2 identity=$3 + python3 - "$source" "$destination" "$identity" <<'PY' +import os +from pathlib import Path +import sys + +source, destination = map(Path, sys.argv[1:3]) +expected = tuple(map(int, sys.argv[3].split(":"))) +try: + info = source.lstat() +except OSError: + raise SystemExit(1) +if (info.st_dev, info.st_ino) != expected: + raise SystemExit(1) +destination.unlink() +directory = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY) +try: + os.fsync(directory) +finally: + os.close(directory) +PY +} + +sk_write_launch_request() { + local shpool_id=$1 provider=$2 cwd=$3 model=$4 launch_key=$5 + [[ -n $model && $provider =~ ^(claude|codex)$ && $cwd == /* ]] || return 1 + [[ $model != *[$'\t\r\n\034']* && $launch_key != *[$'\t\r\n\034']* ]] || return 1 + [[ -z $launch_key || $launch_key =~ ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$ ]] || return 1 + [[ ! -e $SK_START_DIR/$shpool_id.launch && ! -L $SK_START_DIR/$shpool_id.launch ]] || return 1 + local temporary="$SK_START_DIR/.${shpool_id}.launch.$$" + printf '%s\t%s\t%s\t%s\n' "$provider" "$cwd" "$model" "$launch_key" > "$temporary" || return 1 + chmod 600 "$temporary" 2>/dev/null || true + if ! mv -f -- "$temporary" "$SK_START_DIR/$shpool_id.launch"; then + [[ ! -e $temporary ]] || command rm -- "$temporary" + return 1 + fi +} + +sk_arm_launch_request() { + local shpool_id=$1 provider=$2 cwd=$3 model=$4 launch_key=$5 + local boot_id=$6 started=$7 shell_pid=$8 shell_start=$9 + local daemon_pid=${10} daemon_start=${11} + local path="$SK_START_DIR/$shpool_id.launch" + [[ -f $path && ! -L $path ]] || return 1 + local request_provider request_cwd request_model request_key extra request_line + IFS= read -r request_line < "$path" || return 1 + # A tab is IFS whitespace, so a run of them reads as ONE separator and an + # empty field vanishes -- the launch key is empty for every launch nobody + # passed --launch-key to. Read on \034, which is not whitespace, and every + # field keeps its place. (The husk bug: three launches died of this.) + request_line=${request_line//$'\t'/$'\034'} + IFS=$'\034' read -r request_provider request_cwd request_model request_key extra \ + <<<"$request_line" + [[ -z $extra && $request_provider == "$provider" && $request_cwd == "$cwd" && + $request_model == "$model" && $request_key == "$launch_key" ]] || return 1 + local temporary="$SK_START_DIR/.${shpool_id}.launch-armed.$$" + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$provider" "$cwd" "$model" "$launch_key" "$boot_id" "$started" \ + "$shell_pid" "$shell_start" "$daemon_pid" "$daemon_start" > "$temporary" || return 1 + chmod 600 "$temporary" 2>/dev/null || true + if ! mv -f -- "$temporary" "$path"; then + [[ ! -e $temporary ]] || command rm -- "$temporary" + return 1 + fi +} + +sk_quarantine_launch_request() { + local shpool_id=$1 destination=$2 + local path="$SK_START_DIR/$shpool_id.launch" + [[ ! -e $path && ! -L $path ]] && return 0 + [[ -f $path && ! -L $path ]] || return 1 + mv -- "$path" "$destination.launch" +} + +start_new() { + sk_require_integration || return 1 + local requested_provider= requested_project= prompt_file= requested_model= launch_key= + local requested_account= account_profile_json= requested_worktree= + local refuse_worktree=0 auto_worktree=0 model_anyway=0 + # Unstamped is a person's: a tool that forgets to say it is a machine shows + # up in the human list and gets noticed, rather than hiding by accident. + # SESSION_KIT_ORIGIN lets an automation stamp everything it starts without + # threading a flag through every call site. + local requested_origin= + requested_origin=$(sk_environment_origin) + local -a positional=() + while (($#)); do + case "$1" in + --prompt-file) + [[ -z $prompt_file && -n ${2:-} ]] || { + sk_die "--prompt-file requires one file" + return 2 + } + prompt_file=$2 + shift 2 + ;; + --model) + [[ -z $requested_model && -n ${2:-} ]] || { + sk_die "--model requires one model identifier" + return 2 + } + requested_model=$2 + shift 2 + ;; + --launch-key) + [[ -z $launch_key && -n ${2:-} ]] || { + sk_die "--launch-key requires one idempotency key" + return 2 + } + launch_key=$2 + shift 2 + ;; + --account) + [[ -z $requested_account && -n ${2:-} ]] || { + sk_die "--account requires one enrolled account alias" + return 2 + } + requested_account=$2 + shift 2 + ;; + --origin) + # Who is asking. A machine says so; a person never has to. + [[ -n ${2:-} && ( $2 == human || $2 == machine ) ]] || { + sk_die "--origin requires human or machine" + return 2 + } + requested_origin=$2 + shift 2 + ;; + --worktree) + [[ -z $requested_worktree && -n ${2:-} ]] || { + sk_die "--worktree requires one branch name" + return 2 + } + requested_worktree=$2 + shift 2 + ;; + --no-worktree) + # Delegated work gets its own copy by default. This is how a machine + # session says it means to work in the checkout itself. + refuse_worktree=1 + shift + ;; + --model-anyway) + # The person was told what this machine would really serve and chose + # the model they asked for regardless. Never a substitution either way. + model_anyway=1 + shift + ;; + --*) sk_die "unknown new-session option: $1"; return 2 ;; + *) positional+=("$1"); shift ;; + esac + done + (( ${#positional[@]} <= 2 )) || { + sk_die "new accepts at most a provider and project alias" + return 2 + } + requested_provider=${positional[0]:-} + requested_project=${positional[1]:-} + local provider=shell cwd=$PWD project_row="" + + if [[ $requested_provider =~ ^(claude|codex|shell)$ ]]; then + provider=$requested_provider + elif [[ -n $requested_provider ]]; then + requested_project=$requested_provider + requested_provider="" + fi + + if [[ -n $requested_project ]]; then + # A project carries more than a directory now: a committed session-kit.toml + # can name the provider, account, and model this project is worked on with, + # so `sp new <project>` starts what the project says instead of what the + # person happened to remember. Flags passed here still win, and the + # account and model returned go through the same validation below as one + # typed on the command line. + project_row=$(sk_project_plan "$requested_project" "$requested_provider" \ + "$requested_account" "$requested_model") || { + sk_die "unknown or invalid project: $requested_project" + return 1 + } + local planned_cwd planned_provider planned_account planned_model startup_state + # Same husk: a project that names no account and no model sends two empty + # fields, tab-collapse shifts the rest left, and `sp new <project>` refused + # itself with "the selected account could not be prepared" while the + # startup notice never printed at all. + project_row=${project_row//$'\t'/$'\034'} + IFS=$'\034' read -r planned_cwd planned_provider planned_account planned_model \ + startup_state <<<"$project_row" + [[ -n $planned_cwd ]] || { + sk_die "the project plan for $requested_project was incomplete" + return 1 + } + # A project is a directory, not a provider. One with no default provider + # says which words open it instead of falling through to a shell nobody + # asked for. + [[ -n $planned_provider ]] || { + sk_die "$requested_project has no default provider; name one: sp new claude $requested_project, sp new codex $requested_project, or sp new shell $requested_project" + return 2 + } + cwd=$planned_cwd + provider=$planned_provider + [[ -n $requested_account || -z $planned_account ]] || requested_account=$planned_account + [[ -n $requested_model || -z $planned_model ]] || requested_model=$planned_model + case "$startup_state" in + unapproved) + printf "This project's startup command is not approved here, so it was not run.\n" + printf 'Review it with: session-kit projects launch-plan %s\n' "$requested_project" + printf 'Approve it with: session-kit projects approve-startup %s\n' "$requested_project" + ;; + changed) + printf "This project's startup command changed since it was approved, so it was not run.\n" + printf 'Review it with: session-kit projects launch-plan %s\n' "$requested_project" + printf 'Approve it with: session-kit projects approve-startup %s\n' "$requested_project" + ;; + esac + fi + + [[ -d $cwd ]] || { + sk_die "project directory does not exist: $cwd" + return 1 + } + cwd=$(cd -- "$cwd" && pwd -P) || return 1 + + # Delegated work gets its own copy of the code. A machine session standing in + # a git repository is given a worktree on a branch of its own -- it does not + # have to ask, and two workers on one project stop editing the same files at + # the same time. `--no-worktree` is how an automation says it means to work + # in the checkout itself; a directory that is not a repository has nothing to + # isolate and says so rather than refusing to start. + if [[ -z $requested_worktree && $refuse_worktree == 0 && + $requested_origin == machine ]]; then + local shared_reason= copy_check_status=0 + if ! command git -C "$cwd" rev-parse --show-toplevel >/dev/null 2>&1; then + printf '%s is not a git repository, so this session works in it directly.\n' \ + "$cwd" + else + # `copy-check` answers in exit codes: 0 with a reason means do not copy + # this one, 1 means copying it is fine. Anything else is the check + # failing, and a failed guard is not a pass -- reading "non-zero" as "go + # ahead" is how a session ends up editing a copy of a checkout whose + # files are the running site. Three states, three branches, and the + # unknown one takes the same road as the refusal. + shared_reason=$(python3 "$INVENTORY_CORE" worktree copy-check --repo "$cwd") || + copy_check_status=$? + if (( copy_check_status == 0 )); then + # Two reasons not to copy a repository without being asked: it is the + # running thing (a directory whose files are served live, where a copy + # is work that never takes effect), or it is big enough that copying it + # is a decision somebody should make on purpose. Either way the session + # runs in the checkout and says which. + printf 'This session works in %s itself: %s.\n' "$cwd" \ + "${shared_reason:-it is not copied without being asked}" + printf 'Give it a copy of its own with --worktree BRANCH.\n' + elif (( copy_check_status == 1 )); then + local worktree_stamp= + worktree_stamp=$(sk_now_unix_ms) || return 1 + requested_worktree="sk/w-$worktree_stamp-$$" + auto_worktree=1 + else + printf 'session-kit: could not check whether %s is safe to copy, so this session works in it directly.\n' \ + "$cwd" + printf 'Give it a copy of its own with --worktree BRANCH.\n' + fi + fi + fi + + # Worktree isolation: the branch is already decided elsewhere -- a delegated + # worker's plan records it, a person names it here, or the line above cut one + # for a machine session -- and this materializes it as a directory of its own. + # Idempotent by (repository, branch), so a retried launch reuses the worktree + # instead of forking a second one. + if [[ -n $requested_worktree ]]; then + local worktree_record= worktree_path= + local -a materialize_argv=(python3 "$INVENTORY_CORE" worktree materialize + --repo "$cwd" --branch "$requested_worktree") + # Only a copy the kit chose is a copy the kit gives back on close. A branch + # a person named stays until they say otherwise. + (( auto_worktree == 0 )) || + materialize_argv+=(--auto --origin "$requested_origin") + worktree_record=$("${materialize_argv[@]}") || { + sk_die "no worktree for branch $requested_worktree; no session was created" + return 1 + } + worktree_path=$(printf '%s' "$worktree_record" | python3 -c ' +import json,sys +record = json.load(sys.stdin) +path = record.get("path") or "" +if not isinstance(path, str) or not path.startswith("/"): + raise SystemExit(1) +print(path) +') || { + sk_die "the worktree record did not name an absolute directory" + return 1 + } + [[ -d $worktree_path ]] || { + sk_die "the recorded worktree directory is missing: $worktree_path" + return 1 + } + cwd=$(cd -- "$worktree_path" && pwd -P) || return 1 + fi + + if [[ -n $prompt_file && $provider != codex ]]; then + sk_die "--prompt-file is available only for a new Codex session" + return 2 + fi + if [[ -n $requested_account ]]; then + [[ $provider =~ ^(claude|codex)$ && + $requested_account =~ ^[a-z][a-z0-9_-]{0,11}$ ]] || { + sk_die "--account is available only for Claude or Codex with an enrolled alias" + return 2 + } + local account_error + account_error=$(mktemp) || return 1 + account_profile_json=$(python3 "$INVENTORY_CORE" account launch-profile \ + "$provider" "$requested_account" 2>"$account_error") || { + # The core names the failed predicate (blocked state, stale feed, + # missing enrollment). Print that truth, never a guessed login problem. + sk_die "$(grep -m1 . "$account_error" 2>/dev/null || + echo "the selected $provider account could not be prepared")" + command rm -f -- "$account_error" + return 1 + } + command rm -f -- "$account_error" + python3 -c ' +import json,sys +v=json.loads(sys.stdin.read()) +raise SystemExit(0 if v.get("provider")==sys.argv[1] and v.get("alias")==sys.argv[2] + and isinstance(v.get("profile_dir"),str) and v["profile_dir"].startswith("/") else 1) +' "$provider" "$requested_account" <<<"$account_profile_json" || { + sk_die "the selected account profile returned an invalid launch identity" + return 1 + } + fi + if [[ -n $requested_model ]]; then + [[ $provider =~ ^(claude|codex)$ ]] || { + sk_die "--model is available only for Claude or Codex" + return 2 + } + local validated_model + validated_model=$(python3 "$INVENTORY_CORE" validate-worker-model \ + "$provider" "$requested_model" 2>/dev/null) || { + sk_die "unsupported or unsafe $provider model identifier" + return 2 + } + [[ $validated_model == "$requested_model" ]] || { + sk_die "model validation did not preserve the identifier" + return 2 + } + # The identifier is well formed. Whether this machine actually answers it + # with that model is a different question, and it is asked here -- before + # the session exists -- because a session that quietly runs on something + # else runs on it for the whole conversation. Nothing is substituted: the + # refusal names what would serve the request, and --model-anyway is how a + # person says they meant it. + if (( model_anyway == 0 )); then + local availability_message= availability_status=0 + availability_message=$(python3 "$INVENTORY_CORE" model-availability \ + "$provider" "$requested_model" --flag=--model-anyway 2>&1 >/dev/null) || + availability_status=$? + if (( availability_status == 3 )); then + [[ -z $availability_message ]] || printf '%s\n' "$availability_message" >&2 + sk_die "no session was created" + return 2 + fi + if (( availability_status != 0 )); then + # The check itself could not run. That is not evidence the model is + # wrong, and it is never evidence it is right: say which -- including + # whatever the check itself said -- and start exactly what was asked. + [[ -z $availability_message ]] || printf '%s\n' "$availability_message" >&2 + printf 'session-kit: could not check whether this machine serves %s; starting it as asked.\n' \ + "$requested_model" >&2 + elif [[ -n $availability_message && -t 2 ]]; then + # A verdict of "unknown" arrives here: nothing on this machine can + # confirm the model, which is not a refusal and never stops a launch. + # A person is told, once, at the moment they asked. A scripted or + # delegated launch is not: repeating "nobody has confirmed this" into + # every log line is how a real warning stops being read. Anything can + # ask for the verdict outright with `session-kit model-availability`. + printf '%s\n' "$availability_message" >&2 + fi + fi + elif [[ -n $launch_key ]]; then + sk_die "--launch-key requires --model" + return 2 + fi + if [[ -n $launch_key ]]; then + launch_key=$(python3 - "$launch_key" <<'PY' +import re +import sys +value = sys.argv[1].strip() +if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", value) is None: + raise SystemExit(1) +print(value) +PY + ) || { + sk_die "--launch-key is not a supported idempotency key" + return 2 + } + fi + + local launch_uuid= launch_mode=new + if [[ $provider == claude ]] && sk_prebake_claude; then + # The window opens as a resume of the pre-baked conversation, so the + # session color is already native in its very first frame. + launch_uuid=$SK_PREBAKE_UUID + launch_mode=resume + elif [[ ${SK_PREBAKE_STATUS:-off} == failed ]]; then + # It was attempted and did not finish. The session still opens; its color + # arrives at the next start instead of the first frame, and saying so + # beats a window that quietly does not match the picker. + printf 'The first-frame color step did not finish; this window takes its color from its next start.\n' >&2 + fi + sk_prepare_state || return 1 + exec 9>"$SK_STATE_DIR/create.lock" || return 1 + sk_lock_acquire 9 "$SK_STATE_DIR/create.lock" || { exec 9>&-; return 1; } + if ! sk_require_integration; then + sk_lock_release 9 + return 1 + fi + local id + id=$(sk_allocate_id) || { + sk_lock_release 9 + sk_die "could not allocate a unique session ID" + return 1 + } + local prompt_handoff= + if [[ -n $prompt_file ]]; then + prompt_handoff=$SK_START_DIR/$id.prompt + if ! sk_stage_prompt_handoff "$prompt_file" "$prompt_handoff"; then + sk_lock_release 9 + sk_die "prompt file must be a readable owner-private mode-0600 regular UTF-8 file" + sk_log_action prompt_handoff refused || true + return 1 + fi + if ! sk_log_action prompt_handoff created; then + sk_abort_prompt_stage "$prompt_file" "$prompt_handoff" \ + "$SK_STAGED_PROMPT_IDENTITY" >/dev/null 2>&1 || true + sk_lock_release 9 + sk_die "prompt handoff could not be audited; no session was created and at least one private copy was retained" + return 1 + fi + fi + sk_write_start_record "$id" "$provider" "$cwd" "$launch_uuid" "$launch_mode" || { + [[ -z $prompt_handoff ]] || + sk_abort_prompt_stage "$prompt_file" "$prompt_handoff" \ + "$SK_STAGED_PROMPT_IDENTITY" >/dev/null 2>&1 || true + sk_lock_release 9 + return 1 + } + if [[ -n $requested_account ]] && + ! sk_write_account_record "$id" "$provider" "$requested_account"; then + command rm -f -- "$SK_START_DIR/$id" "$SK_START_DIR/$id.account" + [[ -z $prompt_handoff ]] || + sk_abort_prompt_stage "$prompt_file" "$prompt_handoff" \ + "$SK_STAGED_PROMPT_IDENTITY" >/dev/null 2>&1 || true + sk_lock_release 9 + sk_die "selected account launch record could not be written; no session was created" + return 1 + fi + if [[ -n $requested_model ]] && + ! sk_write_launch_request "$id" "$provider" "$cwd" "$requested_model" "$launch_key"; then + command rm -f -- "$SK_START_DIR/$id" "$SK_START_DIR/$id.account" + [[ -z $prompt_handoff ]] || + sk_abort_prompt_stage "$prompt_file" "$prompt_handoff" \ + "$SK_STAGED_PROMPT_IDENTITY" >/dev/null 2>&1 || true + sk_lock_release 9 + sk_die "requested model launch record could not be written; no session was created" + return 1 + fi + if [[ -n $prompt_handoff ]] && + ! sk_finalize_prompt_source "$prompt_file" "$SK_STAGED_PROMPT_IDENTITY"; then + sk_log_action prompt_handoff source-retained >/dev/null 2>&1 || true + fi + if [[ -n $requested_worktree ]]; then + # The session exists either way; an unbound record only costs the picker + # its session label, so this reports rather than unwinds a live launch. + local -a bind_argv=(python3 "$INVENTORY_CORE" worktree bind + --path "$cwd" --shpool-id "$id") + [[ -z $launch_key ]] || bind_argv+=(--launch-key "$launch_key") + if ! "${bind_argv[@]}" >/dev/null; then + printf 'session-kit: the session runs in %s, but its worktree record was not updated\n' \ + "$cwd" >&2 + fi + if (( auto_worktree )); then + printf 'Its own copy of the code on branch %s. It goes back when this session closes, unless there is unmerged work in it.\n' \ + "$requested_worktree" + else + printf 'Running in its own worktree on branch %s.\n' "$requested_worktree" + fi + fi + # The one verb that CREATES a session logged nothing at all, so "what made + # this session, and when" had no answer anywhere on the machine. It does now, + # before the session can exist. + python3 "$INVENTORY_CORE" action-log session_create requested \ + --session "$id" >/dev/null 2>&1 || true + printf 'Starting a %s session in %s\n' "$(sk_provider_name "$provider")" "$cwd" + local creation_floor_ms + creation_floor_ms=$(sk_now_unix_ms) || { + sk_lock_release 9 + return 1 + } + local allocated_id=$id + if ! sk_attach_new_unique "$id" "$cwd" "$requested_origin"; then + id=$SK_ATTACHED_ID + local quarantine + quarantine=$(sk_quarantine_start_record "$id" attach-failed 2>/dev/null || true) + [[ -z $requested_model || -z $quarantine ]] || + sk_quarantine_launch_request "$id" "$quarantine" >/dev/null 2>&1 || true + sk_lock_release 9 + sk_die "the session manager did not confirm the new session; nothing was opened. Try 'sp new' again" + return 1 + fi + id=$SK_ATTACHED_ID + # sk_attach_new_unique, called above, publishes this sourced-module field. + # shellcheck disable=SC2153 + sk_attach_status=$SK_ATTACH_STATUS + if [[ $id != "$allocated_id" ]]; then + [[ -z $prompt_handoff ]] || prompt_handoff=$SK_START_DIR/$id.prompt + python3 "$INVENTORY_CORE" action-log session_create requested \ + --session "$id" >/dev/null 2>&1 || true + if [[ -n $requested_worktree ]]; then + local -a rebound_argv=(python3 "$INVENTORY_CORE" worktree bind + --path "$cwd" --shpool-id "$id") + [[ -z $launch_key ]] || rebound_argv+=(--launch-key "$launch_key") + "${rebound_argv[@]}" >/dev/null || + printf 'session-kit: the new session uses %s, but its worktree record still names the stale entry %s\n' \ + "$id" "$allocated_id" >&2 + fi + fi + # A timed-out reply (124/137) does not prove the session was NOT created; + # generation capture below settles it either way. + if ! sk_capture_session_generation "$id" "$creation_floor_ms"; then + local quarantine + quarantine=$(sk_quarantine_start_record "$id" generation-unproven 2>/dev/null || true) + [[ -z $requested_model || -z $quarantine ]] || + sk_quarantine_launch_request "$id" "$quarantine" >/dev/null 2>&1 || true + sk_lock_release 9 + sk_die "the session may be open, but Session Kit could not confirm it. Check 'sp list' before starting another" + return 1 + fi + local created_started=$SK_CREATED_STARTED + local created_boot_id=$SK_CREATED_BOOT_ID + local created_shell_pid=$SK_CREATED_SHELL_PID created_shell_start=$SK_CREATED_SHELL_START + local created_daemon_pid=$SK_CREATED_DAEMON_PID created_daemon_start=$SK_CREATED_DAEMON_START + # A reusable manager name is not provenance. Stamp only after the exact + # shell generation is known; until then the safe default leaves the row in + # the person's list rather than binding this origin to a later namesake. + sk_record_origin "$id" "$requested_origin" "$created_started" \ + "$created_shell_pid" "$created_shell_start" + if [[ -n $requested_model ]] && + ! sk_arm_launch_request "$id" "$provider" "$cwd" "$requested_model" "$launch_key" \ + "$created_boot_id" "$created_started" "$created_shell_pid" "$created_shell_start" \ + "$created_daemon_pid" "$created_daemon_start"; then + local quarantine + quarantine=$(sk_quarantine_start_record "$id" model-arming-failed 2>/dev/null || true) + [[ -z $quarantine ]] || + sk_quarantine_launch_request "$id" "$quarantine" >/dev/null 2>&1 || true + sk_lock_release 9 + sk_die "the session is open, but the model you asked for could not be set. Close it from the picker and start again" + return 1 + fi + sk_write_generation_record "$id" "$provider" "$cwd" "$launch_uuid" \ + "$created_boot_id" "$created_started" "$created_shell_pid" "$created_shell_start" \ + "$created_daemon_pid" "$created_daemon_start" "$launch_mode" || { + local quarantine + quarantine=$(sk_quarantine_start_record "$id" arming-failed 2>/dev/null || true) + [[ -z $requested_model || -z $quarantine ]] || + sk_quarantine_launch_request "$id" "$quarantine" >/dev/null 2>&1 || true + sk_lock_release 9 + sk_die "the session is open, but Session Kit could not record how it started. Close it from the picker and start again" + return 1 + } + sk_lock_release 9 + if [[ $provider == shell ]] && + ! sk_clear_start_record "$id" "$provider" "$cwd" "$launch_uuid" \ + "$created_boot_id" "$created_started" "$created_shell_pid" \ + "$created_shell_start" "$created_daemon_pid" "$created_daemon_start" \ + "$launch_mode"; then + sk_die "the session is open, but Session Kit could not clear its launch record" + return 1 + fi + if [[ $provider != shell ]] && ! sk_wait_for_provider "$id" "$provider" "$cwd" \ + "$launch_uuid" \ + "$created_boot_id" "$created_started" "$created_shell_pid" "$created_shell_start" \ + "$created_daemon_pid" "$created_daemon_start" "$launch_mode"; then + sk_die "the session opened, but Session Kit could not confirm that $(sk_provider_name "$provider") started. Find it in 'sp list' and run 'sp verify-start <session>'" + return 1 + fi + if [[ $provider != shell && -n ${SK_PROVEN_UUID:-} ]]; then + if [[ -n $requested_account ]] && + ! python3 "$INVENTORY_CORE" account bind "$provider" "$SK_PROVEN_UUID" \ + "$requested_account" --source launch >/dev/null; then + sk_die "the session is open, but its verified account binding could not be recorded" + return 1 + fi + # If Claude's first-window pre-bake could not complete, reserve a color + # against the exact live palette now that its conversation ID is known. + # The session is still detached here, so the native propagation lands + # before the caller can attach. Future resumes keep the stored override. + if [[ $provider == claude && $launch_mode == new ]]; then + python3 "$INVENTORY_CORE" color conversation-pick claude "$SK_PROVEN_UUID" \ + >/dev/null 2>&1 || true + fi + # Every proven provider session gets its stable color pushed once at + # creation, so Claude shows it natively from the first resume onward. A + # failure here is the difference between a window that matches the picker + # and one that does not, so it is reported rather than swallowed. + if ! python3 "$INVENTORY_CORE" color propagate "$provider" "$SK_PROVEN_UUID" \ + >/dev/null 2>&1; then + printf 'The color could not be written for this session; it takes the kit color from its next start.\n' >&2 + fi + fi + if [[ ${SESSION_KIT_BACKGROUND:-0} == 1 ]]; then + printf '%s\n' "$id" + return + fi + # `sp new` used to end on a promise -- "Starting a Claude session" -- and + # never said whether it arrived. Say what happened, by the number the picker + # and `sp list` show, before the attach takes the screen. + if make_snapshot >/dev/null 2>&1 && + sk_resolve "$SNAPSHOT" "$id" >/dev/null 2>&1 && + [[ -n ${SK_NUMBER:-} ]]; then + printf 'Opened session %s.\n' "$SK_NUMBER" + else + printf 'Opened the session.\n' + fi + # attach_id execs, so the EXIT trap never runs from here. Drop the snapshot + # now rather than leave one temp file behind per created session. + cleanup_snapshot + SK_PROVIDER=$provider + SK_UUID=${SK_PROVEN_UUID:-} + attach_id "$id" +} + +restore_exact() { + local provider=$1 uuid=$2 cwd=$3 account_alias=${4:-} requested_model=${5:-} + sk_require_integration || return 1 + if [[ -n $requested_model ]]; then + # A restore that names a model is the model-change path: the same + # conversation, resumed by a provider started on the model asked for. + # Validation is the same one `sp new --model` uses, so an identifier the + # kit would refuse to launch can never reach a launch record. + local validated_model + validated_model=$(python3 "$INVENTORY_CORE" validate-worker-model \ + "$provider" "$requested_model" 2>/dev/null) || { + sk_die "unsupported or unsafe $provider model identifier" + return 2 + } + [[ $validated_model == "$requested_model" ]] || { + sk_die "model validation did not preserve the identifier" + return 2 + } + fi + [[ $provider =~ ^(claude|codex)$ ]] || { + sk_die "restore provider must be claude or codex" + return 1 + } + [[ $uuid =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]] || { + sk_die "restore requires a conversation UUID" + return 1 + } + uuid=${uuid,,} + # A delegated session ran in a working copy that its own close gave back, so + # the directory a restore is handed can be one the kit removed on purpose. + # The tombstone the release leaves behind names the repository that copy came + # from, and the conversation reopens there rather than dead-ending on a + # directory that no longer exists. + if [[ $cwd == /* && ! -d $cwd ]]; then + local released_repo= + released_repo=$(python3 "$INVENTORY_CORE" worktree lookup --path "$cwd" \ + --include-released 2>/dev/null | python3 -c ' +import json,sys +try: + record = json.load(sys.stdin) +except Exception: + raise SystemExit(1) +repo = record.get("repo") or "" +if not record.get("released") or not isinstance(repo, str) or not repo.startswith("/"): + raise SystemExit(1) +print(repo) +' 2>/dev/null) || released_repo="" + if [[ -n $released_repo && -d $released_repo ]]; then + printf 'Its own copy of the code was given back when it closed, so this reopens in %s.\n' \ + "$released_repo" + cwd=$released_repo + fi + fi + [[ $cwd == /* && -d $cwd ]] || { + sk_die "restore cwd is not an existing absolute directory" + return 1 + } + cwd=$(cd -- "$cwd" && pwd -P) || return 1 + if [[ -z $account_alias ]]; then + # A conversation bound to an account profile only exists in that + # profile's transcript tree; restoring it on the default profile kills + # the original and then fails identity proof. When no caller names the + # alias, the binding ledger does, best-effort, empty when unproven. + account_alias=$(python3 "$INVENTORY_CORE" account source "$provider" "$uuid" \ + 2>/dev/null | + python3 -c 'import json,sys +try: + print(json.loads(sys.stdin.read()).get("alias") or "") +except Exception: + print("")' 2>/dev/null) || account_alias="" + [[ $account_alias =~ ^[a-z][a-z0-9_-]{0,11}$ ]] || account_alias="" + fi + if [[ -n $account_alias ]]; then + [[ $account_alias =~ ^[a-z][a-z0-9_-]{0,11}$ ]] || { + sk_die "restore account alias is invalid" + return 1 + } + python3 "$INVENTORY_CORE" account resume-profile "$provider" "$account_alias" \ + >/dev/null 2>&1 || { + sk_die "restore account profile is unavailable or does not match its login" + return 1 + } + fi + + sk_prepare_state || return 1 + exec 9>"$SK_STATE_DIR/create.lock" || return 1 + sk_lock_acquire 9 "$SK_STATE_DIR/create.lock" || { exec 9>&-; return 1; } + if ! sk_require_integration; then + sk_lock_release 9 + return 1 + fi + # Recovery uses the same guard inventory as every other mutating command. + # A half-started session that has not yet published a provider identity is + # unrelated to this restore and must not be able to block it; the exact + # guarantees that matter here are the duplicate-conversation check below and + # the exact generation proof captured after creation. + make_guard_snapshot || { + sk_lock_release 9 + return 1 + } + local duplicate_check ignore_stale_id=${SK_RESTORE_IGNORE_STALE_ID:-} + duplicate_check=$(python3 -c ' +import json,sys +d=json.load(open(sys.argv[1])) +wanted_provider=sys.argv[2] +wanted_uuid=sys.argv[3].lower() +stale_id=sys.argv[4] +rows=[ + row for row in d.get("sessions",[]) + if not stale_id or row.get("shpool_id_raw") != stale_id +] +rows+=d.get("outside_agents",[]) +print("yes" if any( + isinstance(row,dict) + and row.get("provider")==wanted_provider + and isinstance((row.get("identity") or {}).get("uuid"),str) + and row["identity"]["uuid"].lower()==wanted_uuid + for row in rows +) else "no") + ' "$SNAPSHOT" "$provider" "$uuid" "$ignore_stale_id") || { + sk_lock_release 9 + sk_die "could not verify open conversations; nothing was restored" + return 1 + } + if [[ $duplicate_check == yes ]]; then + sk_lock_release 9 + sk_die "that conversation is already open" + return 1 + elif [[ $duplicate_check != no ]]; then + sk_lock_release 9 + sk_die "active conversation UUID check returned an invalid result" + return 1 + fi + + local id + id=$(sk_allocate_id) || { + sk_lock_release 9 + return 1 + } + sk_write_start_record "$id" "$provider" "$cwd" "$uuid" resume || { + sk_lock_release 9 + return 1 + } + # A restore brings back a session that already had an origin, so the record + # of what it was outranks any reading of who is restoring it -- in BOTH + # directions. Reading only the machine half meant a conversation recorded as + # the operator's, restored by anything running inside a session (a sweep, or + # the operator typing `sp restore-exact` in one of their own windows), fell + # through to the caller reading and came back a machine's: their own + # conversation, gone from their own list, by their own keystroke. Repair paths + # still declare an origin explicitly and that declaration wins over both. + # Without any of this, the recovery sweep -- which runs from the login + # shell, where nothing says "machine" -- brought every agent's session back + # as one of the operator's rows. + local restore_origin=${SESSION_KIT_ORIGIN:-} + if [[ $restore_origin != human && $restore_origin != machine ]]; then + restore_origin=$(sk_recorded_origin "$provider" "$uuid") + case $restore_origin in + human|machine) ;; + # Everything else -- no record, a ledger that did not read, a ledger + # that would not parse -- is UNKNOWN provenance, and unknown is the + # person's. The caller is deliberately not consulted here. Reading the + # caller answers the question "who is creating this?", and a restore + # creates nothing: it brings back a conversation that already had an + # owner. Answering the wrong question froze the caller's context into a + # permanent machine stamp for any conversation with no record -- so a + # repair, a model change, or `sp restore-exact` typed inside one of the + # operator's own windows took a session out of their list and no later + # refresh could put it back. An agent that wants its restore marked + # says so with SESSION_KIT_ORIGIN, exactly as it does at creation. + *) restore_origin=human ;; + esac + fi + if [[ -n $account_alias ]] && + ! sk_write_account_record "$id" "$provider" "$account_alias"; then + command rm -f -- "$SK_START_DIR/$id" + sk_lock_release 9 + sk_die "restore account launch record could not be written; nothing launched" + return 1 + fi + if [[ -n $requested_model ]] && + ! sk_write_launch_request "$id" "$provider" "$cwd" "$requested_model" ""; then + command rm -f -- "$SK_START_DIR/$id" "$SK_START_DIR/$id.account" + sk_lock_release 9 + sk_die "requested model launch record could not be written; nothing launched" + return 1 + fi + local creation_floor_ms + creation_floor_ms=$(sk_now_unix_ms) || { + sk_lock_release 9 + return 1 + } + # A restored conversation must come back looking like itself: same name, + # same color, with nobody having to do anything. Both are provider-store + # writes that only take effect at a process start, so both happen HERE, + # before the provider is launched, and a failure is said out loud rather + # than discovered on a nameless window. + if ! python3 "$INVENTORY_CORE" color propagate "$provider" "$uuid" \ + >/dev/null 2>&1; then + printf 'The color could not be written for this conversation; the window opens in its own color.\n' >&2 + fi + local name_push_status=0 + python3 "$INVENTORY_CORE" alias push "$provider" "$uuid" >/dev/null || + name_push_status=$? + case $name_push_status in + 0) ;; + 3) + # Some surfaces took the name and some did not. On Codex that means the + # index has it and the status bar does not, which reads as a nameless + # window even though a push "succeeded" -- so it is said, not hidden. + printf 'The name reached only part of this conversation; the window may open unnamed.\n' >&2 + ;; + *) + printf 'The name could not be written for this conversation; the window opens unnamed.\n' >&2 + ;; + esac + if ! sk_attach_new_unique "$id" "$cwd" "$restore_origin"; then + id=$SK_ATTACHED_ID + local quarantine + quarantine=$(sk_quarantine_start_record "$id" attach-failed 2>/dev/null || true) + sk_lock_release 9 + sk_die "the session manager did not confirm the restored session; nothing was opened. Try the restore again" + return 1 + fi + id=$SK_ATTACHED_ID + sk_attach_status=$SK_ATTACH_STATUS + if ! sk_capture_session_generation "$id" "$creation_floor_ms"; then + local quarantine + quarantine=$(sk_quarantine_start_record "$id" generation-unproven 2>/dev/null || true) + sk_lock_release 9 + sk_die "the restored session may be open, but Session Kit could not confirm it. Check 'sp list' before restoring again" + return 1 + fi + sk_record_origin "$id" "$restore_origin" "$SK_CREATED_STARTED" \ + "$SK_CREATED_SHELL_PID" "$SK_CREATED_SHELL_START" + if [[ -n $requested_model ]] && + ! sk_arm_launch_request "$id" "$provider" "$cwd" "$requested_model" "" \ + "$SK_CREATED_BOOT_ID" "$SK_CREATED_STARTED" "$SK_CREATED_SHELL_PID" \ + "$SK_CREATED_SHELL_START" "$SK_CREATED_DAEMON_PID" "$SK_CREATED_DAEMON_START"; then + local quarantine + quarantine=$(sk_quarantine_start_record "$id" model-arming-failed 2>/dev/null || true) + [[ -z $quarantine ]] || + sk_quarantine_launch_request "$id" "$quarantine" >/dev/null 2>&1 || true + sk_lock_release 9 + sk_die "the session is open, but the model you asked for could not be set. Close it from the picker and restore again" + return 1 + fi + sk_write_generation_record "$id" "$provider" "$cwd" "$uuid" \ + "$SK_CREATED_BOOT_ID" "$SK_CREATED_STARTED" "$SK_CREATED_SHELL_PID" "$SK_CREATED_SHELL_START" \ + "$SK_CREATED_DAEMON_PID" "$SK_CREATED_DAEMON_START" resume || { + local quarantine + quarantine=$(sk_quarantine_start_record "$id" arming-failed 2>/dev/null || true) + sk_lock_release 9 + sk_die "the restored session is open, but Session Kit could not record how it started. Close it from the picker and restore again" + return 1 + } + local created_started=$SK_CREATED_STARTED + local created_boot_id=$SK_CREATED_BOOT_ID + local created_shell_pid=$SK_CREATED_SHELL_PID created_shell_start=$SK_CREATED_SHELL_START + local created_daemon_pid=$SK_CREATED_DAEMON_PID created_daemon_start=$SK_CREATED_DAEMON_START + sk_lock_release 9 + if ! sk_wait_for_provider "$id" "$provider" "$cwd" "$uuid" \ + "$created_boot_id" "$created_started" "$created_shell_pid" "$created_shell_start" \ + "$created_daemon_pid" "$created_daemon_start" resume; then + sk_die "the session opened, but Session Kit could not confirm that $(sk_provider_name "$provider") reopened the conversation. Find it in 'sp list' and run 'sp verify-start <session>'" + return 1 + fi + if [[ -n $account_alias ]] && + ! python3 "$INVENTORY_CORE" account bind "$provider" "$uuid" "$account_alias" \ + --source restore >/dev/null; then + sk_die "the conversation was restored, but its account binding could not be recorded" + return 1 + fi + # Keep the closed evidence. The shared recovery projection hides it while + # this exact conversation is live, then can offer it again if the restored + # provider disappears before another loss snapshot records the transition. + printf '%s\n' "$id" +} diff --git a/macos/README.md b/macos/README.md new file mode 100644 index 0000000..07852b7 --- /dev/null +++ b/macos/README.md @@ -0,0 +1,135 @@ +# macOS support + +Session Kit supports macOS 14 or newer on Apple Silicon and Intel. It needs a +normal user who is logged into the Mac desktop, because that login is what +creates the per-user `gui/$UID` launchd domain the services load into. It also +needs Homebrew Bash 4 or newer, Python 3.11 or newer, official shpool 0.11.0, +and a local Claude Code, Codex, or both. + +```bash +brew install bash python rust +cargo install shpool --version 0.11.0 --locked +``` + +Follow the [installation guide](../docs/install.md) for the release install. +Apple ships Bash 3.2, which cannot run this code, so `install.sh` and +`session-kit` re-execute themselves under `/opt/homebrew/bin/bash` or +`/usr/local/bin/bash` when they are started by it. If neither exists they exit +`69` with the `brew` and `cargo` commands above rather than running in a shell +that would misparse them. + +Installation may add the opt-in login integration to `.bashrc`, +`.bash_profile`, and `.zshrc`, but it never replaces the account's login shell. +zsh receives only the Session Kit command path, so a normal zsh SSH login stays +a normal shell and the picker opens when you type `kit`. Managed shpool sessions +run the exact modern Bash the installer validated, recorded in the +owner-controlled shpool configuration; a conflicting `shell` setting there is +refused rather than silently replaced. + +## LaunchAgents + +Installation generates three LaunchAgent templates in +`~/.config/session-kit/launchd/`. A first install does not load them; activate +the jobs explicitly while logged into the Mac desktop. On an update or +rollback, an already-loaded watchdog is kickstarted onto the selected release. +The shpool and reaper jobs are not restarted automatically. + +```bash +session-kit services enable +session-kit services status +session-kit services disable +``` + +The three jobs are `com.session-kit.shpool`, which keeps the shpool daemon +available with `KeepAlive` and a ten-second throttle; +`com.session-kit.reaper`, which runs the safe-state cleanup pass hourly; and +`com.session-kit.watchdog`, which runs one report-only check every 60 seconds +under `SESSION_KIT_WATCHDOG_MODE=report`. They are user LaunchAgents, not +privileged LaunchDaemons, so they provide no service persistence before a GUI +login. Each job's stdout and stderr go to +`~/.local/state/session-kit/logs/<label>.{out,err}.log`, created mode `0600`. +launchd gives Session Kit no retention or rotation guarantee for them, so manage +their size yourself. + +`services enable` refuses to load the shpool job while an unmanaged shpool +daemon is already reachable, or while a probe for one times out. A second daemon +on the same socket is the failure that loses sessions, so an ambiguous probe is +treated as a positive. It also refuses to overwrite a loaded job whose active +plist differs from the generated template, that combination means an update +left a pending definition, and quietly replacing the file would leave the loaded +job and the file on disk describing different things. + +`services disable` holds the Session Kit creation lock, proves shpool reports +zero sessions, unloads the watchdog and reaper, proves the list is still empty, +and only then unloads shpool. Unloading shpool stops the daemon and takes the +managed terminals with it, which is why the proof is taken twice around the +window where a new session could appear. Before it removes any active plist it +verifies that file against the generated template or the digest in its private +receipt, and refuses a modified or unowned one. + +Update and rollback regenerate the templates and kickstart an already-loaded +watchdog. They do not unload or reload shpool or the reaper. To adopt changed +definitions for those jobs, reach a point with no managed sessions and cycle +`services disable` then `services enable` deliberately. See +[Update and roll back](../docs/update-and-rollback.md) and +[Troubleshooting](../docs/troubleshooting.md#macos-services-are-not-running). + +## Native identity and lifecycle + +Darwin process identity comes from `PROC_PIDTBSDINFO`. The recorded generation +is the native process start timestamp in epoch microseconds. Session Kit reads +that identity before and after `KERN_PROCARGS2` and discards any PID whose +identity changed between the two reads, because a PID can be reused mid-read and +the argv it returned would then belong to a different process. Boot identity +comes from `kern.boottime`. + +Codex identity uses the exact live process's `CODEX_THREAD_ID` together with one +owner-controlled local rollout whose structured UUID matches it; the rollout +supplies turn and reply state. Claude Code uses its structured agent state +joined to the same exact native process tree. Missing, duplicated, or changed +identity fails closed. + +The supported macOS lifecycle covers inventory, picker, new, attach, takeover, +exact resume and fork, automatic naming, journals, provider exit and reopen, the +safe-state reaper, and manual `sp prune`. + +## Current limits + +- Watchdog repair mode is Linux-only because it depends on Linux daemon-thread + evidence. The macOS watchdog reports findings and refuses repair mode before + it changes anything. +- Two reaper maintenance passes are skipped on Darwin: expiring retired picker + temporary files, and garbage-collecting stale immutable releases. Installed + releases therefore accumulate under `~/.local/lib/session-kit/releases/` on + macOS until they are removed by hand. +- Per-user service persistence requires the account's Mac desktop session to be + logged in. A privileged or pre-login LaunchDaemon is not supported. +- Provider versions outside the combinations named in the release evidence are + best-effort, because provider state formats and commands can change. +- GitHub CI runs native macOS 15 runners on Apple Silicon and Intel against the + Darwin adapter and fixture-based lifecycle and export behavior. It does not + load a user LaunchAgent or start a provider TUI, so it proves nothing about + real shpool, provider, LaunchAgent, SSH reconnect, or reboot behavior. + +## Release acceptance + +CI passing on both architectures does not release a beta. A dedicated supported +Mac must first verify the real-device lifecycle below, and the release notes +must state which coverage came from CI and which from that device: + +1. Native process start identity, including rapid PID churn between the two + identity reads. +2. Stable `kern.boottime` across one boot. Fixture tests already verify that a + changed boot identity invalidates saved process generations, so acceptance + does not require rebooting the device. +3. List, picker, new, attach, takeover, resume, fork, naming, journals, provider + exit, reopen, reaper, and manual prune against real shpool sessions. +4. Exact Claude Code and Codex identity and reply state for the release's pinned + provider versions. +5. SSH disconnect and reattach while the GUI user LaunchAgent domain stays + available. +6. Explicit service enable, status, and safe disable, including the refusal of + an unmanaged daemon and the refusal to unload a nonempty shpool daemon. +7. Report-only watchdog behavior and repair-mode refusal. +8. Update, rollback, and uninstall without disturbing live sessions or deleting + retained private data. diff --git a/public-files.txt b/public-files.txt index ba18248..9257a63 100644 --- a/public-files.txt +++ b/public-files.txt @@ -1,10 +1,18 @@ +# Public export manifest for tools/build-public-tree. +# fnmatch patterns, one per line; every tracked file that matches is exported. +# Rule: every tracked file is either matched here or deliberately private. +# The deliberately-private files are listed, with a reason each, in +# tests/test_manifest_coverage.py. That test fails when a tracked file appears +# in neither place, so an omission cannot pass review unseen. Do not move a +# file between the two without a maintainer decision. .github/** .gitignore .shellcheckrc CHANGELOG.md +CODE_OF_CONDUCT.md CONTRIBUTING.md LICENSE -LICENSES/** +LICENSES/Apache-2.0.txt README.md requirements-dev.txt ruff.toml @@ -16,34 +24,159 @@ config/** docs/** deploy/session-kit-launcher deploy/session-kit-release +extras/** install.sh lib/** +lib/sh/session_kit_checks.sh +lib/sh/session_kit_doctor.sh +lib/sh/session_kit_install.sh +lib/sh/session_kit_lifecycle.sh +lib/sh/session_kit_login.sh +lib/sh/session_kit_projects.sh +lib/sh/session_kit_services.sh +lib/sh/session_kit_uninstall.sh +lib/sh/shpool_login_actions.sh +lib/sh/shpool_login_live.sh +lib/sh/shpool_login_projects.sh +lib/sh/shpool_login_recovery.sh +lib/sh/shpool_login_render.sh +lib/sh/shpool_login_theme.sh +lib/sh/shpool_login_view.sh +lib/sh/sp_commands.sh +lib/sh/sp_completion.bash +lib/sh/sp_core.sh +lib/sh/sp_picker.sh +lib/sh/sp_provider_bounce.sh +lib/sh/sp_sessions.sh +macos/README.md public-files.txt shpool-patch/** shpool/config.toml systemd/** tests/__init__.py +tests/latency/shim-ppid/python3 +tests/latency/shim/python3 +tests/latency/shim/stty +tests/latency/shim/tput tests/run +tests/sandbox_guard.py +tests/shard.py tests/support.py +tests/sweep_sandboxes.py +tests/test_account_advice_providers.py +tests/test_account_auto_switch.py +tests/test_account_picker_ux.py +tests/test_account_switch_guard.py +tests/test_accounts.py +tests/test_app_server_gate.py +tests/test_app_server_reaper.py +tests/test_attach_history.py +tests/test_attach_tty_handoff.py +tests/test_attention_notify.py +tests/test_auto_name_reliability.py +tests/test_cli_help.py +tests/test_close_intent.py tests/test_commands.py tests/test_config.py +tests/test_coordination_broker.py +tests/test_daemon_identity.py tests/test_doc_links.py +tests/test_exit_closes_session.py tests/test_facade.py +tests/test_human_output_ids.py +tests/test_idle_state.py +tests/test_history_search.py tests/test_install.py tests/test_inventory.py +tests/test_journal_render.py +tests/test_launch_log.py tests/test_lifecycle_shell.py tests/test_login.py tests/test_macos_preview.py +tests/test_manifest_coverage.py +tests/test_model_launch.py +tests/test_notice_routing.py +tests/test_operator_drill.py +tests/test_attention_truth.py +tests/test_picker_attention_truth.py +tests/test_picker_events.py +tests/test_pinned_formats.py +tests/test_probe_side_effects.py +tests/test_picker_exit_grammar.py +tests/test_picker_input_safety.py +tests/test_picker_latency_budget.py +tests/test_picker_projects.py +tests/test_model_availability.py +tests/test_picker_rows_grammar.py +tests/test_picker_stderr_survives.py +tests/test_picker_ux.py +tests/test_project_dir_name.py +tests/test_readme_art.py +tests/test_readme_keys.py +tests/test_self_name_exit_status.py +tests/test_projects.py +tests/test_projects_context.py +tests/test_projects_identity.py +tests/test_projects_manifest.py +tests/test_projects_one_entry.py tests/test_provider_title_push.py +tests/test_pty_child_exit.py tests/test_public_export.py tests/test_public_scan.py tests/test_reaper_autoclose.py +tests/test_recolor_reaches_the_window.py +tests/test_recovery_one_list.py tests/test_recovery_pending.py +tests/test_repair_dismissal.py +tests/test_state_io.py +tests/test_subagent_sweep.py +tests/test_tab_title_ownership.py +tests/test_tab_ifs_rule.py +tests/test_worker_model.py +tests/test_worktree_isolation.py tests/test_release.py tests/test_release_artifact.py +tests/test_release_gc.py +tests/test_restore_identity.py +tests/test_sandbox_guard.py +tests/test_sandbox_sweep.py tests/test_session_colors.py +tests/test_snapshot_contract.py +tests/test_state_column.py +tests/test_testing_gates.py +tests/test_voice_labels.py tests/test_watchdog.py tools/build-public-tree tools/build-release-artifact tools/check-doc-links +tools/check-embedded-scripts +tools/install-matrix +tools/install-matrix.d/** +tools/operator-drill tools/public-scan +tools/public-scan-history-baseline +tools/readme_art_lib.py +tools/readme_capture.py +tools/readme_keys.py +tools/render-readme-figures +tools/render-readme-picker +tools/render-readme-social +tools/title-ownership-drill +tests/tui_support.py +tests/test_tui_actions.py +tests/test_tui_acceptance.py +tests/test_tui_attention.py +tests/test_tui_frame.py +tests/test_tui_rows.py +tests/test_tui_smoke.py +tests/test_tui_state.py +tests/test_change_model.py +tests/test_claude_socket.py +tests/test_closed_sessions.py +tests/test_codex_lifecycle_relaunch.py +tests/test_color_authority.py +tests/test_name_authority.py +tests/test_projects_startup_wiring.py +tests/test_session_origin.py +tests/test_transcript_reach.py +tests/test_transcript_text.py diff --git a/shpool-patch/0001-heartbeat-ack-timeout-is-not-fatal.patch b/shpool-patch/0001-heartbeat-ack-timeout-is-not-fatal.patch index 957fd57..3369c1c 100644 --- a/shpool-patch/0001-heartbeat-ack-timeout-is-not-fatal.patch +++ b/shpool-patch/0001-heartbeat-ack-timeout-is-not-fatal.patch @@ -1,24 +1,305 @@ +# Upstream project: shpool -- https://github.com/shell-pool/shpool +# Upstream license: Apache License, Version 2.0 +# (Copyright 2023 Google LLC; see ../LICENSES/Apache-2.0.txt) +# Base revision: v0.11.0 == fe2d11595ff255810523b0868159dec051e303f1 +# +# This file is a patch against Apache-2.0 licensed shpool source. The modified +# source stays subject to the Apache License, Version 2.0 and to upstream's +# own notices. Session Kit does not vendor, redistribute, or replace the +# shpool source tree or any shpool binary; only this diff is distributed. +# Apply with: git apply <this file> (see README.md in this directory) +# +# Change: libshpool/src/daemon/server.rs, libshpool/src/daemon/shell.rs and +# shpool/tests/regression.rs. Gives the heartbeat ack channel one buffered +# slot instead of a rendezvous and tags each heartbeat with a request id, so +# an ack abandoned by a timed-out heartbeat is discarded on the next pass +# rather than wedging the shell->client thread. The shell->client thread +# deposits its ack without blocking and the heartbeat thread drains stale +# acks before each request, so neither side can ever park on the other. +# Adds a regression test. +# +diff --git a/libshpool/src/daemon/server.rs b/libshpool/src/daemon/server.rs +index 7e59d9a..8bf55d7 100644 +--- a/libshpool/src/daemon/server.rs ++++ b/libshpool/src/daemon/server.rs +@@ -1102,13 +1102,18 @@ impl Server { + let (tty_size_change_ack_tx, tty_size_change_ack_rx) = crossbeam_channel::bounded(0); + + let (heartbeat_tx, heartbeat_rx) = crossbeam_channel::bounded(0); +- let (heartbeat_ack_tx, heartbeat_ack_rx) = crossbeam_channel::bounded(0); ++ // One slot rather than a rendezvous, so the shell->client thread can ++ // always deposit an ack and get back to its select loop. If the ++ // heartbeat thread gave up waiting, the ack it abandoned sits here and ++ // is discarded by request id on the next pass. ++ let (heartbeat_ack_tx, heartbeat_ack_rx) = crossbeam_channel::bounded(1); + + // We make this buffered to avoid blocking during a broadcast. There is + // no ack chan so we can afford to buffer a bit. + let (maybe_switch_tx, maybe_switch_rx) = crossbeam_channel::bounded(10); + + let shell_to_client_ctl = Arc::new(Mutex::new(shell::ShellToClientCtl { ++ next_heartbeat_id: std::sync::atomic::AtomicU64::new(0), + client_connection: client_connection_tx, + client_connection_ack: client_connection_ack_rx, + tty_size_change: tty_size_change_tx, diff --git a/libshpool/src/daemon/shell.rs b/libshpool/src/daemon/shell.rs -index cc3ab28..af76798 100644 +index cc3ab28..6984893 100644 --- a/libshpool/src/daemon/shell.rs +++ b/libshpool/src/daemon/shell.rs -@@ -962,7 +962,18 @@ impl SessionInner { +@@ -210,10 +210,12 @@ pub struct ShellToClientArgs { + pub client_connection_ack: crossbeam_channel::Sender<ClientConnectionStatus>, + pub tty_size_change: crossbeam_channel::Receiver<TtySize>, + pub tty_size_change_ack: crossbeam_channel::Sender<()>, +- pub heartbeat: crossbeam_channel::Receiver<()>, ++ // Carries the request id that the ack must echo back. ++ pub heartbeat: crossbeam_channel::Receiver<u64>, + pub maybe_switch: crossbeam_channel::Receiver<MaybeSwitch>, +- // true if the client is still live, false if it has hung up on us +- pub heartbeat_ack: crossbeam_channel::Sender<bool>, ++ // The request id of the heartbeat this ack answers, and a flag that is ++ // true if the client is still live, false if it has hung up on us. ++ pub heartbeat_ack: crossbeam_channel::Sender<(u64, bool)>, + pub child_exit_notifier: Arc<ExitNotifier>, + } + +@@ -375,7 +377,7 @@ impl SessionInner { + } + } + } +- recv(args.heartbeat) -> _ => { ++ recv(args.heartbeat) -> request_id => { + let client_present = if let ClientConnectionMsg::New(conn) = &mut client_conn { + let chunk = Chunk { kind: ChunkKind::Heartbeat, buf: &[] }; + match chunk.write_to(&mut conn.sink).and_then(|_| conn.sink.flush()) { +@@ -396,8 +398,29 @@ impl SessionInner { + false + }; + +- args.heartbeat_ack.send(client_present) +- .context("sending heartbeat ack")?; ++ test_hooks::emit("daemon-wrote-heartbeat"); ++ ++ // Never block here. The one buffer slot can still hold ++ // an ack the heartbeat thread gave up waiting for, and ++ // the receiving half lives in the long-lived ctl struct, ++ // so it never disconnects and a blocking send would park ++ // this thread with nobody left to drain it -- the wedge ++ // this patch exists to prevent, through another door. ++ // Dropping an ack is safe because requests carry ids: ++ // the heartbeat thread times out and asks again under a ++ // fresh id. ++ match args ++ .heartbeat_ack ++ .try_send((request_id.unwrap_or(0), client_present)) ++ { ++ Ok(()) => {} ++ Err(crossbeam_channel::TrySendError::Full(_)) => { ++ trace!("heartbeat ack slot still full, dropping ack"); ++ } ++ Err(crossbeam_channel::TrySendError::Disconnected(_)) => { ++ return Err(anyhow!("sending heartbeat ack: disconnected")); ++ } ++ } + } + recv(args.maybe_switch) -> maybe_switch => { + let maybe_switch = match maybe_switch { +@@ -921,7 +944,7 @@ impl SessionInner { + .spawn_scoped(scope, move || -> anyhow::Result<()> { + let _s1 = span!(Level::INFO, "heartbeat", s = self.name, cid = conn_id).entered(); + +- loop { ++ 'heartbeat: loop { + trace!("checking stop_rx"); + let stop_early = common::sleep_unless( + consts::HEARTBEAT_DURATION, +@@ -934,9 +957,22 @@ impl SessionInner { + } + { + let shell_to_client_ctl = self.shell_to_client_ctl.lock(); ++ ++ // Discard any ack left over from a request we already ++ // gave up on, so the shell->client thread always finds ++ // the slot free. The id check below stays as the second ++ // line of defence for an ack that lands after this. ++ while shell_to_client_ctl.heartbeat_ack.try_recv().is_ok() { ++ trace!("discarded a stale heartbeat ack"); ++ } ++ ++ let request_id = shell_to_client_ctl ++ .next_heartbeat_id ++ .fetch_add(1, Ordering::Relaxed); ++ + match shell_to_client_ctl + .heartbeat +- .send_timeout((), SHELL_TO_CLIENT_CTL_TIMEOUT) ++ .send_timeout(request_id, SHELL_TO_CLIENT_CTL_TIMEOUT) + { + // If the channel is disconnected, it means that the shell exited and // the shell->client process exited cleanly. We should not raise a - // ruckus. - Err(crossbeam_channel::RecvTimeoutError::Disconnected) => return Ok(()), +@@ -954,16 +990,28 @@ impl SessionInner { + } + _ => {} + } +- let client_present = match shell_to_client_ctl +- .heartbeat_ack +- .recv_timeout(SHELL_TO_CLIENT_CTL_TIMEOUT) +- { +- // If the channel is disconnected, it means that the shell exited and +- // the shell->client process exited cleanly. We should not raise a +- // ruckus. +- Err(crossbeam_channel::RecvTimeoutError::Disconnected) => return Ok(()), - Err(e) => return Err(e).context("waiting for heartbeat ack"), -+ // A timeout here means the same thing the send -+ // timeout above means, and is handled the same way: -+ // the shell->client thread may simply be busy. It -+ // is NOT evidence that the client is gone. Treating -+ // it as fatal unwinds the whole thread scope and -+ // destroys this session's shell->client thread, so -+ // the session can never be attached again and -+ // whatever runs inside it blocks forever on its -+ // next write. A genuinely dead client is still -+ // detected: the write to client_stream fails and -+ // takes the "assuming hangup" path. -+ Err(crossbeam_channel::RecvTimeoutError::Timeout) => continue, - Ok(client_present) => client_present, +- Ok(client_present) => client_present, ++ let client_present = loop { ++ match shell_to_client_ctl ++ .heartbeat_ack ++ .recv_timeout(SHELL_TO_CLIENT_CTL_TIMEOUT) ++ { ++ // If the channel is disconnected, it means that the shell exited ++ // and the shell->client process exited cleanly. We should not ++ // raise a ruckus. ++ Err(crossbeam_channel::RecvTimeoutError::Disconnected) => { ++ return Ok(()) ++ } ++ // Like the send timeout above, a slow ack just means ++ // the shell->client thread is busy, not that the ++ // client is gone. A dead client still gets noticed ++ // when the write to it fails. ++ Err(crossbeam_channel::RecvTimeoutError::Timeout) => { ++ continue 'heartbeat ++ } ++ // An ack for a request we already gave up on. ++ Ok((ack_id, _)) if ack_id != request_id => continue, ++ Ok((_, client_present)) => break client_present, ++ } }; if !client_present { + // Bail from the thread to get the rest of the +@@ -1065,11 +1113,19 @@ pub struct ShellToClientCtl { + pub tty_size_change_ack: crossbeam_channel::Receiver<()>, + + // A control channel telling the shell->client thread to issue +- // a heartbeat to check if the client is still listening. +- pub heartbeat: crossbeam_channel::Sender<()>, +- // True if the client is still listening, false if it has hung up +- // on us. +- pub heartbeat_ack: crossbeam_channel::Receiver<bool>, ++ // a heartbeat to check if the client is still listening. The payload ++ // is a request id, which the ack echoes back. ++ pub heartbeat: crossbeam_channel::Sender<u64>, ++ // The request id of the heartbeat this ack answers, and a flag that is ++ // true if the client is still listening, false if it has hung up on us. ++ // ++ // The heartbeat thread gives up waiting for an ack that takes too long, ++ // so a late ack can still turn up afterwards. The id is what lets the ++ // next read tell that ack apart from its own and discard it. ++ pub heartbeat_ack: crossbeam_channel::Receiver<(u64, bool)>, ++ ++ /// The id to give the next heartbeat request. ++ pub next_heartbeat_id: std::sync::atomic::AtomicU64, + + /// A control channel telling the shell->client thread to + /// broadcast the given MaybeSwitch. There is no ack channel +diff --git a/shpool/tests/regression.rs b/shpool/tests/regression.rs +index e8a8e14..770c1dc 100644 +--- a/shpool/tests/regression.rs ++++ b/shpool/tests/regression.rs +@@ -301,6 +301,98 @@ fn concurrent_attach_to_existing_session_race() -> anyhow::Result<()> { + Ok(()) + } + ++/// Regression test for a slow heartbeat ack killing the session. The ++/// shell->client thread can be busy when the heartbeat thread asks it for an ++/// ack (generating a large session restore buffer is the usual cause). Treating ++/// that as fatal tore down the thread scope along with the session's ++/// shell->client thread, leaving a session that could never be attached again. ++/// ++/// We stall the shell->client thread right after it writes a heartbeat but ++/// before it acks, hold it past the ack timeout, then check the session still ++/// works. ++#[test] ++#[timeout(30000)] ++fn slow_heartbeat_ack_does_not_wedge_session() -> anyhow::Result<()> { ++ let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) ++ .context("starting daemon proc")?; ++ ++ let mut attach_proc = ++ daemon_proc.attach("sh1", Default::default()).context("starting attach proc")?; ++ daemon_proc.await_event("daemon-bidi-stream-enter")?; ++ ++ daemon_proc.send_event_command("pause-at daemon-wrote-heartbeat")?; ++ daemon_proc.await_event("paused-at daemon-wrote-heartbeat")?; ++ ++ // SHELL_TO_CLIENT_CTL_TIMEOUT is 300ms, so this guarantees the ack recv ++ // times out at least once. ++ std::thread::sleep(Duration::from_millis(600)); ++ ++ daemon_proc.send_event_command("release daemon-wrote-heartbeat")?; ++ ++ // On buggy code the heartbeat thread has already returned an error, the ++ // scope has unwound, and this command never produces output. ++ let mut line_matcher = attach_proc.line_matcher()?; ++ attach_proc.run_cmd("echo still-alive")?; ++ line_matcher.scan_until_re("still-alive$")?; ++ ++ // Reproduce the user-visible path from the production incident: lose the ++ // original client, then open the listed session again. The old code left ++ // the session listed but its shell->client thread gone, so this reattach ++ // timed out. ++ attach_proc.proc.kill()?; ++ daemon_proc.wait_until_list_matches(|out| out.contains("disconnected"))?; ++ let mut reattached = ++ daemon_proc.attach("sh1", Default::default()).context("reattaching session")?; ++ let mut reattached_matcher = reattached.line_matcher()?; ++ reattached.run_cmd("echo reattached-after-slow-ack")?; ++ reattached_matcher.scan_until_re("reattached-after-slow-ack$")?; ++ ++ Ok(()) ++} ++ ++/// Regression for two follow-on hazards: a late ack from one request must not ++/// satisfy the next request, and losing the client while an ack is delayed ++/// must leave the listed session reattachable. ++#[test] ++#[timeout(30000)] ++fn repeated_slow_acks_and_client_loss_remain_reattachable() -> anyhow::Result<()> { ++ let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) ++ .context("starting daemon proc")?; ++ ++ let mut attach_proc = ++ daemon_proc.attach("sh1", Default::default()).context("starting attach proc")?; ++ daemon_proc.await_event("daemon-bidi-stream-enter")?; ++ let mut matcher = attach_proc.line_matcher()?; ++ ++ // Delay two consecutive acks. The second pass must discard the first ++ // request's late, buffered ack by ID and wait for its own. ++ for _ in 0..2 { ++ daemon_proc.send_event_command("pause-at daemon-wrote-heartbeat")?; ++ daemon_proc.await_event("paused-at daemon-wrote-heartbeat")?; ++ std::thread::sleep(Duration::from_millis(600)); ++ daemon_proc.send_event_command("release daemon-wrote-heartbeat")?; ++ attach_proc.run_cmd("echo survived-repeated-delay")?; ++ matcher.scan_until_re("survived-repeated-delay$")?; ++ } ++ ++ // Now lose the client while the ack is delayed, matching an SSH window ++ // disappearing mid-handshake. Releasing the ack must let cleanup finish. ++ daemon_proc.send_event_command("pause-at daemon-wrote-heartbeat")?; ++ daemon_proc.await_event("paused-at daemon-wrote-heartbeat")?; ++ std::thread::sleep(Duration::from_millis(600)); ++ attach_proc.proc.kill()?; ++ daemon_proc.send_event_command("release daemon-wrote-heartbeat")?; ++ daemon_proc.wait_until_list_matches(|out| out.contains("disconnected"))?; ++ ++ let mut reopened = ++ daemon_proc.attach("sh1", Default::default()).context("reopening after client loss")?; ++ let mut matcher = reopened.line_matcher()?; ++ reopened.run_cmd("echo reopened-after-client-loss")?; ++ matcher.scan_until_re("reopened-after-client-loss$")?; ++ ++ Ok(()) ++} ++ + /// Regression test for a bug where shpool would abort the attach process + /// if the MOTD pager exited normally (e.g. less EOF). It should transition + /// to the shell instead. diff --git a/shpool-patch/0002-restore-input-modes-on-reattach.patch b/shpool-patch/0002-restore-input-modes-on-reattach.patch new file mode 100644 index 0000000..71b41f6 --- /dev/null +++ b/shpool-patch/0002-restore-input-modes-on-reattach.patch @@ -0,0 +1,51 @@ +# Upstream project: shpool -- https://github.com/shell-pool/shpool +# Upstream license: Apache License, Version 2.0 +# (Copyright 2023 Google LLC; see ../LICENSES/Apache-2.0.txt) +# Base revision: v0.11.0 == fe2d11595ff255810523b0868159dec051e303f1 +# +# This file is a patch against Apache-2.0 licensed shpool source. The modified +# source stays subject to the Apache License, Version 2.0 and to upstream's +# own notices. Session Kit does not vendor, redistribute, or replace the +# shpool source tree or any shpool binary; only this diff is distributed. +# Apply with: git apply <this file> (see README.md in this directory) +# +# Change: libshpool/src/session_restore.rs. Replays the terminal input modes +# (bracketed paste, application cursor/keypad, mouse protocol) alongside the +# restored screen contents, so a paste into a reattached session is still +# bracketed instead of submitting on every newline. +# +diff --git a/libshpool/src/session_restore.rs b/libshpool/src/session_restore.rs +index 0c8f3ea..01a3c9d 100644 +--- a/libshpool/src/session_restore.rs ++++ b/libshpool/src/session_restore.rs +@@ -86,7 +86,15 @@ impl SessionSpool for Vt100Screen { + fn restore_buffer(&self) -> Vec<u8> { + let (rows, cols) = self.parser.screen().size(); + info!("computing screen restore buf with (rows={}, cols={})", rows, cols); +- self.parser.screen().contents_formatted() ++ // The screen contents alone do not tell a freshly attached terminal ++ // which input modes the application enabled (bracketed paste, ++ // application cursor/keypad, mouse protocol). Without them a paste ++ // into a reattached session arrives unbracketed: terminals flag it ++ // as unsafe and every newline submits early. Replay the tracked ++ // input modes after the contents. ++ let mut buf = self.parser.screen().contents_formatted(); ++ buf.extend(self.parser.screen().input_mode_formatted()); ++ buf + } + + fn process(&mut self, bytes: &[u8]) { +@@ -111,7 +119,12 @@ impl SessionSpool for Vt100Lines { + fn restore_buffer(&self) -> Vec<u8> { + let (rows, cols) = self.parser.screen().size(); + info!("computing lines({}) restore buf with (rows={}, cols={})", self.nlines, rows, cols); +- self.parser.screen().last_n_rows_contents_formatted(self.nlines) ++ // See Vt100Screen::restore_buffer: input modes must ride along with ++ // the restored contents or the attached terminal loses bracketed ++ // paste (and friends) across a reattach. ++ let mut buf = self.parser.screen().last_n_rows_contents_formatted(self.nlines); ++ buf.extend(self.parser.screen().input_mode_formatted()); ++ buf + } + + fn process(&mut self, bytes: &[u8]) { diff --git a/shpool-patch/0003-kill-tolerates-an-already-dead-shell.patch b/shpool-patch/0003-kill-tolerates-an-already-dead-shell.patch new file mode 100644 index 0000000..a24d14a --- /dev/null +++ b/shpool-patch/0003-kill-tolerates-an-already-dead-shell.patch @@ -0,0 +1,47 @@ +# Upstream project: shpool -- https://github.com/shell-pool/shpool +# Upstream license: Apache License, Version 2.0 +# (Copyright 2023 Google LLC; see ../LICENSES/Apache-2.0.txt) +# Base revision: v0.11.0 == fe2d11595ff255810523b0868159dec051e303f1 +# +# This file is a patch against Apache-2.0 licensed shpool source. The modified +# source stays subject to the Apache License, Version 2.0 and to upstream's +# own notices. Session Kit does not vendor, redistribute, or replace the +# shpool source tree or any shpool binary; only this diff is distributed. +# Apply with: git apply <this file> (see README.md in this directory) +# +# Change: libshpool/src/daemon/shell.rs. Treats ESRCH from the SIGHUP kill as +# success, because a child that is already gone is the state kill exists to +# reach. Without it a session whose shell exited on its own can never be +# removed from the daemon table and haunts the list until a daemon restart. +# +diff --git a/libshpool/src/daemon/shell.rs b/libshpool/src/daemon/shell.rs +index af76798..4e85e6a 100644 +--- a/libshpool/src/daemon/shell.rs ++++ b/libshpool/src/daemon/shell.rs +@@ -95,13 +95,22 @@ impl Session { + // from a process. We can't use the normal SIGTERM graceful-shutdown + // signal since shells just forward those to their child process, + // but for shells SIGHUP serves as the graceful shutdown signal. +- signal::kill(Pid::from_raw(self.child_pid), Some(signal::Signal::SIGHUP)) +- .context("sending SIGHUP to child proc")?; ++ // A child that is already gone is exactly the state kill exists to ++ // reach. ESRCH must not fail the kill: the caller removes the session ++ // from the daemon's table only when this returns Ok, so treating a ++ // long-dead child as an error leaves an unkillable phantom session ++ // that haunts the list until a daemon restart. ++ match signal::kill(Pid::from_raw(self.child_pid), Some(signal::Signal::SIGHUP)) { ++ Err(nix::errno::Errno::ESRCH) => return Ok(()), ++ res => res.context("sending SIGHUP to child proc")?, ++ } + + if self.child_exit_notifier.wait(Some(SHELL_KILL_TIMEOUT)).is_none() { + info!("child failed to exit within kill timeout, no longer being polite"); +- signal::kill(Pid::from_raw(self.child_pid), Some(signal::Signal::SIGKILL)) +- .context("sending SIGKILL to child proc")?; ++ match signal::kill(Pid::from_raw(self.child_pid), Some(signal::Signal::SIGKILL)) { ++ Err(nix::errno::Errno::ESRCH) => return Ok(()), ++ res => res.context("sending SIGKILL to child proc")?, ++ } + } + + Ok(()) diff --git a/shpool-patch/0004-detach-must-not-hold-the-shells-lock.patch b/shpool-patch/0004-detach-must-not-hold-the-shells-lock.patch new file mode 100644 index 0000000..698714b --- /dev/null +++ b/shpool-patch/0004-detach-must-not-hold-the-shells-lock.patch @@ -0,0 +1,119 @@ +# Upstream project: shpool -- https://github.com/shell-pool/shpool +# Upstream license: Apache License, Version 2.0 +# (Copyright 2023 Google LLC; see ../LICENSES/Apache-2.0.txt) +# Base revision: v0.11.0 == fe2d11595ff255810523b0868159dec051e303f1 +# +# This file is a patch against Apache-2.0 licensed shpool source. The modified +# source stays subject to the Apache License, Version 2.0 and to upstream's +# own notices. Session Kit does not vendor, redistribute, or replace the +# shpool source tree or any shpool binary; only this diff is distributed. +# Apply with: git apply <this file> (see README.md in this directory) +# +# Change: libshpool/src/daemon/server.rs. Restructures handle_detach so the +# control handshake runs after the global shells lock is dropped. Holding +# that lock across a rendezvous channel exchange lets one unresponsive +# client deadlock every list, attach, detach and kill in the daemon. +# +diff --git a/libshpool/src/daemon/server.rs b/libshpool/src/daemon/server.rs +index 7e59d9a..91f0bc5 100644 +--- a/libshpool/src/daemon/server.rs ++++ b/libshpool/src/daemon/server.rs +@@ -615,38 +615,77 @@ impl Server { + fn handle_detach(&self, mut stream: UnixStream, request: DetachRequest) -> anyhow::Result<()> { + let mut not_found_sessions = vec![]; + let mut not_attached_sessions = vec![]; ++ ++ // Resolve the requested names to control handles while the shells lock ++ // is held, then drop it. The ctl handshake below MUST NOT run under ++ // that lock: client_connection and client_connection_ack are both ++ // rendezvous channels (bounded(0)), so each half only completes when ++ // the shell->client thread is sitting in its select loop. A client ++ // whose socket has stopped draining (a stalled ssh window, a suspended ++ // laptop) leaves that thread blocked in write() instead, and an ++ // unbounded exchange here then parks the global shells lock forever -- ++ // every list, attach, detach and kill in the daemon wedges behind a ++ // single unresponsive session. Holding only an Arc keeps the ctl alive ++ // if the session is removed while we talk to it. ++ let mut targets = Vec::with_capacity(request.sessions.len()); + { + let _s = span!(Level::INFO, "lock(shells)").entered(); + let shells = self.shells.lock(); + for session in request.sessions.into_iter() { + if let Some(s) = shells.get(&session) { +- let _s = span!(Level::INFO, "lock(shell_to_client_ctl)", s = session).entered(); +- let shell_to_client_ctl = s.shell_to_client_ctl.lock(); +- shell_to_client_ctl +- .client_connection +- .send(shell::ClientConnectionMsg::Disconnect) +- .context("sending client detach to shell->client")?; +- let status = shell_to_client_ctl +- .client_connection_ack +- .recv() +- .context("getting client conn ack")?; +- info!("detached session({}), status = {:?}", session, status); +- if let shell::ClientConnectionStatus::DetachNone = status { +- not_attached_sessions.push(session); +- } else { +- // The bidi-loop unwind in handle_attach owns the +- // SessionDetached publish; we just update +- // last_disconnected_at eagerly so a concurrent list() +- // reflects the detach immediately. +- s.lifecycle_timestamps.lock().last_disconnected_at = +- Some(time::SystemTime::now()); +- } ++ targets.push((session, Arc::clone(&s.shell_to_client_ctl))); + } else { + not_found_sessions.push(session); + } + } + } + ++ // Both halves are bounded, matching the session-message detach path. ++ // A session that cannot complete the handshake in time is reported as ++ // not attached rather than being allowed to stall the daemon. ++ let mut detached_sessions = vec![]; ++ for (session, shell_to_client_ctl) in targets.into_iter() { ++ let _s = span!(Level::INFO, "lock(shell_to_client_ctl)", s = session).entered(); ++ let shell_to_client_ctl = shell_to_client_ctl.lock(); ++ if let Err(err) = shell_to_client_ctl ++ .client_connection ++ .send_timeout(shell::ClientConnectionMsg::Disconnect, SESSION_MSG_TIMEOUT) ++ { ++ error!("sending client detach to shell->client for {}: {:?}", session, err); ++ not_attached_sessions.push(session); ++ continue; ++ } ++ let status = match shell_to_client_ctl.client_connection_ack.recv_timeout(SESSION_MSG_TIMEOUT) ++ { ++ Ok(status) => status, ++ Err(err) => { ++ error!("getting client conn ack for {}: {:?}", session, err); ++ not_attached_sessions.push(session); ++ continue; ++ } ++ }; ++ info!("detached session({}), status = {:?}", session, status); ++ if let shell::ClientConnectionStatus::DetachNone = status { ++ not_attached_sessions.push(session); ++ } else { ++ detached_sessions.push(session); ++ } ++ } ++ ++ // The bidi-loop unwind in handle_attach owns the SessionDetached ++ // publish; we just update last_disconnected_at eagerly so a concurrent ++ // list() reflects the detach immediately. ++ if !detached_sessions.is_empty() { ++ let _s = span!(Level::INFO, "timestamp_lock(shells)").entered(); ++ let shells = self.shells.lock(); ++ for session in detached_sessions.iter() { ++ if let Some(s) = shells.get(session) { ++ s.lifecycle_timestamps.lock().last_disconnected_at = ++ Some(time::SystemTime::now()); ++ } ++ } ++ } ++ + write_reply(&mut stream, DetachReply { not_found_sessions, not_attached_sessions }) + .context("writing detach reply")?; + diff --git a/shpool-patch/0005-attach-must-not-discard-the-shells-exit-status.patch b/shpool-patch/0005-attach-must-not-discard-the-shells-exit-status.patch new file mode 100644 index 0000000..d035d63 --- /dev/null +++ b/shpool-patch/0005-attach-must-not-discard-the-shells-exit-status.patch @@ -0,0 +1,58 @@ +# Upstream project: shpool -- https://github.com/shell-pool/shpool +# Upstream license: Apache License, Version 2.0 +# (Copyright 2023 Google LLC; see ../LICENSES/Apache-2.0.txt) +# Base revision: v0.11.0 == fe2d11595ff255810523b0868159dec051e303f1 +# +# This file is a patch against Apache-2.0 licensed shpool source. The modified +# source stays subject to the Apache License, Version 2.0 and to upstream's +# own notices. Session Kit does not vendor, redistribute, or replace the +# shpool source tree or any shpool binary; only this diff is distributed. +# Apply with: git apply <this file> (see README.md in this directory) +# +# Change: libshpool/src/protocol.rs. The attach client stamps its fallback +# exit status of 1 into the result slot as soon as EITHER pipe thread +# finishes. When the terminal closes the client's stdin first -- which the +# upstream test suite does on every exit-status assertion, and which a real +# terminal does whenever it hangs up before the shell's status arrives -- +# that discards the ExitStatus frame still in flight and reports 1 instead +# of the shell's real status. Waits the existing bounded detach window for +# the socket side before defaulting. +# +diff --git a/libshpool/src/protocol.rs b/libshpool/src/protocol.rs +index 109a2e1..9bc0370 100644 +--- a/libshpool/src/protocol.rs ++++ b/libshpool/src/protocol.rs +@@ -395,6 +395,33 @@ impl Client { + + (sock_to_stdout_h.is_finished() as usize); + + if nfinished_threads > 0 { ++ // A finished stdin->sock thread only means our local input ++ // side is done: the terminal closed our stdin. The session ++ // may still be about to report the shell's exit status, and ++ // stamping the fallback into the result slot right now makes ++ // sock->stdout bail on its next wakeup without ever reading ++ // the ExitStatus frame, so the attach process reports 1 ++ // instead of the shell's real status. Wait a bounded window ++ // for the socket side first. It returns as soon as the frame ++ // lands or the daemon closes the connection, so the ordinary ++ // case costs a poll, not the whole window. ++ if stdin_to_sock_h.is_finished() && !sock_to_stdout_h.is_finished() { ++ common::sleep_unless( ++ MAX_DETACH_WAIT_DUR, ++ || { ++ sock_to_stdout_h.is_finished() ++ || result_slot.lock().unwrap().is_some() ++ }, ++ common::PollStrategy::Backoff { ++ initial_interval: DETACH_BACKOFF_INITIAL_DUR, ++ factor: 2.0, ++ max_interval: DETACH_BACKOFF_MAX_STEP_DUR, ++ }, ++ ); ++ nfinished_threads = (stdin_to_sock_h.is_finished() as usize) ++ + (sock_to_stdout_h.is_finished() as usize); ++ } ++ + // If one of the threads has exited, but not the other + // make sure that the exit result slot has some contents + // so the other thread will exit the next time it wakes diff --git a/shpool-patch/0006-coalesce-client-resize-bursts.patch b/shpool-patch/0006-coalesce-client-resize-bursts.patch new file mode 100644 index 0000000..585a5fd --- /dev/null +++ b/shpool-patch/0006-coalesce-client-resize-bursts.patch @@ -0,0 +1,92 @@ +diff --git a/libshpool/src/attach.rs b/libshpool/src/attach.rs +index 6693e6a..38fbd3b 100644 +--- a/libshpool/src/attach.rs ++++ b/libshpool/src/attach.rs +@@ -421,11 +421,16 @@ enum AttachResult { + struct SignalHandler { + session_name: Arc<Mutex<String>>, + socket: PathBuf, ++ last_sent_size: Mutex<Option<(u16, u16)>>, + } + + impl SignalHandler { + fn new(session_name: String, socket: PathBuf) -> Self { +- SignalHandler { session_name: Arc::new(Mutex::new(session_name)), socket } ++ SignalHandler { ++ session_name: Arc::new(Mutex::new(session_name)), ++ socket, ++ last_sent_size: Mutex::new(None), ++ } + } + + fn spawn(self) -> anyhow::Result<Arc<Mutex<String>>> { +@@ -436,19 +441,44 @@ impl SignalHandler { + let sigs = vec![SIGWINCH]; + let mut signals = Signals::new(sigs).context("creating signal iterator")?; + +- thread::spawn(move || { +- for signal in &mut signals { +- let res = match signal { +- SIGWINCH => self.handle_sigwinch(), ++ thread::spawn(move || loop { ++ let mut got_winch = false; ++ for signal in signals.wait() { ++ match signal { ++ SIGWINCH => got_winch = true, + sig => { + error!("unknown signal: {}", sig); + panic!("unknown signal: {sig}"); + } +- }; +- if let Err(e) = res { +- error!("signal handler error: {:?}", e); + } + } ++ if !got_winch { ++ continue; ++ } ++ // Coalesce resize bursts. A fullscreen animation delivers a ++ // stream of SIGWINCHes; forwarding each one makes the attached ++ // application repaint per event, shoving one stale frame into ++ // the client's scrollback every time (operator finding X19, ++ // 2026-08-14). Wait for the reported size to stop moving and ++ // for the queue to run dry, then forward one resize carrying ++ // the final size. ++ loop { ++ let before = TtySize::from_fd(0).ok().map(|s| (s.rows, s.cols)); ++ thread::sleep(time::Duration::from_millis(120)); ++ let mut more = false; ++ for signal in signals.pending() { ++ if signal == SIGWINCH { ++ more = true; ++ } ++ } ++ let after = TtySize::from_fd(0).ok().map(|s| (s.rows, s.cols)); ++ if !more && before == after { ++ break; ++ } ++ } ++ if let Err(e) = self.handle_sigwinch() { ++ error!("signal handler error: {:?}", e); ++ } + }); + + Ok(session_name_slot) +@@ -467,6 +497,17 @@ impl SignalHandler { + let tty_size = TtySize::from_fd(0).context("getting tty size")?; + info!("handle_sigwinch: tty_size={:?}", tty_size); + ++ // A size the daemon already holds triggers a whole-frame repaint ++ // for nothing; skip the duplicate instead of forwarding it. ++ { ++ let mut last = self.last_sent_size.lock().unwrap(); ++ if *last == Some((tty_size.rows, tty_size.cols)) { ++ info!("handle_sigwinch: size unchanged, not forwarding"); ++ return Ok(()); ++ } ++ *last = Some((tty_size.rows, tty_size.cols)); ++ } ++ + // write the request on a new, seperate connection + client + .write_connect_header(ConnectHeader::SessionMessage(SessionMessageRequest { diff --git a/shpool-patch/README.md b/shpool-patch/README.md index 97d0c79..2177c80 100644 --- a/shpool-patch/README.md +++ b/shpool-patch/README.md @@ -1,18 +1,38 @@ -# Optional shpool 0.11.0 heartbeat patch +# Optional shpool 0.11.0 patches -Session Kit uses official shpool 0.11.0 by default. This directory contains an -optional one-file source patch for users who have confirmed the matching -heartbeat failure in their own daemon logs. +Session Kit uses official shpool 0.11.0 by default. This directory contains +optional source patches: `0001` for a heartbeat failure, `0002` for +input-mode loss on reattach, `0003` for sessions that become unkillable once +their shell dies on its own, `0004` for a daemon-wide deadlock in the detach +handler, `0005` for an attach client that reports the wrong exit status, and +`0006` for resize bursts that cause repeated stale-frame repaints. Apply any +set you choose in numeric order; `0002` through `0006` touch independent +regions and apply cleanly to pristine `v0.11.0` on their own. -The patch is not installed, built, selected, or activated by Session Kit. +**Evaluate `0004` first.** It is the only patch here whose reproduced failure +mode is daemon-wide rather than confined to a single session. If you then carry +more than one patch, still apply the chosen set in numeric order. See +[Detach deadlock patch (0004)](#detach-deadlock-patch-0004). -## Problem +The patches are not installed, built, selected, or activated by Session Kit. + +## Heartbeat ack patch (0001) + +> **Evidence rule.** A generic attach failure does not establish a heartbeat +> fault. Require the matching acknowledgement wait and receive-timeout chain +> below, and rule out the bounded-detach failure addressed by `0004` first. +> Applying `0001` without that chain changes the wrong path. In shpool 0.11.0, the heartbeat sender treats a send timeout as a busy handler and continues. The acknowledgement receiver can treat the same timeout as fatal. That error can unwind the handler scope, leaving a listed session unable to accept another client. +The required chain is a daemon log containing `joining heartbeat_h`, `waiting +for heartbeat ack`, and the receive timeout, followed by attempts to open the +listed session timing out while attaching a new client stream. That sequence, +not a generic attach error by itself, is the evidence for `0001`. + Typical evidence is: ```text @@ -26,20 +46,36 @@ before sharing logs. ## Change -The patch changes only the acknowledgement timeout branch: +The patch makes a delayed acknowledgement recoverable without confusing it +with the next heartbeat: -```rust -Err(crossbeam_channel::RecvTimeoutError::Timeout) => continue, -``` +- heartbeat requests and acknowledgements carry matching request IDs; +- the acknowledgement channel has one buffer slot, so a late ack cannot block + the shell-to-client thread; +- a timed-out request is abandoned without ending the session scope; +- a late ack for an abandoned request is discarded by ID; +- the shell-to-client thread deposits its ack without blocking, and the + heartbeat thread drains stale acks before each request, so neither side can + park on the other. A disconnected channel still returns normally. A dead client is still detected by the existing client-stream write failure. +The last point is not decoration. The buffer slot introduced above can still +hold an ack the heartbeat thread gave up waiting for, and the receiving half +lives in the long-lived control struct, so it never disconnects. A blocking +send into a full slot would therefore park the shell-to-client thread with +nobody left to drain it, the same wedge this patch exists to prevent, reached +through a different door. Dropping an ack is safe precisely because requests +carry IDs: the heartbeat thread times out and asks again under a fresh one. + ## Limits -The repository does not claim a deterministic reproduction of the narrow race. -The patch removes one fatal timeout path based on the upstream control flow. It -does not prove that every unopenable session has this cause. +The patch includes deterministic regressions that pause the shell-to-client +thread after it writes a heartbeat, hold the ack past the 300ms timeout, and +verify continued output, detach-and-reattach, repeated late-ack ID handling, +and client loss during the delay. They do not prove that every unopenable +session has this cause. Do not apply it for quiet output, a provider pause, a normal provider exit, or a generic attach failure without the matching acknowledgement evidence. @@ -54,19 +90,39 @@ git clone --branch v0.11.0 --depth 1 \ https://github.com/shell-pool/shpool.git shpool-0.11.0 cd shpool-0.11.0 test "$(git rev-parse HEAD)" = fe2d11595ff255810523b0868159dec051e303f1 -git apply --check /path/to/session-kit/shpool-patch/0001-heartbeat-ack-timeout-is-not-fatal.patch -git apply /path/to/session-kit/shpool-patch/0001-heartbeat-ack-timeout-is-not-fatal.patch +git apply --check ~/.local/lib/session-kit/current/shpool-patch/0001-heartbeat-ack-timeout-is-not-fatal.patch +git apply ~/.local/lib/session-kit/current/shpool-patch/0001-heartbeat-ack-timeout-is-not-fatal.patch cargo build --locked --release --bin shpool sha256sum target/release/shpool ``` -`patched-binary.sha256` is a reference checksum from one reviewed build. A -different Rust toolchain, target, or build environment can produce different -bytes. Record the upstream tag, patch checksum, Rust version, target triple, -build command, and resulting binary checksum for your own artifact. +`patched-binary.sha256` is the reference checksum from the reviewed build with +all six patches applied in numeric order. A different Rust toolchain, target, +or build environment can produce different bytes, so the checksum is worthless +without the environment that produced it. The recorded value came from: + +| | | +|---|---| +| upstream tag | `v0.11.0` == `fe2d11595ff255810523b0868159dec051e303f1` | +| patches | `0001`–`0006`, applied in numeric order | +| rustc | 1.97.1 (8bab26f4f 2026-07-14) | +| cargo | 1.97.1 (c980f4866 2026-06-30) | +| target triple | `x86_64-unknown-linux-gnu` | +| build host | AlmaLinux 10.2, glibc 2.39, rustup `stable-x86_64-unknown-linux-gnu` | +| build command | `cargo build --locked --release --bin shpool` | +| sha256 | `937ed524412b2810c45661c266cfbb495aa534b0da399ef6509aa04914556ade` | + +The `0001`–`0005` checksum recorded before `0006` was +`ca8d28aa52da0b9ee45f7d7ef24aac41bccaa316a2a959ed6804db3b8a5b1e45`, built in +`docker.io/library/rust:bookworm` on the same toolchain versions. The two are +not comparable byte for byte: the patch set and the build host both differ. + +Record the same set for your own artifact. A checksum recorded without its +build environment cannot later be reproduced or refuted, which is the same as +not having recorded it. -The CI patch job checks that the patch applies to the pinned upstream tag and -that the patched source builds. +The CI patch job applies all six patches to the pinned upstream tag, runs the +workspace tests, and builds the release binary. ## Activation and rollback @@ -81,11 +137,233 @@ Before activation: 4. plan recovery for every active provider conversation; 5. choose a maintenance window. -After replacement, record the running binary checksum in the private Session -Kit state if you use binary-change monitoring. +### Record the new fingerprint, every time + +The watchdog compares the running daemon against a fingerprint recorded in +private Session Kit state. Replacing the binary without updating that value +leaves the check reporting a changed binary on every pass, for a change you made +on purpose. Treat this as part of the replacement, not as optional cleanup: + +```bash +install -m 600 /dev/null "${XDG_STATE_HOME:-$HOME/.local/state}/session-kit/shpool-binary.sha256" +sha256sum "$(command -v shpool)" | cut -d' ' -f1 \ + > "${XDG_STATE_HOME:-$HOME/.local/state}/session-kit/shpool-binary.sha256" +``` + +Use the path to the binary the daemon will actually execute, which is not +necessarily the one you just built. Confirm afterwards with `session-kit +doctor`, which reports a missing or no-longer-matching fingerprint. + +Skipping this is not a quiet failure with no consequence. It is a check that +complains constantly and therefore stops being read, so the genuine silent +replacement it exists to catch arrives to an audience that has learned to ignore +it. + +Rollback restores the prior binary, requires the same planned daemon restart, +and needs the fingerprint recorded again for the restored binary. Session Kit +never performs either restart. + +## Input-mode restore patch (0002) + +On attach, shpool replays restorable screen contents but not the input modes +the application enabled: bracketed paste, application cursor and keypad keys, +and the mouse protocol. A freshly connected terminal, a new window +reattaching to a long-lived session, starts with those modes off while the +application inside still believes they are on. + +The visible failure is pasting. Terminals with paste protection prompt before +pasting multi-line text into a session that appears to lack bracketed paste, +and the paste then arrives unbracketed, so embedded newlines submit or execute +immediately instead of arriving as one block. Arrow-key and mouse handling can +be similarly skewed until the application re-asserts its modes. + +`0002-restore-input-modes-on-reattach.patch` appends the engine's tracked +input-mode state (`input_mode_formatted()`, already maintained by the +`shpool_vt100` engine) to the restore buffer in both vt100 restore paths +(`screen` and `lines` modes). + +Limits: modes are replayed only when a restore buffer is emitted at all, so +the `simple` restore mode and the vterm engine are unchanged. The build, +activation, and rollback procedure is the same as for 0001, including the +daemon-restart requirement. + +## Dead-shell kill patch (0003) + +`shpool kill` sends SIGHUP to the session's child and treats any signal +error as fatal. If the child already exited on its own, a crash, a clean +exit the daemon's bookkeeping missed, an external kill, the signal returns +ESRCH, the kill handler aborts, and the session is never removed from the +daemon's table. The result is a phantom session that `shpool list` keeps +advertising, that cannot be attached, and that no kill can remove until the +daemon itself restarts. Log signature: + +```text +ERROR ... handling new connection: killing shell proc +Caused by: + 0: sending SIGHUP to child proc + 1: ESRCH: No such process +``` + +`0003-kill-tolerates-an-already-dead-shell.patch` treats ESRCH as the state +kill exists to reach: the kill succeeds and the session is removed. Both the +SIGHUP and SIGKILL-escalation sites are guarded. + +## Detach deadlock patch (0004) + +This is the one patch here whose failure mode can block the whole daemon. + +`handle_detach` in `libshpool/src/daemon/server.rs` holds the **global session +table lock** across an unbounded control-channel handshake: + +```rust +let shells = self.shells.lock(); // global lock HELD + let shell_to_client_ctl = s.shell_to_client_ctl.lock(); + shell_to_client_ctl.client_connection + .send(shell::ClientConnectionMsg::Disconnect) // unbounded send + let status = shell_to_client_ctl.client_connection_ack + .recv() // unbounded recv, no timeout +``` + +`client_connection` and `client_connection_ack` are both created as +`crossbeam_channel::bounded(0)`, **rendezvous** channels. Neither half +completes unless the session's shell→client thread is sitting in its `select!` +loop ready to receive. + +A client whose socket has stopped draining, a stalled ssh window, a suspended +laptop, a terminal tab that went away without closing cleanly, leaves that +thread blocked in `write()` to the client socket instead. The detach then waits +forever while holding the global lock, and every `list`, `attach`, `detach` and +`kill` in the daemon queues behind it. The daemon still accepts connections +(its main thread stays healthy in `accept()`), which is what makes this look +like a hang rather than a crash. + +**One unresponsive client window takes down every session in the daemon.** + +Log signature, a `handle_detach` span that opens and never closes, followed by +`handle_list` spans that all stop at the same place: + +```text +handle_conn{cid=N}:handle_detach:lock(shells): new +handle_conn{cid=N}:handle_detach:lock(shells):lock(shell_to_client_ctl){s="..."}: new +... no matching close, ever ... +handle_conn{cid=M}:handle_list:lock(shells): new +... no matching close, ever ... +``` + +Confirm with a bounded probe: `timeout 30 shpool list` exiting 124 while the +daemon process is alive and its main thread is in `accept()`. + +### Change + +`0004-detach-must-not-hold-the-shells-lock.patch` restructures `handle_detach` +into three phases: + +1. resolve the requested names to `Arc` control handles under the shells lock, + then **drop the lock**; +2. run the handshake with the global lock released and both halves bounded by + `SESSION_MSG_TIMEOUT`; +3. re-take the shells lock briefly for the `last_disconnected_at` bookkeeping. + +This is not a new design, it is the pattern upstream already uses for the +session-message detach in the same file (`SessionMessageRequestPayload::Detach`), +and every other user of that control channel is bounded too +(`SHELL_TO_CLIENT_CTL_TIMEOUT` at the attach and disconnect sites). +`handle_detach` was the single call site that was neither scoped nor bounded. + +A session that cannot complete the handshake in time is reported as +`not_attached` instead of stalling the daemon. Both callers handle that +correctly already: the background detach behind a takeover treats it as a +non-fatal `debug!` and continues, and an explicit `shpool detach` surfaces it +as an error, which is the accurate signal that the detach did not happen. + +Worst case after the patch is one unreachable session, never a daemon-wide +outage. + +### Limits + +The patch bounds the wait; it does not explain why a shell→client thread +stopped servicing its control channel in the first place. That remains +uninvestigated, the containment is deliberate, because no single-session fault +should be able to take the daemon down regardless of its cause. + +Build, activation and rollback are the same as for `0001`, including the +daemon-restart requirement. Because a restart ends every managed terminal +process, capture exact provider recovery identities first, after the restart +they come back through the one recovery list, which the login chooser's Closed +sessions screen and `sp recover` both read. + +## Exit-status patch (0005) + +`shpool attach` exits 1 instead of the shell's real exit status whenever the +terminal closes the client's stdin before the status frame arrives. + +The attach client runs two threads over the daemon socket, `stdin->sock` and +`sock->stdout`, with a coordinator loop that watches both. The moment +**either** finishes, the coordinator stamps its fallback status of 1 into the +shared result slot so the other thread will notice and stop: + +```rust +if nfinished_threads > 0 { + let mut res = result_slot.lock().unwrap(); + if res.is_none() { + *res = Some(PipeBytesResult::Exit(1)); // protocol.rs:405 + } + ... +``` + +A finished `stdin->sock` thread only means the local input side is done: our +stdin hit EOF. It says nothing about the session, which may be about to report +the shell's status. But `sock->stdout` checks that slot at the top of its loop, +so it returns without ever reading the `ExitStatus` frame, and the client +reports 1. + +The observable ordering is sufficient: stdin reaches EOF, the shared slot is +set to the fallback, the socket reader exits before consuming `ExitStatus`, and +the daemon then has no client for the real status. The broken pipe sets the connection to +`Disconnect`, so the hangup path's `if let ClientConnectionMsg::New(conn)` +no longer matches and the exit chunk is never written at all. + +`0005-attach-must-not-discard-the-shells-exit-status.patch` waits the +**existing** `MAX_DETACH_WAIT_DUR` (300ms) window for the socket side before +defaulting, and only when the stdin side is the one that finished. +`common::sleep_unless` evaluates its predicate before sleeping and returns as +soon as the frame lands or the daemon closes the connection, so the ordinary +case costs one predicate call rather than the whole window. + +### Evidence + +Upstream's own `exits_with_same_status_as_shell` is the reproducer. Repeat it +under load on both pristine and patched trees; the pristine tree must expose +the mismatched status and the patched tree must preserve the shell status. +The full workspace suite must remain green, including +`detaches_on_null_stdin` and `client_eof_does_not_spin`, the two tests that +depend on the current early-exit behaviour. + +### Limits + +The wait is bounded by a constant upstream already uses for this purpose, so a +session that genuinely never reports a status still exits after 300ms rather +than hanging. The patch does not change what the daemon sends, only whether +the client is still listening when it arrives. + +This is an upstream defect, not a Session Kit one, and belongs upstream. Prefer +an accepted upstream fix over carrying this patch. + +## Resize coalescing patch (0006) + +A fullscreen application can emit a burst of `SIGWINCH` events while a terminal +is being resized. Stock shpool forwards every event immediately, so the attached +application repaints once per intermediate size and stale frames can accumulate +in scrollback. + +`0006-coalesce-client-resize-bursts.patch` waits 120 ms for the reported size +and pending signal queue to settle, then forwards one resize carrying the final +row and column count. It also remembers the last forwarded size and skips an +unchanged duplicate. The wait runs only after a resize signal and is bounded by +continued size movement; ordinary input and output are unchanged. -Rollback restores the prior binary and also requires a planned daemon restart. -Session Kit never performs either restart. +Build, activation, rollback, and fingerprint recording are the same as for +`0001`. When carrying it with other patches, apply it last. ## License and upstream diff --git a/shpool-patch/patched-binary.sha256 b/shpool-patch/patched-binary.sha256 index 1d7c1c3..95440c5 100644 --- a/shpool-patch/patched-binary.sha256 +++ b/shpool-patch/patched-binary.sha256 @@ -1 +1 @@ -fcc02563d8386c2c488434505efc2abd986eaf72df943867168b14d445d7fca9 +937ed524412b2810c45661c266cfbb495aa534b0da399ef6509aa04914556ade diff --git a/shpool/config.toml b/shpool/config.toml index 2e5a575..512d5d5 100644 --- a/shpool/config.toml +++ b/shpool/config.toml @@ -1,13 +1,15 @@ -# shpool config — ~/.config/shpool/config.toml +# shpool config, ~/.config/shpool/config.toml # Docs: https://github.com/shell-pool/shpool/blob/master/CONFIG.md -# The daemon hot-reloads this file — no restart, no session loss. +# The daemon hot-reloads this file, no restart, no session loss. -# On reattach, restore up to 500 recent rendered terminal rows. Complete raw -# journals remain explicit through `sp history`; reconnect never streams them. -session_restore_mode = { lines = 500 } +# "simple": on reattach emit NOTHING from the spool; SIGWINCH makes the live +# program redraw its own screen. Replaying spooled frames of a cell-diffing +# TUI (Claude Code, Codex) interleaves cells from different frames, braided +# corruption no width or line count fixes. Clean reading is `sp history`. +session_restore_mode = "simple" -# The spool is what the daemon RETAINS while detached; restore can never replay -# more than this. Keep it at least as large as session_restore_mode.lines. +# The spool is retained while detached; with "simple" restore it is never +# replayed into a terminal, so these only bound daemon memory. output_spool_lines = 1000 vt100_output_spool_width = 200 @@ -15,5 +17,5 @@ vt100_output_spool_width = 200 # start in $HOME). # default_dir = "/home/you/projects/web" -# No name prefix in the prompt — the ▌ marker set in bashrc is the indicator. +# No name prefix in the prompt, the ▌ marker set in bashrc is the indicator. prompt_prefix = "" diff --git a/systemd/session-kit-subagent-sweep.service b/systemd/session-kit-subagent-sweep.service new file mode 100644 index 0000000..4a2bb94 --- /dev/null +++ b/systemd/session-kit-subagent-sweep.service @@ -0,0 +1,10 @@ +[Unit] +Description=Session Kit, close finished sub-agent worker processes + +[Service] +Type=oneshot +# The user manager's default PATH omits the provider CLI and shpool homes. +# This pass needs far less than the reaper does (it reads /proc and signals), +# but it runs the same program, so it is given the same PATH. +Environment=PATH=%h/.cargo/bin:%h/.local/bin:%h/.npm-global/bin:/usr/local/bin:/usr/bin:/bin +ExecStart=%h/.local/bin/shpool_reaper --sweep-subagents diff --git a/systemd/session-kit-subagent-sweep.timer b/systemd/session-kit-subagent-sweep.timer new file mode 100644 index 0000000..a5ad3b3 --- /dev/null +++ b/systemd/session-kit-subagent-sweep.timer @@ -0,0 +1,19 @@ +[Unit] +Description=Session Kit, five-minute sweep for finished sub-agent workers + +[Timer] +# The operator's rule is "close it 15 minutes after it's finished", and idle +# is measured between passes: an hourly pass could only ever deliver that +# promise within about 75 minutes. Five minutes delivers it within about 20. +# This is its own timer rather than a faster shpool-reaper.timer because that +# pass also builds a full guard-live inventory and runs the 72-hour terminal +# auto-close, expensive, destructive, and governed by thresholds that a +# twelve-times-faster cadence buys nothing for. +OnBootSec=2min +OnUnitActiveSec=5min +# No Persistent=: a missed sweep has nothing to catch up. The next pass reads +# the same live /proc and reaches the same answer. +AccuracySec=30s + +[Install] +WantedBy=timers.target diff --git a/systemd/session-kit-watchdog.service b/systemd/session-kit-watchdog.service index 015916b..79e084c 100644 --- a/systemd/session-kit-watchdog.service +++ b/systemd/session-kit-watchdog.service @@ -1,5 +1,5 @@ [Unit] -Description=Recover sessions whose terminal dies inside shpool +Description=Session Kit, restore sessions whose terminal dies # The watchdog reads the daemon's log and inventory; it must not start before # the daemon exists, but it must not fail if the daemon is restarted either. Wants=shpool.service diff --git a/systemd/shpool-reaper.service b/systemd/shpool-reaper.service index 57754f1..ee4e95c 100644 --- a/systemd/shpool-reaper.service +++ b/systemd/shpool-reaper.service @@ -1,5 +1,5 @@ [Unit] -Description=Close exactly verified provider-exited Session Kit terminals +Description=Session Kit, close verified provider-exited sessions [Service] Type=oneshot diff --git a/systemd/shpool-reaper.timer b/systemd/shpool-reaper.timer index cb98ba5..a48180e 100644 --- a/systemd/shpool-reaper.timer +++ b/systemd/shpool-reaper.timer @@ -1,5 +1,5 @@ [Unit] -Description=Hourly exact provider-exit cleanup check +Description=Session Kit, hourly check for provider-exited sessions [Timer] OnCalendar=hourly diff --git a/systemd/shpool.service b/systemd/shpool.service index 4c94ae5..53794cb 100644 --- a/systemd/shpool.service +++ b/systemd/shpool.service @@ -1,5 +1,5 @@ [Unit] -Description=Shpool - Shell Session Pool +Description=shpool session daemon Requires=shpool.socket [Service] diff --git a/systemd/shpool.socket b/systemd/shpool.socket index a291510..972cdad 100644 --- a/systemd/shpool.socket +++ b/systemd/shpool.socket @@ -1,5 +1,5 @@ [Unit] -Description=Shpool Shell Session Pooler +Description=shpool session daemon socket [Socket] ListenStream=%t/shpool/shpool.socket diff --git a/tests/__init__.py b/tests/__init__.py index 3839191..7ac1f26 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,76 @@ -"""session-kit isolated regression tests.""" +"""session-kit isolated regression tests. + +Importing this package arms the sandbox guard, which is what keeps a fixture +from reaching the machine's real session manager. Read +``tests/sandbox_guard.py`` before changing anything here: the guard has to be +installed before the first test module builds a fixture environment, and +importing this package is the one point every suite passes through. + +It also pins the terminal, for the same reason and at the same point. See +``pin_terminal`` below. +""" + +import errno +import os + +from tests import sandbox_guard + +sandbox_guard.install() + + +def pin_terminal() -> None: + """Answer the ambient-terminal questions here instead of inheriting them. + + ``shutil.get_terminal_size`` in the shipped renderer + (``sessionkit_inventory/render.py``) asks the process about its window + rather than about the data, and the width it returns picks which detail + form a row shows. A test that renders without pinning it therefore + measured the operator's window: the same unchanged suite was green through + a pipe and red on a terminal, and three tests changed their answer between + the two. That cost two reviewers and two lanes a full investigation before + it was diagnosed, twice as an ordering fault it never was. + + Pinning belongs here rather than in ``tests/run`` alone: a reviewer runs + ``python3 -m unittest tests.test_inventory`` far more often than the + runner script, and both paths import this package. + + Refusing the window query is exactly the answer a pipe gives, so every + caller falls back to the width declared in its own signature and the + suite's rendered output is byte-identical to the piped runs CI has always + made. The refusal is a Python-level patch, so it does not reach a child: + a fixture driving a real pty child still measures that child's real pty. + (The four pops below DO change what a child inherits -- that is their + point: they remove the launcher's values, they add none.) Local overrides + still win either way, because ``shutil.get_terminal_size`` reads COLUMNS + before it asks the terminal, so a test that wants a width exports one. + + The colour half of the same question is deliberately NOT pinned with an + environment variable. SESSION_KIT_NO_COLOR and NO_COLOR are *kill + switches*: `sp doctor` reports a set one as a disabled live channel, and + tests/test_install.py rightly asserts a clean install answers "no + supported kill switch". A harness that exported one to make its own + rendering deterministic would hand every child a switch the operator never + threw -- so the inherited ones are cleared here instead, and a module that + needs colour decided pins it for itself (see setUpModule in + tests/test_inventory.py) or passes ``color_enabled=`` to the renderer. + + Like the sandbox guard, there is deliberately no environment variable that + switches this off. A switch is the accidental inheritance it exists to + prevent. + """ + # Popped, not overwritten: an exported COLUMNS would answer shutil before + # the refusal below is ever reached, which is the leak, not the fix. + os.environ.pop("COLUMNS", None) + os.environ.pop("LINES", None) + # A kill switch the operator's shell happened to export is not this + # suite's input either -- it reaches every child and doctor reports it. + os.environ.pop("SESSION_KIT_NO_COLOR", None) + os.environ.pop("NO_COLOR", None) + + def _no_terminal(*_args: object, **_kwargs: object) -> "os.terminal_size": + raise OSError(errno.ENOTTY, "no terminal (pinned by the test suite)") + + os.get_terminal_size = _no_terminal # type: ignore[assignment] + + +pin_terminal() diff --git a/tests/latency/shim-ppid/python3 b/tests/latency/shim-ppid/python3 new file mode 100755 index 0000000..0593c79 --- /dev/null +++ b/tests/latency/shim-ppid/python3 @@ -0,0 +1,6 @@ +#!/bin/bash +# Logs who started each interpreter: a call from the picker's own shell is +# foreground work between a keystroke and its echo; a call from one of its +# children is the background collector, where that work belongs. +printf '%s\t%s\t%s\n' "$EPOCHREALTIME" "$PPID" "$*" >> "$SK_SHIM_PPID_LOG" +exec /usr/bin/python3 "$@" diff --git a/tests/latency/shim/python3 b/tests/latency/shim/python3 new file mode 100755 index 0000000..0a66eae --- /dev/null +++ b/tests/latency/shim/python3 @@ -0,0 +1,3 @@ +#!/bin/bash +printf '%s\tpython3\t%s\n' "$EPOCHREALTIME" "$*" >> "$SK_SHIM_LOG" +exec /usr/bin/python3 "$@" diff --git a/tests/latency/shim/stty b/tests/latency/shim/stty new file mode 100755 index 0000000..df35c6d --- /dev/null +++ b/tests/latency/shim/stty @@ -0,0 +1,3 @@ +#!/bin/bash +printf '%s\tstty\t%s\n' "$EPOCHREALTIME" "$*" >> "$SK_SHIM_LOG" +exec /usr/bin/stty "$@" diff --git a/tests/latency/shim/tput b/tests/latency/shim/tput new file mode 100755 index 0000000..43b894e --- /dev/null +++ b/tests/latency/shim/tput @@ -0,0 +1,3 @@ +#!/bin/bash +printf '%s\ttput\t%s\n' "$EPOCHREALTIME" "$*" >> "$SK_SHIM_LOG" +exec /usr/bin/tput "$@" diff --git a/tests/run b/tests/run index e339c55..a37782d 100755 --- a/tests/run +++ b/tests/run @@ -4,11 +4,69 @@ set -euo pipefail repo=$(cd -- "$(dirname -- "$0")/.." && pwd) export PYTHONDONTWRITEBYTECODE=1 +# A full suite on a live box must never outrank the person's sessions, the +# picker, or the collectors, 2,619 tests at normal priority pushed the load +# to 12 and starved interactive use on 2026-08-17. Lowest scheduling priority +# and idle IO for this process and everything it starts; harmless where the +# tools are missing. +# +# NOT on a CI runner. There the suite is the only thing running and there is +# nobody to yield to, so lowering its own priority only starves the pty tests: +# the picker under test loses its scheduling slice mid-read and a fixture +# waiting on a repaint gives up on a picker that was behaving correctly. That +# is measured, not supposed, under one identical load, +# test_help_and_more_remain_modal_across_refresh_intervals passed 3 of 4 runs +# at normal priority and 0 of 4 reniced, and the ubuntu-24.04/3.10 cell went +# from five straight green runs to failing every run from the commit that +# added these two lines (42d5946) onward. +if [[ ${GITHUB_ACTIONS:-} != true ]]; then + renice -n 15 -p $$ >/dev/null 2>&1 || true + ionice -c 3 -p $$ >/dev/null 2>&1 || true +fi + +# The suite must not inherit the operator's window, and must not inherit a +# kill switch their shell happened to export either. tests/__init__.py pins +# this for every entry point, including a bare +# `python3 -m unittest tests.test_inventory`; the same clearing happens here so +# it also covers whatever this script starts outside Python. An exported +# COLUMNS would answer shutil.get_terminal_size before the pin is reached, and +# a set SESSION_KIT_NO_COLOR/NO_COLOR is a disabled live channel that `sp +# doctor` reports and tests/test_install.py asserts against. Read +# pin_terminal() in tests/__init__.py before changing either. A test that wants +# a width, or wants colour, still sets it for itself and still wins. +unset COLUMNS LINES SESSION_KIT_NO_COLOR NO_COLOR + +# An interrupted run leaves its sandbox in the repository, and any real provider +# CLI it started inside that sandbox keeps running -- one pair was found alive a +# day and twenty hours later. Clear both before starting, never anything younger +# than two hours, so a run in flight is untouched. +python3 "$repo/tests/sweep_sandboxes.py" || true + +# SESSION_KIT_TEST_EXEC_ROOT is deliberately NOT set here. The fixtures honour +# it (sandboxes move out of the repository, which fixes sandbox litter and the +# AF_UNIX path-length overflow on deep checkouts), but setting it also flips +# every pty fixture onto its exec-via-bash branch, a different invocation +# path than the one CI has always tested, and one that proved timing-fragile +# on hosted runners. Export it yourself for local runs from a long worktree. + +# One process per module, several at a time, when asked for. Unset or 1 keeps +# the single discovery run this has always been, so nothing changes for anyone +# who does not opt in. The suite spends most of its wall clock asleep in a pty +# waiting for a picker to repaint, so the workers cost far less CPU than the +# clock they save: 4 workers finish the same 2,728 tests in well under half the +# time, on four processors as readily as on thirty-two. +workers=${SESSION_KIT_TEST_WORKERS:-1} +[[ $workers =~ ^[0-9]+$ ]] || workers=1 +if (( workers > 1 )); then + suite=(python3 "$repo/tests/shard.py" --workers "$workers") +else + suite=(python3 -m unittest discover -s "$repo/tests" -t "$repo" -v) +fi + if [[ ${GITHUB_ACTIONS:-} == true ]]; then - log=$(mktemp "${RUNNER_TEMP:-/tmp}/session-kit-tests.XXXXXX.log") + log=$(mktemp "${RUNNER_TEMP:-/tmp}/session-kit-tests.log.XXXXXX") set +e - python3 -m unittest discover -s "$repo/tests" -t "$repo" -v 2>&1 | - tee "$log" + "${suite[@]}" 2>&1 | tee "$log" status=${PIPESTATUS[0]} set -e if (( status != 0 )); then @@ -27,4 +85,4 @@ PY exit "$status" fi -exec python3 -m unittest discover -s "$repo/tests" -t "$repo" -v +exec "${suite[@]}" diff --git a/tests/sandbox_guard.py b/tests/sandbox_guard.py new file mode 100644 index 0000000..9cf9cb6 --- /dev/null +++ b/tests/sandbox_guard.py @@ -0,0 +1,480 @@ +"""No test in this suite may reach the machine's real session manager. + +The hole this closes +-------------------- +``bashrc/shpool.bashrc`` hands a window back to the picker with +``command shpool detach``. That call is hardcoded on purpose -- +``SESSION_KIT_SHPOOL_CMD`` deliberately does not reach it, because a session +shell must not be redirected by an environment variable -- so the only lever a +test has is PATH. Any fixture that sources the bashrc and drives the +provider-exit path therefore runs whatever ``shpool`` PATH resolves to, on the +default socket, carrying whatever ``SHPOOL_SESSION_NAME`` the fixture set. On +this machine that resolves to a real binary in ``~/.cargo/bin``, and a real +daemon with live sessions is listening. ``detach`` ends a client's view rather +than the session, so the damage is an operator's window thrown back to the +picker mid-work: disruptive and recoverable, not destructive. It is still the +wrong thing for a test to be able to do. + +Twelve suites build a fixture HOME. A fix that depends on each of them +remembering is a fix that lasts until the thirteenth. So the guard is global +and installed once, at import of the ``tests`` package. + +What the guard does +------------------- +1. Creates a private directory holding a **refusing** ``shpool`` stub. The stub + prints the command and its arguments, records the attempt, and exits + non-zero, so a suite that genuinely needed a session manager fails with a + message naming the problem instead of silently touching the estate. +2. Splices that directory into ``PATH`` -- for this process and, through the + belt below, for every child -- immediately **before** the first PATH entry + that holds a real ``shpool`` outside the test sandbox. Placing it there + rather than at the front matters: a fixture that installs its own stub in + its sandbox still wins, which is what a test asserting on shpool calls + needs. +3. Rewrites the environment of every child started through ``subprocess``, + ``os.execve`` / ``os.execvpe``, ``os.posix_spawn`` / ``os.posix_spawnp`` and + ``os.spawnve``, so a suite that hands its child a hand-built ``PATH`` + (several do; ``"PATH": "/usr/bin:/bin"`` and ``f"{bin}:{os.environ['PATH']}"`` + are both in the tree) cannot step outside the guard by accident. +4. Pins ``XDG_STATE_HOME`` to agree with ``HOME``. ``__sk_state_root`` is + ``${XDG_STATE_HOME:-$HOME/.local/state}``, so an inherited value would send + every state write the bashrc makes -- provider-bounce, + account-switch-requests, session-color -- outside the fixture. It is unset + on this machine today, which is luck rather than isolation. A value a test + sets deliberately is left exactly as the test set it. + +The two documented opt-outs +--------------------------- +Neither of them can hand a test the real binary. That is the property that +makes them safe to have. + +*A test that runs its own private daemon.* Give the binary a name that is not +``shpool``; the guard only ever intercepts the names in +:data:`GUARDED_COMMANDS`. Declare it through :func:`private_daemon_name`, which +refuses a name that would collide and makes the intent visible at the call +site. + +*A test that needs no session manager at all.* ``tests/test_cli_help.py`` +proves ``sp help`` answers on a machine where shpool is not installed, and a +refusing stub on PATH makes that pass for the wrong reason. :func:`without_shpool` +marks such a child, and the belt answers by removing the guard stub **and every +PATH entry holding a real binary**, so asking for absence gets absence. + +There is deliberately **no** environment variable that switches the guard off. +A switch is the accidental escape this module exists to prevent, and +``PrivateDaemonOptOutTests`` asserts one never appears. + +Credit: the per-suite version of this -- a PATH stub in the fixture HOME, a +call log, and a tearDown that reads every call back -- was written first for +``tests/test_lifecycle_shell.py`` on ``fix/exits-and-commands`` (b650fc2). The +recording stub and the read-back assertion here are lifted from it. +""" + +from __future__ import annotations + +import atexit +import inspect +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + + +REPO = Path(__file__).resolve().parents[1] + +# Basenames a test may never resolve to a real binary. Everything about the +# guard is keyed off this tuple, so widening it later is a one-line change. +GUARDED_COMMANDS = ("shpool",) + +# Distinct from anything a real tool returns, so a refusal is recognisable in a +# transcript that only kept the exit status. +REFUSAL_EXIT_CODE = 97 + +# A child environment carrying this asks for the guarded commands to be ABSENT +# rather than refusing. tests/test_cli_help.py needs exactly that: it proves +# `sp help` still answers on a machine with no session manager installed, and a +# refusing stub sitting on PATH makes that test pass for the wrong reason. +# Honouring the request makes the guard stricter, never weaker -- see +# guarded_environment(), which also drops every PATH entry holding a real +# binary, so "absent" can never quietly become "the real one". Ask for it +# through tests.support.without_shpool(). +ABSENT_MARKER = "SESSION_KIT_TEST_SHPOOL_ABSENT" + +# Captured before anything is pinned, so the belt can tell an inherited value +# apart from one a test chose. INHERITED_PATH is the PATH this process started +# with -- the one that still reaches the machine's real binaries -- kept so a +# test can ask what the guard is standing in front of without hard-coding a +# path that would leak into the public export. +_REAL_HOME = os.environ.get("HOME") or "" +_INHERITED_STATE_HOME = os.environ.get("XDG_STATE_HOME") +# A test run launched from inside a kit-managed session inherits the session's +# own account profile in these. Any suite that starts a real provider CLI in a +# fixture HOME would then write real transcripts into a real account profile: +# 952 such stray directories were cleaned out of two profiles on 2026-08-17. +# Captured here for the same inherited-versus-chosen test the state home uses. +_INHERITED_PROVIDER_DIRS = { + name: os.environ.get(name) + for name in ( + "CLAUDE_CONFIG_DIR", + "CODEX_HOME", + "CLAUDE_CODE_PROJECT_DIR_NAME", + # The estate identity of the SESSION the tests happen to run inside. + # A generated SHPOOL_SESSION_NAME makes sp classify every launch as + # machine-origin, the person-origin worktree tests fail on a live + # box and nowhere else. A fixture that sets its own value keeps it; + # only the inherited one is dropped. + "SHPOOL_SESSION_NAME", + "SHPOOL_SESSION_DIR", + "SHPOOL_JOURNAL", + "SESSION_KIT_ORIGIN", + "SESSION_KIT_ACCOUNT_ALIAS", + "SESSION_KIT_ACCOUNT_CAPABLE", + ) + if os.environ.get(name) is not None +} +INHERITED_PATH = os.environ.get("PATH") or "" + +_guard_dir: Path | None = None +_refusal_log: Path | None = None +_installed = False +_holder_cache: dict[str, bool] = {} + + +# -------------------------------------------------------------------------- +# Where the guard lives +# -------------------------------------------------------------------------- + + +def guard_dir() -> Path: + """The directory holding the refusing stubs. Installed on first use.""" + install() + assert _guard_dir is not None + return _guard_dir + + +def refusal_log() -> Path: + """Every guarded call the run refused, one ``name arg arg`` line each.""" + install() + assert _refusal_log is not None + return _refusal_log + + +def refused_calls() -> list[str]: + """Read the refusal log back. Empty when nothing was refused.""" + log = refusal_log() + if not log.exists(): + return [] + return [line for line in log.read_text(encoding="utf-8").splitlines() if line] + + +def _write_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def _refusing_stub(name: str, log: Path) -> str: + # Single-quoted heredoc-free bash: the log path is baked in absolutely so + # the stub refuses identically however the child's environment was built. + return ( + "#!/usr/bin/env bash\n" + "# Refusing stub installed by tests/sandbox_guard.py. A test reached\n" + "# for the machine's real session manager; it may not have it.\n" + f"printf '%s %s\\n' {name!r} \"$*\" >> {str(log)!r}\n" + "{\n" + f" printf 'session-kit tests: refused `%s %s`\\n' {name!r} \"$*\"\n" + " printf ' This test process is sandboxed: the real session manager\\n'\n" + " printf ' is out of reach, on purpose. A live daemon with the\\n'\n" + " printf ' operator sessions is listening on the default socket.\\n'\n" + " printf ' Install a fixture stub (tests.support" + ".install_recording_shpool)\\n'\n" + " printf ' or run a private daemon under a non-%s basename\\n' " + f"{name!r}\n" + " printf ' (tests.sandbox_guard.private_daemon_name).\\n'\n" + f"}} >&2\n" + f"exit {REFUSAL_EXIT_CODE}\n" + ) + + +# -------------------------------------------------------------------------- +# PATH surgery +# -------------------------------------------------------------------------- + + +def _sandbox_roots() -> tuple[str, ...]: + roots = [os.fspath(REPO), tempfile.gettempdir()] + exec_root = os.environ.get("SESSION_KIT_TEST_EXEC_ROOT") + if exec_root: + roots.append(exec_root) + return tuple(os.path.realpath(root) for root in roots) + + +def _inside_the_sandbox(directory: str) -> bool: + """Is this PATH entry somewhere a fixture builds its own stubs? + + Fixtures build their sandbox with ``tempfile.TemporaryDirectory(dir=REPO)`` + (or under ``SESSION_KIT_TEST_EXEC_ROOT``), so anything under those roots is + the test's own and the guard must not shadow it. + """ + try: + real = os.path.realpath(directory) + except (OSError, ValueError): + return False + return any( + real == root or real.startswith(root + os.sep) for root in _sandbox_roots() + ) + + +def _holds_a_real_guarded_command(directory: str) -> bool: + """Does this PATH entry hold a guarded command the guard must get in front of?""" + if not directory or _inside_the_sandbox(directory): + return False + cached = _holder_cache.get(directory) + if cached is not None: + return cached + found = False + for name in GUARDED_COMMANDS: + candidate = os.path.join(directory, name) + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + found = True + break + _holder_cache[directory] = found + return found + + +def guarded_path(value: str | None) -> str: + """``value`` with the guard directory spliced in ahead of any real binary. + + Idempotent: an existing occurrence is removed and re-placed, so the belt can + run over a PATH the belt already touched without drifting. + """ + root = os.fspath(guard_dir()) + entries = [entry for entry in (value or "").split(os.pathsep) if entry] + if not entries: + # No PATH means the child searches confstr's default. Say that path out + # loud rather than leaving the guard with nothing to sit in front of. + default = os.confstr("CS_PATH") or "/bin:/usr/bin" + entries = [entry for entry in default.split(os.pathsep) if entry] + entries = [entry for entry in entries if entry != root] + position = len(entries) + for index, entry in enumerate(entries): + if _holds_a_real_guarded_command(entry): + position = index + break + entries.insert(position, root) + return os.pathsep.join(entries) + + +def path_without_guarded_commands(value: str | None) -> str: + """``value`` with the guard directory AND every real holder removed. + + For the test that needs a machine with no session manager at all. Dropping + the real holders as well as the stub is what makes this safe: asking for + absence can never hand a child the real binary instead. + """ + root = os.fspath(guard_dir()) + entries = [entry for entry in (value or "").split(os.pathsep) if entry] + return os.pathsep.join( + entry + for entry in entries + if entry != root and not _holds_a_real_guarded_command(entry) + ) + + +# -------------------------------------------------------------------------- +# The belt: every child's environment +# -------------------------------------------------------------------------- + + +def guarded_environment(env): + """A copy of ``env`` a child cannot use to reach the real session manager.""" + if env is None: + return None + guarded = dict(env) + if guarded.get(ABSENT_MARKER) == "1": + guarded["PATH"] = path_without_guarded_commands(guarded.get("PATH")) + else: + guarded["PATH"] = guarded_path(guarded.get("PATH")) + + home = guarded.get("HOME") + if home and home != _REAL_HOME: + state_home = guarded.get("XDG_STATE_HOME") + inherited = state_home is not None and state_home == _INHERITED_STATE_HOME + if state_home is None or inherited: + # Absent resolves to exactly this already, so writing it changes + # nothing and stops an inherited value from ever mattering. A value + # the test chose is left alone. + guarded["XDG_STATE_HOME"] = os.path.join(home, ".local", "state") + # A provider directory the test chose is honoured; one this process + # merely inherited is dropped, so a real provider CLI started in a + # fixture HOME falls back to that HOME instead of a real profile. + for name, inherited_value in _INHERITED_PROVIDER_DIRS.items(): + if guarded.get(name) == inherited_value: + guarded.pop(name, None) + return guarded + + +def _patch_subprocess() -> None: + original = subprocess.Popen.__init__ + if getattr(original, "_session_kit_guarded", False): + return + signature = inspect.signature(original) + + def __init__(self, *args, **kwargs): # noqa: N807 - patching a dunder + try: + bound = signature.bind(self, *args, **kwargs) + except TypeError: + # Let the real constructor raise the argument error itself. + return original(self, *args, **kwargs) + if bound.arguments.get("env") is not None: + bound.arguments["env"] = guarded_environment(bound.arguments["env"]) + return original(*bound.args, **bound.kwargs) + + __init__._session_kit_guarded = True # type: ignore[attr-defined] + subprocess.Popen.__init__ = __init__ # type: ignore[method-assign] + + +def _patch_os_exec() -> None: + """``os.exec*e`` and friends bypass subprocess entirely. + + Nine suites fork and call ``os.execve`` with a hand-built environment. The + fork shares this interpreter's memory, so patching here reaches the child. + """ + + def wrap(name: str, index: int) -> None: + original = getattr(os, name, None) + if original is None or getattr(original, "_session_kit_guarded", False): + return + + def wrapper(*args, **kwargs): + if "env" in kwargs: + kwargs["env"] = guarded_environment(kwargs["env"]) + elif len(args) > index: + arguments = list(args) + arguments[index] = guarded_environment(arguments[index]) + args = tuple(arguments) + return original(*args, **kwargs) + + wrapper._session_kit_guarded = True # type: ignore[attr-defined] + wrapper.__name__ = name + wrapper.__doc__ = original.__doc__ + setattr(os, name, wrapper) + + # (function, position of the environment argument) + wrap("execve", 2) + wrap("execvpe", 2) + wrap("spawnve", 3) + wrap("posix_spawn", 2) + wrap("posix_spawnp", 2) + + +# -------------------------------------------------------------------------- +# Install +# -------------------------------------------------------------------------- + + +def install() -> Path: + """Arm the guard. Safe to call repeatedly; the first call does the work.""" + global _guard_dir, _refusal_log, _installed + if _installed: + assert _guard_dir is not None + return _guard_dir + + # Some supported hosts mount /tmp noexec, and the stub has to execute, so + # the guard lives on the repository's filesystem like the fixtures do. The + # dotted prefix keeps it out of a commit (.gitignore covers `/.*/`) and + # tests/sweep_sandboxes.py clears one an interrupted run left behind. + _guard_dir = Path(tempfile.mkdtemp(prefix=".sandbox-guard-", dir=REPO)) + _refusal_log = _guard_dir / "refused-calls.log" + for name in GUARDED_COMMANDS: + _write_executable(_guard_dir / name, _refusing_stub(name, _refusal_log)) + atexit.register(shutil.rmtree, _guard_dir, True) + _installed = True + + os.environ["PATH"] = guarded_path(os.environ.get("PATH")) + # Drop an inherited value rather than carry it into every os.environ.copy(). + if _INHERITED_STATE_HOME is not None: + os.environ.pop("XDG_STATE_HOME", None) + for name in _INHERITED_PROVIDER_DIRS: + os.environ.pop(name, None) + + _patch_subprocess() + _patch_os_exec() + return _guard_dir + + +# -------------------------------------------------------------------------- +# Helpers for fixtures +# -------------------------------------------------------------------------- + +# The recording stub, lifted from tests/test_lifecycle_shell.py on +# fix/exits-and-commands (b650fc2). It answers `list --json` with an empty but +# VALID payload rather than refusing, so a reopen guard fails on "this terminal +# is not in the list" for a reason the fixture chose rather than on whatever +# the real estate happened to contain that minute. +RECORDING_STUB = ( + "#!/usr/bin/env bash\n" + '# Recording shpool stub. Writes every call to "$SHPOOL_LOG".\n' + 'printf "%s\\n" "$*" >> "$SHPOOL_LOG"\n' + "if [[ $1 == list ]]; then printf '{\"sessions\": []}\\n'; exit 0; fi\n" + "exit 1\n" +) + + +def install_recording_shpool(bin_dir: Path, log: Path) -> Path: + """Put a RECORDING ``shpool`` in ``bin_dir`` and return its path. + + The global guard already stops a fixture reaching the real binary. This is + for the fixture that needs to *assert* on the calls, or that needs ``list + --json`` to answer rather than refuse. Pass ``SHPOOL_LOG=<log>`` in the + child environment; ``bin_dir`` must be inside the fixture sandbox, which is + where the bashrc's ``$HOME/.local/bin`` prepend puts it first. + """ + bin_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + stub = bin_dir / "shpool" + _write_executable(stub, RECORDING_STUB) + log.parent.mkdir(parents=True, exist_ok=True) + return stub + + +def recorded_shpool_calls(log: Path) -> list[str]: + """Every distinct ``shpool`` call the fixture made, sorted.""" + if not log.exists(): + return [] + return sorted(set(log.read_text(encoding="utf-8").split("\n")) - {""}) + + +def unexpected_shpool_calls(log: Path, allowed: tuple[str, ...]) -> list[str]: + """Calls outside ``allowed``. A new one is a new way to reach a daemon.""" + return [call for call in recorded_shpool_calls(log) if call not in allowed] + + +def without_shpool(env: dict[str, str]) -> dict[str, str]: + """``env`` for a child that must find NO session manager at all. + + ``tests/test_cli_help.py`` proves ``sp help`` answers on a machine where + shpool is not installed. A refusing stub on PATH would make that test pass + for the wrong reason, so this asks the belt for absence instead -- and the + belt delivers absence by removing the real holders too, so the request can + never turn into access. + """ + return {**env, ABSENT_MARKER: "1"} + + +def private_daemon_name(name: str) -> str: + """Declare a private daemon binary a test is allowed to really run. + + The one legitimate escape from the guard, and the only one: a test that + starts its own daemon under a basename the guard does not intercept. + ``/home/.../exits`` did this correctly with ``sk-exitproof-pool``. Calling + this makes the intent explicit at the call site and refuses a name that + would collide with the guarded set. + """ + if name in GUARDED_COMMANDS: + raise AssertionError( + f"{name!r} is guarded: a test may not run the real binary under " + f"that name. Give the private daemon its own basename (the exits " + f"branch used 'sk-exitproof-pool') so the guard never has to " + f"decide whether this particular call was safe." + ) + return name diff --git a/tests/shard.py b/tests/shard.py new file mode 100644 index 0000000..5473d36 --- /dev/null +++ b/tests/shard.py @@ -0,0 +1,178 @@ +"""Run the suite across several worker processes instead of one. + +The whole suite is two of the three things the release gate spends its hour on: +`tests/run` inside the exact public export, and the same suite again under +coverage. Both were one process walking 99 modules in a row, and both are +bounded by wall clock rather than by work -- most of the expensive modules sit +in a pty waiting for a picker to repaint, so a single process is idle for most +of its own runtime. + +Two properties this has to keep, and they are why it is a work queue rather +than a fixed split: + + * Balance without a table to maintain. Module cost is not predictable from + anything in the file -- the biggest module by bytes is not the slowest, and + the slowest are the pty suites, which are small and mostly asleep. Workers + therefore PULL the next module as they free up, so the split adapts to + whatever the modules actually cost today. SLOW_FIRST only decides what is + handed out early; it is a hint about balance and can never change a result. + + * One module, one process. Modules that build sandboxes inside the checkout + run beside modules that copy the checkout, and the isolation that makes + that safe is process isolation (see tests/sweep_sandboxes.py). Splitting + finer than a module would also split fixtures that share class-level state. + +Scheduling pressure is the risk this carries, not correctness. tests/run +records that renicing the suite took a modal pty test from three of four +passing to none of four, so the pty suites fail when they lose their slice. +Workers therefore default to a number that leaves the machine headroom rather +than to the CPU count. +""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import subprocess +import sys +import time + +REPO = Path(__file__).resolve().parents[1] + +# Handed out first so the long poles are never the last thing started: a queue +# whose slowest module starts last finishes no sooner than a serial run of the +# rest plus that module. The floor for any split is the slowest single module, +# so test_watchdog alone sets about six and a half minutes. +# +# These are measured, with the seconds each took on 2026-08-18 (4 workers, 4 +# CPUs). Guessing was worse than useless: the first version of this list led +# with test_inventory, the largest module in the tree at 344 KB, which runs in +# 3.6 s -- while test_watchdog, the real long pole at over six minutes, was not +# in it at all and got handed out near the end. +# +# Refresh from the per-module table this prints at the end of every run. +# Nothing here affects which tests run or whether they pass: an unlisted slow +# module costs balance and nothing else. +SLOW_FIRST = ( + "test_watchdog", # 388s + "test_install", # 171s + "test_commands", # 168s + "test_login", # 99s + "test_account_auto_switch", # 70s + "test_model_availability", # 67s + "test_launch_log", # 62s + "test_tui_acceptance", # 58s + "test_picker_events", # 47s + "test_attach_tty_handoff", # 37s +) + + +def modules() -> list[str]: + """Every test module, slowest-known first, then the rest by name.""" + found = sorted(path.stem for path in (REPO / "tests").glob("test_*.py")) + known = [name for name in SLOW_FIRST if name in found] + return known + [name for name in found if name not in set(known)] + + +def default_workers() -> int: + """Leave the machine headroom rather than saturating it. + + A worker that loses its scheduling slice mid-read fails pty tests that are + behaving correctly, which is the same fault tests/run refuses to reintroduce + by renicing on CI. Half the CPUs, at least two, never more than four: past + four the suite is waiting on sleeps rather than on processors anyway. + """ + available = os.cpu_count() or 2 + return max(2, min(4, available // 2)) + + +def command(module: str, *, coverage: bool) -> list[str]: + if coverage: + # --parallel-mode gives every worker its own data file, which is what + # makes `coverage combine` afterwards meaningful rather than a race. + return [ + sys.executable, + "-m", + "coverage", + "run", + "--parallel-mode", + "--branch", + "--source=lib", + "-m", + "unittest", + "-v", + module, + ] + return [sys.executable, "-m", "unittest", "-v", module] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--workers", type=int, default=default_workers()) + parser.add_argument("--coverage", action="store_true") + options = parser.parse_args() + + queue = modules() + total = len(queue) + workers = max(1, min(options.workers, total)) + print(f"running {total} modules across {workers} worker(s)", flush=True) + + environment = dict(os.environ, PYTHONDONTWRITEBYTECODE="1") + running: dict[subprocess.Popen[bytes], tuple[str, float]] = {} + timings: list[tuple[float, str]] = [] + failed: list[str] = [] + started = time.monotonic() + + def start_next() -> None: + if not queue: + return + module = queue.pop(0) + process = subprocess.Popen( + command(f"tests.{module}", coverage=options.coverage), + cwd=REPO, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + running[process] = (module, time.monotonic()) + + for _ in range(workers): + start_next() + + while running: + for process in list(running): + if process.poll() is None: + continue + module, began = running.pop(process) + output = process.stdout.read() if process.stdout else b"" + if process.stdout: + process.stdout.close() + took = time.monotonic() - began + timings.append((took, module)) + if process.returncode != 0: + failed.append(module) + # The failing module's whole output, so a parallel run is no + # harder to read than a serial one at the point it matters. + print(f"\n===== FAILED tests.{module} =====", flush=True) + print(output.decode("utf-8", errors="replace"), flush=True) + else: + print(f" ok {module} ({took:.1f}s)", flush=True) + start_next() + if running: + time.sleep(0.05) + + elapsed = time.monotonic() - started + print("\nslowest modules:", flush=True) + for took, module in sorted(timings, reverse=True)[:10]: + print(f" {took:7.1f}s {module}", flush=True) + print(f"\n{total} modules in {elapsed:.1f}s across {workers} worker(s)") + if failed: + print(f"FAILED modules: {' '.join(sorted(failed))}") + return 1 + print("OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/support.py b/tests/support.py index 4e10b00..e0ea456 100644 --- a/tests/support.py +++ b/tests/support.py @@ -10,10 +10,100 @@ import tempfile from typing import Iterable +# Re-exported so a fixture never has to know where the guard lives. The guard +# itself is armed from tests/__init__.py, before any of this runs; these are the +# pieces a fixture reaches for when it wants to RECORD shpool calls and assert +# on them, rather than only be stopped from making them. +from tests.sandbox_guard import ( # noqa: F401 + guarded_environment, + install_recording_shpool, + private_daemon_name, + recorded_shpool_calls, + unexpected_shpool_calls, + without_shpool, +) + REPO = Path(__file__).resolve().parents[1] + +# The only dotted entries at the repository root that ARE the source. Anything +# else dotted up there is a fixture's sandbox or a build cache. +SOURCE_ROOT_DOTTED = frozenset({".github", ".gitignore", ".shellcheckrc"}) + + +def source_tree_ignore(root: Path = REPO): + """What a copy of this checkout must leave behind. + + Three tests copy the source tree, and all three used to name what to skip: + `.git`, the caches, `__pycache__`. None of them skipped the fixtures' + sandboxes, which are built inside the checkout on purpose to keep AF_UNIX + paths short and which come and go in seconds. Copy the tree while a sibling + test removes one and copytree lists an entry, then fails to open it, and the + run dies over a file that was never part of the source. + + c9d85e9 fixed that in one of the three by borrowing the sweep's list of + sandbox prefixes. Six were named against the two hundred and more the suite + hands tempfile, so the fix covered neither of the two sandboxes that have + actually broken a run. Prefixes were never the right shape here. + + tests/sweep_sandboxes.py no longer carries a list -- it reads the prefixes + out of the test sources -- and this rule still does not borrow from it. A + copy rule and a delete rule disagree about `.git`: the copy must drop it, + the sweep must protect it. Two answers, two constants, no shared edit. + + .gitignore settled this for Git already -- `/.*/` with `!/.github/`, under + the note "Ignore the whole class rather than chase prefixes" -- so this + applies that same rule at the root: every dotted entry goes except the ones + that really are source. Nothing about deeper directories changes, where a + dotted name is ordinary content. + """ + root = Path(root).resolve() + + def ignore(directory: str, names: list[str]) -> set[str]: + dropped = { + name for name in names if name == "__pycache__" or name.endswith(".pyc") + } + if Path(directory).resolve() == root: + dropped |= { + name + for name in names + if name.startswith(".") and name not in SOURCE_ROOT_DOTTED + } + return dropped + + return ignore + + RELEASE_TOOL = REPO / "deploy" / "session-kit-release" -HELPERS = ("sp", "shpool_login", "shpool_status", "shpool_reaper", "codex_resume_here") +HELPERS = ( + "sp", + "shpool_login", + "shpool_login_tui", + "shpool_login_launcher", + "shpool_status", + "shpool_reaper", + "codex_resume_here", +) +# Both palettes: the eight names Claude Code's /color accepts and the six +# Codex-only ones, in palette order. Mirrors bin/session-kit's +# codex_theme_names, and tests/test_session_colors.py asserts it still equals +# the palette the runtime declares. +THEME_COLORS = ( + "red", + "blue", + "green", + "yellow", + "purple", + "orange", + "pink", + "cyan", + "lime", + "magenta", + "silver", + "sand", + "sky", + "sea", +) # Suite-wide sandbox belt: harness subprocesses inherit this environment, and # any real bin script they run would otherwise auto-title the developer's live @@ -39,6 +129,18 @@ def run( cwd=cwd, env=merged, input=input_text, + # A child with no input of its own must not inherit the runner's + # stdin. Whether stdin is a terminal is a BRANCH in the shipped code: + # every `[[ ! -t 0 ]]` site takes a different path for a person than + # for a script, and sk_confirm_exact (session_kit_common, the person + # branch that announces and acts vs the scripted branch that requires + # an exact SESSION_KIT_CONFIRM_ID) is the one that bit. Inheriting + # made the launcher pick the branch the assertion measured -- green + # through a pipe, red from a terminal, with the test unchanged. + # DEVNULL is the same not-a-terminal a pipe already gave, chosen here + # instead of inherited. `stdin=None` when `input` is used keeps + # subprocess's own "stdin and input may not both be used" check happy. + stdin=subprocess.DEVNULL if input_text is None else None, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -99,20 +201,22 @@ def write_runtime(repo: Path, version: str) -> str: required = { "lib/session_inventory.py": "# fixture inventory\n", "lib/sessionkit_inventory/__init__.py": "# fixture package\n", + "lib/sessionkit_inventory/colors.py": "# fixture color helpers\n", "lib/sessionkit_inventory/common.py": "# fixture common helpers\n", "lib/sessionkit_inventory/lifecycle.py": "# fixture lifecycle helpers\n", + "lib/sessionkit_inventory/projects.py": "# fixture project helpers\n", "lib/sessionkit_inventory/providers.py": "# fixture provider helpers\n", "lib/sessionkit_inventory/state_io.py": "# fixture state helpers\n", "bashrc/shpool.bashrc": "# fixture bashrc\n", "config/projects.example.tsv": "# alias\tprovider\tcwd\n", "config/session-inventory.example.json": '{"schema_version":1}\n', "config/shpool.example.toml": ( - "session_restore_mode = { lines = 500 }\n" + 'session_restore_mode = "simple"\n' "output_spool_lines = 1000\n" "vt100_output_spool_width = 200\n" ), "shpool/config.toml": ( - "session_restore_mode = { lines = 500 }\n" + 'session_restore_mode = "simple"\n' "output_spool_lines = 1000\n" "vt100_output_spool_width = 200\n" ), @@ -167,6 +271,8 @@ def make_backup_bundle( with tarfile.open(installed, mode="w") as archive: for name in ( f"{home_prefix}/.local/bin/shpool_login", + f"{home_prefix}/.local/bin/shpool_login_tui", + f"{home_prefix}/.local/bin/shpool_login_launcher", f"{home_prefix}/.local/bin/shpool_reaper", f"{home_prefix}/.local/bin/shpool_status", f"{home_prefix}/.local/bin/sp", diff --git a/tests/sweep_sandboxes.py b/tests/sweep_sandboxes.py new file mode 100644 index 0000000..6f806a5 --- /dev/null +++ b/tests/sweep_sandboxes.py @@ -0,0 +1,368 @@ +"""Clear what an interrupted run left behind, before the next run starts. + +Fixtures build their sandbox with `tempfile.TemporaryDirectory(dir=REPO)` and +rely on interpreter shutdown to remove it. That covers a clean exit and a +KeyboardInterrupt; it does not cover SIGKILL, a harness timeout, or a traceback +that keeps the fixture alive. And some suites start a REAL provider CLI inside +the sandbox: two `claude` processes were found alive one day and twenty hours +after the run that spawned them, holding a conversation id with their sandbox +working directory deleted underneath them. The crontab orphan reaper missed +them because it requires `ppid == 1` and these had reparented to +`systemd --user`. + +So the sweep is by sandbox, not by parent: any same-user process whose working +directory is inside a stale sandbox goes with the sandbox. Nothing younger than +the grace period is touched, so a run in flight -- including a parallel one -- +is never disturbed. + +Which names count as a sandbox is READ FROM THE SUITE, never listed here. The +hand-written list this file used to carry named six of the two hundred and +more the suite hands tempfile, so most of what a crashed run leaves stayed +where it was -- including the leaked processes this exists to kill, because it +decides those by sandbox too. A list somebody has to remember to extend is a +list that is wrong from the next commit onward. `sandbox_prefixes()` parses the +test sources instead, and `tests/test_sandbox_sweep.py` fails when a fixture +writes a prefix that parse cannot read. + +Run for its effect, from tests/run. It prints only what it actually removed. +`--list` prints the same judgements and removes nothing. +""" + +from __future__ import annotations + +import ast +import os +from pathlib import Path +import shutil +import signal +import string +import sys +import time + +REPO = Path(__file__).resolve().parents[1] +TESTS = REPO / "tests" + +# The two calls that make a DIRECTORY under a name tempfile chooses. mkstemp +# and NamedTemporaryFile are deliberately absent: they leave a file, and this +# sweep only ever removes directories. +SANDBOX_CALLS = frozenset({"TemporaryDirectory", "mkdtemp"}) + +# The characters tempfile draws the random part of a name from +# (tempfile._RandomNameSequence.characters). A leftover sandbox is a known +# prefix followed by a run of these and nothing else, which is what separates +# `.watchdog-g2hiuui4` from a directory somebody deliberately named +# `.watchdog-notes`. Note the absence of `-`: it keeps a short prefix from +# claiming a longer sibling, so `.reap-` cannot answer for `.reap-er-x1y2`. +RANDOM_NAME_CHARACTERS = frozenset(string.ascii_lowercase + string.digits + "_") + +# Never removed, whatever the derived prefixes say, and checked before any +# prefix is consulted. +# +# `.git` is first because it is the only entry here whose loss cannot be +# undone: every commit not yet pushed lives inside it and nothing in the +# working tree can rebuild it. The other three are the repository's own dotted +# entries at the root -- source, not litter -- and the same argument applies to +# each: a mistake about them is not litter left behind, it is source removed. +# No prefix in the tree reaches any of the four today; this is what holds if +# one ever does, and tests/test_sandbox_sweep.py proves it by feeding the +# matcher a prefix that would swallow `.git`. +# +# This is NOT `tests.support.SOURCE_ROOT_DOTTED` and must never be replaced by +# it. That set answers a different question -- what a COPY of this tree must +# leave behind -- and it deliberately omits `.git`, because a copy carrying +# history is the bug it was written to stop. Two rules with opposite +# requirements for the same name; sharing one constant between them puts a +# deleting rule one edit away from losing the repository. +NEVER_REMOVE = frozenset({".git", ".github", ".gitignore", ".shellcheckrc"}) + +# Long enough that no live run is ever in range, short enough that a leak costs +# one run rather than a week. The override exists so this file can be tested +# against a sandbox created a moment ago. +GRACE_SECONDS = int(os.environ.get("SESSION_KIT_SWEEP_GRACE_SECONDS") or 2 * 3600) + + +def called_name(node: ast.Call) -> str: + """`tempfile.mkdtemp(...)` and `mkdtemp(...)` both answer `mkdtemp`.""" + function = node.func + if isinstance(function, ast.Attribute): + return function.attr + if isinstance(function, ast.Name): + return function.id + return "" + + +def string_literal(expression: ast.expr | None) -> str | None: + if isinstance(expression, ast.Constant) and isinstance(expression.value, str): + return expression.value + return None + + +def string_argument(node: ast.Call, keyword: str, position: int) -> str | None: + """The literal a call passes for `keyword`, by name or at `position`.""" + for entry in node.keywords: + if entry.arg == keyword: + return string_literal(entry.value) + if entry.arg is None: + # `**kwargs` could carry the prefix; nothing here can read it. + return None + if position < len(node.args): + return string_literal(node.args[position]) + return None + + +def parse_test_sources() -> dict[Path, ast.Module]: + """Every test module that parses, by path. + + A file that does not parse is skipped and said out loud rather than taken + as having no sandboxes. Skipping narrows the sweep, which leaves litter; + the alternative reading -- treat an unparseable file as empty and carry on + silently -- is how the previous list went stale unnoticed. + """ + trees: dict[Path, ast.Module] = {} + if not TESTS.is_dir(): + print(f"tests: no {TESTS}, sweeping nothing", file=sys.stderr) + return trees + for path in sorted(TESTS.rglob("*.py")): + try: + source = path.read_text(encoding="utf-8") + trees[path] = ast.parse(source, filename=str(path)) + except (OSError, SyntaxError, UnicodeDecodeError) as error: + print( + f"tests: cannot read sandbox names from {path}: {error}", + file=sys.stderr, + ) + return trees + + +def sandbox_factories(trees: dict[Path, ast.Module]) -> dict[str, tuple[str, int]]: + """Helpers that forward a caller's string straight to tempfile. + + `tests/test_worktree_isolation.sandbox(prefix)` is the shape: one function + the whole file goes through, so the literals sit at its call sites instead + of at the tempfile call. Maps the helper's name to the name and position of + the parameter it forwards, so either spelling of a call site can be read. + Names are collected across the whole tree because helpers are imported + between modules. + """ + factories: dict[str, tuple[str, int]] = {} + for tree in trees.values(): + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + parameters = [argument.arg for argument in node.args.args] + for inner in ast.walk(node): + if not isinstance(inner, ast.Call): + continue + if called_name(inner) not in SANDBOX_CALLS: + continue + for entry in inner.keywords: + if entry.arg != "prefix": + continue + forwarded = entry.value + if isinstance(forwarded, ast.Name) and forwarded.id in parameters: + factories[node.name] = ( + forwarded.id, + parameters.index(forwarded.id), + ) + return factories + + +def sandbox_prefixes() -> frozenset[str]: + """Every prefix the suite can hand tempfile, read from the test sources. + + Call sites are taken whatever their `dir=` argument says. Where a fixture + MEANT to put its sandbox is not a property this file can evaluate -- the + argument is an expression, and several are read from the environment -- and + guessing at it is what a sweep must not do. What makes a removal safe is + the name: a prefix from this set followed by tempfile's random characters + and nothing else, at the repository root, outside `NEVER_REMOVE`. A prefix + belonging to a sandbox that normally lands in /tmp costs nothing here, + because no such directory appears at the root; a fixture that moves its + sandbox INTO the checkout is covered from the same commit that moves it. + """ + trees = parse_test_sources() + factories = sandbox_factories(trees) + found: set[str] = set() + for tree in trees.values(): + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + name = called_name(node) + if name in SANDBOX_CALLS: + # tempfile takes prefix second, after suffix. + prefix = string_argument(node, "prefix", 1) + elif name in factories: + keyword, position = factories[name] + prefix = string_argument(node, keyword, position) + else: + continue + if isinstance(prefix, str) and prefix: + found.add(prefix) + return frozenset(found) + + +def is_a_sandbox_name(name: str, prefixes: frozenset[str]) -> bool: + """Could the suite have created a directory called this? + + Protected names are refused before any prefix is looked at, so widening the + prefix set can never reach one. + """ + if name in NEVER_REMOVE: + return False + for prefix in prefixes: + if not name.startswith(prefix): + continue + random_part = name[len(prefix) :] + if random_part and set(random_part) <= RANDOM_NAME_CHARACTERS: + return True + return False + + +def stale_sandboxes(prefixes: frozenset[str]) -> list[Path]: + cutoff = time.time() - GRACE_SECONDS + found = [] + for entry in REPO.iterdir(): + if not entry.is_dir() or entry.is_symlink(): + continue + if not is_a_sandbox_name(entry.name, prefixes): + continue + # Nothing outside the checkout, ever. iterdir yields only children of + # REPO and the symlinks are already gone, so this cannot point + # elsewhere; it is checked anyway because rmtree is what comes next. + if entry.resolve().parent != REPO: + continue + try: + if entry.stat().st_mtime > cutoff: + continue + except OSError: + continue + found.append(entry) + return sorted(found) + + +def in_a_sandbox(cwd: str, prefixes: frozenset[str]) -> bool: + """Is this working directory inside a fixture sandbox, live or deleted? + + The kernel appends " (deleted)" once the directory is gone, and gone is the + normal case: the fixture's own cleanup removes the sandbox and the process it + started survives with a working directory that no longer exists. That is the + pair the audit found still running after a day and twenty hours. + """ + if cwd.endswith(" (deleted)"): + cwd = cwd[: -len(" (deleted)")] + try: + relative = Path(cwd).relative_to(REPO) + except ValueError: + return False + head = relative.parts[0] if relative.parts else "" + return is_a_sandbox_name(head, prefixes) + + +def process_age_seconds(pid: str) -> float | None: + """How long this process has been running, from its own start time. + + The /proc entry's mtime is not the start time -- the kernel refreshes it -- + so the age comes from field 22 of /proc/<pid>/stat against system uptime. + That is the only age a process whose sandbox is already gone can be judged + by, and the judgement has to be exact: it decides what gets killed. + """ + try: + with open("/proc/uptime", encoding="utf-8") as handle: + uptime = float(handle.read().split()[0]) + with open(f"/proc/{pid}/stat", encoding="utf-8") as handle: + fields = handle.read() + except (OSError, ValueError, IndexError): + return None + # The command name is parenthesised and may contain spaces: split after it. + try: + rest = fields[fields.rindex(")") + 1 :].split() + started_ticks = float(rest[19]) + except (ValueError, IndexError): + return None + ticks = os.sysconf("SC_CLK_TCK") or 100 + return max(0.0, uptime - started_ticks / ticks) + + +def leaked_processes( + stale: list[Path], prefixes: frozenset[str] +) -> list[tuple[int, str]]: + """Same-user pids that cannot belong to a live run, inside a sandbox. + + Two ways to be sure. A process sitting in a sandbox this sweep has already + judged stale goes with it whatever its own age. Otherwise the sandbox is + gone -- the fixture removed it and the process outlived it -- and the + process must be older than the grace period before anything is killed. + """ + uid = os.geteuid() + condemned = [os.fspath(root) for root in stale] + out = [] + for name in os.listdir("/proc"): + if not name.isdigit(): + continue + base = f"/proc/{name}" + try: + if os.stat(base, follow_symlinks=False).st_uid != uid: + continue + cwd = os.readlink(f"{base}/cwd") + except OSError: + continue + if not in_a_sandbox(cwd, prefixes): + continue + plain = cwd[: -len(" (deleted)")] if cwd.endswith(" (deleted)") else cwd + inside_stale = any( + plain == root or plain.startswith(root + os.sep) for root in condemned + ) + if not inside_stale: + age = process_age_seconds(name) + if age is None or age < GRACE_SECONDS: + continue + try: + with open(f"{base}/cmdline", "rb") as handle: + command = handle.read(200).replace(b"\0", b" ").decode( + "utf-8", "replace" + ).strip() + except OSError: + command = "?" + out.append((int(name), command)) + return out + + +def main(argv: list[str] | None = None) -> int: + listing = "--list" in (argv if argv is not None else sys.argv[1:]) + prefixes = sandbox_prefixes() + roots = stale_sandboxes(prefixes) + leaked = leaked_processes(roots, prefixes) + if not roots and not leaked: + return 0 + if listing: + for pid, command in leaked: + print(f"tests: would end leaked process {pid} ({command[:60]})") + for root in roots: + print(f"tests: would remove stale sandbox {root.name}") + return 0 + for pid, command in leaked: + # The process group: a provider CLI runs under `script`, and killing the + # wrapper alone leaves the CLI holding its conversation. + try: + os.killpg(os.getpgid(pid), signal.SIGTERM) + except (OSError, ProcessLookupError): + try: + os.kill(pid, signal.SIGTERM) + except OSError: + pass + print(f"tests: ended leaked process {pid} ({command[:60]})") + if leaked: + time.sleep(0.5) + for pid, _command in leaked: + try: + os.killpg(os.getpgid(pid), signal.SIGKILL) + except (OSError, ProcessLookupError): + pass + for root in roots: + shutil.rmtree(root, ignore_errors=True) + print(f"tests: removed stale sandbox {root.name}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_account_advice_providers.py b/tests/test_account_advice_providers.py new file mode 100644 index 0000000..29f62a3 --- /dev/null +++ b/tests/test_account_advice_providers.py @@ -0,0 +1,316 @@ +"""Rotation advice reaches both providers, not only Claude. + +The roster feed has always carried a ``codex_accounts`` list, but the advice +beside it was read only when the caller asked about Claude. A Codex account +could be enabled, healthy, and serving and still never be recommended, the +audit finding these tests pin down. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +from lib.sessionkit_inventory import accounts + + +NOW = 1_800_000_000 + + +class ProviderAdviceTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="session-kit-advice.") + self.root = Path(self.temporary.name) + self.home = self.root / "home" + self.state = self.root / "state" + self.data = self.root / "data" + for path in (self.home, self.state, self.data): + path.mkdir(mode=0o700) + self.profile_root = self.data / "session-kit" / "accounts" + self.roster = self.root / "roster.json" + self.advice = self.root / "advice.json" + self.config = {"state_dir": str(self.state)} + self.environment = mock.patch.dict( + os.environ, + { + "HOME": str(self.home), + "XDG_DATA_HOME": str(self.data), + "SESSION_KIT_ACCOUNT_REGISTRY": str(self.state / "accounts.json"), + "SESSION_KIT_ACCOUNT_ROOT": str(self.profile_root), + "SESSION_KIT_ACCOUNT_ROSTER": str(self.roster), + "SESSION_KIT_ROTATION_ADVICE": str(self.advice), + }, + clear=False, + ) + self.environment.start() + os.environ.pop("SESSION_KIT_ACCOUNT_ADVICE_MAX_AGE_SECONDS", None) + self.seed() + self.write_roster() + + def tearDown(self) -> None: + self.environment.stop() + self.temporary.cleanup() + + def seed(self) -> None: + profiles = {} + for provider, alias, email in ( + ("claude", "one", "one@invalid.example"), + ("claude", "two", "two@invalid.example"), + ("codex", "three", "three@invalid.example"), + ("codex", "four", "four@invalid.example"), + ): + profile_dir = self.profile_root / provider / alias + profile_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + profiles[f"{provider}:{alias}"] = { + "provider": provider, + "alias": alias, + "email": email, + "profile_dir": str(profile_dir), + "legacy": False, + "plan": "max" if provider == "claude" else "plus", + "verified_at_unix_ms": 1_800_000_000_000, + "enabled": True, + } + accounts.write_registry( + self.config, + { + "schema_version": accounts.ACCOUNT_SCHEMA_VERSION, + "generation": 1, + "profiles": profiles, + "bindings": {}, + }, + ) + + def write_roster(self) -> None: + healthy = {"health": "ok", "serving": True} + self.write( + self.roster, + { + "ts": NOW, + "accounts": [ + dict(healthy, email="one@invalid.example"), + dict(healthy, email="two@invalid.example"), + ], + "codex_accounts": [ + dict(healthy, email="three@invalid.example"), + dict(healthy, email="four@invalid.example"), + ], + }, + ) + + def write(self, path: Path, value: object) -> None: + path.write_text(json.dumps(value), encoding="utf-8") + path.chmod(0o600) + + def choices(self, provider: str) -> dict: + with mock.patch.object(accounts.time, "time", return_value=NOW): + return accounts.account_choices(self.config, provider) + + def test_a_resting_account_with_a_healthy_probe_is_selectable(self) -> None: + """`serving` is the cron-lane toggle, never an interactive gate.""" + self.write( + self.roster, + { + "ts": NOW, + "accounts": [ + {"health": "ok", "serving": False, "email": "one@invalid.example", + "u5h": 0.34, "u7d": 0.36}, + {"health": "ok", "serving": True, "email": "two@invalid.example"}, + ], + "codex_accounts": [], + }, + ) + rows = {row["alias"]: row for row in self.choices("claude")["choices"]} + self.assertTrue(rows["one"]["eligible"]) + self.assertEqual("ready", rows["one"]["state"]) + self.assertFalse(rows["one"]["serving"]) + self.assertTrue(rows["two"]["eligible"]) + + def test_a_failed_probe_still_blocks_and_names_itself(self) -> None: + self.write( + self.roster, + { + "ts": NOW, + "accounts": [ + {"health": "expired", "serving": True, "email": "one@invalid.example"}, + ], + "codex_accounts": [], + }, + ) + rows = {row["alias"]: row for row in self.choices("claude")["choices"]} + self.assertFalse(rows["one"]["eligible"]) + self.assertEqual("blocked: health expired", rows["one"]["state"]) + self.assertEqual( + "blocked: not in the account roster", rows["two"]["state"] + ) + + def test_a_stale_or_absent_roster_fails_open_to_the_registry(self) -> None: + """A dead feed process must never brick session creation.""" + self.write( + self.roster, + {"ts": NOW - 3600, "accounts": [], "codex_accounts": []}, + ) + payload = self.choices("claude") + self.assertFalse(payload["roster_fresh"]) + self.assertEqual("stale", payload["roster_state"]) + for row in payload["choices"]: + self.assertTrue(row["eligible"]) + self.assertEqual("ready (feed stale)", row["state"]) + self.assertEqual("unverified", row["health"]) + self.roster.unlink() + payload = self.choices("claude") + self.assertEqual("absent", payload["roster_state"]) + self.assertTrue(all(row["eligible"] for row in payload["choices"])) + + def test_a_blocked_advised_account_is_reported_not_silently_dropped(self) -> None: + self.write( + self.roster, + { + "ts": NOW, + "accounts": [ + {"health": "error", "serving": True, "email": "one@invalid.example"}, + {"health": "ok", "serving": True, "email": "two@invalid.example"}, + ], + "codex_accounts": [], + }, + ) + self.write( + self.advice, + {"ts": NOW, "use_now": {"account": "one@invalid.example", "why": "soonest reset"}}, + ) + payload = self.choices("claude") + self.assertIsNone(payload["recommendation"]) + self.assertEqual("", payload["recommendation_reason"]) + self.assertEqual( + {"alias": "one", "state": "blocked: health error"}, + payload["recommendation_blocked"], + ) + + def test_a_provider_keyed_recommendation_reaches_codex(self) -> None: + self.write( + self.advice, + { + "ts": NOW, + "use_now": {"account": "one@invalid.example", "why": "claude pick"}, + "use_now_codex": { + "account": "four@invalid.example", + "why": "codex pick", + }, + }, + ) + + claude = self.choices("claude") + codex = self.choices("codex") + + self.assertEqual("one", claude["recommendation"]) + self.assertEqual("claude pick", claude["recommendation_reason"]) + self.assertEqual("four", codex["recommendation"]) + self.assertEqual("codex pick", codex["recommendation_reason"]) + + def test_a_nested_providers_block_works_too(self) -> None: + self.write( + self.advice, + { + "ts": NOW, + "providers": { + "codex": { + "use_now": { + "account": "three@invalid.example", + "why": "most left", + } + } + }, + }, + ) + + codex = self.choices("codex") + + self.assertEqual("three", codex["recommendation"]) + self.assertEqual("most left", codex["recommendation_reason"]) + + def test_the_legacy_top_level_key_stays_claude_only(self) -> None: + """A single-provider feed means Claude; reading it for Codex would + recommend a Claude account for a Codex session.""" + self.write( + self.advice, + {"ts": NOW, "use_now": {"account": "one@invalid.example", "why": "x"}}, + ) + + self.assertEqual("one", self.choices("claude")["recommendation"]) + self.assertIsNone(self.choices("codex")["recommendation"]) + + def test_the_reason_is_read_under_either_spelling(self) -> None: + """Feeds ship ``reason``; the reader only accepted ``why``, so every + recommendation arrived with a blank explanation.""" + self.write( + self.advice, + { + "ts": NOW, + "use_now": { + "account": "two@invalid.example", + "reason": "soonest weekly reset with most unused", + }, + }, + ) + + claude = self.choices("claude") + + self.assertEqual("two", claude["recommendation"]) + self.assertEqual( + "soonest weekly reset with most unused", claude["recommendation_reason"] + ) + + def test_stale_advice_recommends_nobody_for_either_provider(self) -> None: + self.write( + self.advice, + { + "ts": NOW - 86_400, + "use_now": {"account": "one@invalid.example", "why": "old"}, + "use_now_codex": {"account": "three@invalid.example", "why": "old"}, + }, + ) + + self.assertIsNone(self.choices("claude")["recommendation"]) + self.assertIsNone(self.choices("codex")["recommendation"]) + self.assertFalse(self.choices("codex")["advice_fresh"]) + + def test_advice_naming_an_unhealthy_account_does_not_recommend_it(self) -> None: + self.write( + self.roster, + { + "ts": NOW, + "codex_accounts": [ + { + "email": "three@invalid.example", + "health": "expired", + "serving": False, + } + ], + }, + ) + self.write( + self.advice, + { + "ts": NOW, + "use_now_codex": {"account": "three@invalid.example", "why": "stale"}, + }, + ) + + codex = self.choices("codex") + + self.assertIsNone(codex["recommendation"]) + self.assertFalse(any(row["recommended"] for row in codex["choices"])) + + def test_a_malformed_advice_block_is_ignored_rather_than_crashing(self) -> None: + self.write(self.advice, {"ts": NOW, "use_now_codex": ["not", "an", "object"]}) + + self.assertIsNone(self.choices("codex")["recommendation"]) + self.assertEqual("", self.choices("codex")["recommendation_reason"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_account_auto_switch.py b/tests/test_account_auto_switch.py new file mode 100644 index 0000000..259e221 --- /dev/null +++ b/tests/test_account_auto_switch.py @@ -0,0 +1,2507 @@ +"""The rule that stops one conversation spending every account. + +Each test here is a fact the operator asked for in their own words: a dry +account moves a conversation once, never twice; a move never lands in an +account below the reserve; nothing moves on facts that are stale; the kill +switch stops everything; a working conversation is never moved; and they are +told afterwards exactly once. + +Every number in these tests comes from a fixture file, never from a live +account. No test in this file logs in, enrols, verifies, or switches anything +real. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import tempfile +import time +import unittest +from unittest import mock + +from lib.sessionkit_inventory import account_guard as guard +from lib.sessionkit_inventory import accounts +from lib.sessionkit_inventory.common import CollectionError + +REPO = Path(__file__).resolve().parents[1] +UUID = "00000000-0000-4000-8000-0000000000a1" +OTHER_UUID = "00000000-0000-4000-8000-0000000000b2" +# Distinguishes "no row was passed" from "the row is deliberately None", which +# is itself one of the shapes that must be refused. +DEFAULT_ROW = object() + + +def session_row( + *, + provider: str = "claude", + uuid: str = UUID, + status: str = "idle", + subagents: object = None, + active: int = 0, + mutation: bool = True, + age: object = 900, + alias: object = "spent", + mismatch: object = False, + model: str = "", + model_handoff: bool = True, +) -> dict[str, object]: + """One live inventory row, shaped as the collector publishes it. + + `alias` and `mismatch` are the collector's own account fields. They + default to agreeing with the fixtures' bound source, because the + interesting cases are the ones where they disagree. + """ + row: dict[str, object] = { + "shpool_id_raw": "main", + "provider": provider, + "identity": {"uuid": uuid}, + "mutation_allowed": mutation, + "subagents": subagents, + "active_subagent_count": active, + "agent_status": status, + "recent_output_age_seconds": age, + "account_binding_mismatch": mismatch, + "model": model, + "model_handoff_capable": model_handoff, + } + if alias is not None: + row["account_alias"] = alias + return row + + +class AutomaticSwitchPolicyTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="session-kit-auto-switch.") + self.root = Path(self.temporary.name) + self.home = self.root / "home" + self.state = self.root / "state" + self.data = self.root / "data" + for path in (self.home, self.state, self.data): + path.mkdir(mode=0o700) + self.registry = self.state / "accounts.json" + self.profile_root = self.data / "session-kit" / "accounts" + self.roster = self.root / "cli_accounts.json" + self.advice = self.root / "rotation_advice.json" + self.config = {"state_dir": str(self.state)} + refusing_bin = refusing_commands_bin(self.root) + self.environment = mock.patch.dict( + os.environ, + { + "HOME": str(self.home), + "XDG_DATA_HOME": str(self.data), + "XDG_STATE_HOME": str(self.root), + "XDG_CONFIG_HOME": str(self.root), + # Every call here passes `config` explicitly, so nothing should + # consult these. They are set anyway: the cost is nothing and + # it removes the question of whether some future code path + # falls back to the operator's own state. + "SESSION_KIT_STATE_DIR": str(self.state), + "SESSION_KIT_ACCOUNT_REGISTRY": str(self.registry), + "SESSION_KIT_ACCOUNT_ROOT": str(self.profile_root), + "SESSION_KIT_ACCOUNT_ROSTER": str(self.roster), + "SESSION_KIT_ROTATION_ADVICE": str(self.advice), + "SESSION_KIT_CONFIG": str(self.root / "session-kit.json"), + "SESSION_KIT_SHPOOL_CMD": str(refusing_bin / "shpool"), + "PATH": f"{refusing_bin}:{os.environ.get('PATH', '')}", + "SESSION_KIT_NONINTERACTIVE": "1", + "SESSION_KIT_BACKGROUND": "1", + }, + clear=False, + ) + self.environment.start() + for name in ( + "SESSION_KIT_ACCOUNT_ADVICE_MAX_AGE_SECONDS", + "SESSION_KIT_ACCOUNT_RESERVE_PERCENT", + "SESSION_KIT_ACCOUNT_EXHAUSTED_PERCENT", + ): + os.environ.pop(name, None) + self.now = 1_800_000_000 + + def tearDown(self) -> None: + self.environment.stop() + self.temporary.cleanup() + + # -- fixture builders ------------------------------------------------- + + def seed_profiles(self, *rows: tuple[str, str]) -> None: + profiles = {} + for alias, email in rows: + profile_dir = self.profile_root / "claude" / alias + profile_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + state_path = profile_dir / ".claude.json" + state_path.write_text( + json.dumps({"hasCompletedOnboarding": True}), encoding="utf-8" + ) + state_path.chmod(0o600) + profiles[f"claude:{alias}"] = { + "provider": "claude", + "alias": alias, + "email": email, + "profile_dir": str(profile_dir), + "legacy": False, + "plan": "max", + "verified_at_unix_ms": 1_800_000_000_000, + "enabled": True, + } + accounts.write_registry( + self.config, + { + "schema_version": accounts.ACCOUNT_SCHEMA_VERSION, + "generation": 1, + "profiles": profiles, + "bindings": {}, + }, + ) + + def write_feeds( + self, + usage: dict[str, object], + *, + recommend: str = "", + roster_ts: int | None = None, + advice_ts: int | None = None, + health: dict[str, str] | None = None, + ) -> None: + """Publish one roster and one advice file shaped like the real feeds. + + ``usage`` maps email -> (u5h, u7d) exactly as the live + cli_accounts.json publishes them: fractions of the window, or None + where the feed could not read a number. + """ + health = health or {} + rows = [] + for email, pair in usage.items(): + five, week = pair # type: ignore[misc] + rows.append( + { + "email": email, + "enabled": True, + "serving": True, + "health": health.get(email, "ok"), + "u5h": five, + "u7d": week, + } + ) + self.roster.write_text( + json.dumps({"ts": roster_ts if roster_ts is not None else self.now, + "accounts": rows}), + encoding="utf-8", + ) + self.roster.chmod(0o600) + payload: dict[str, object] = { + "ts": advice_ts if advice_ts is not None else self.now + } + if recommend: + payload["use_now"] = { + "account": recommend, + "reason": f"{recommend} has the soonest weekly reset", + } + self.advice.write_text(json.dumps(payload), encoding="utf-8") + self.advice.chmod(0o600) + + def plan(self, row: object = DEFAULT_ROW, **kwargs: object) -> dict: + with mock.patch("time.time", return_value=float(self.now)): + return guard.plan( + self.config, + "claude", + UUID, + session_row() if row is DEFAULT_ROW else row, # type: ignore[arg-type] + **kwargs, # type: ignore[arg-type] + ) + + def standard_estate(self) -> None: + """A spent source and one healthy target, the ordinary trigger case.""" + self.seed_profiles(("spent", "spent@example.com"), ("fresh", "fresh@example.com")) + accounts.bind(self.config, "claude", UUID, "spent", source="test") + self.write_feeds( + {"spent@example.com": (0.9, 1.0), "fresh@example.com": (0.1, 0.20)}, + recommend="fresh@example.com", + ) + + # -- the ordinary move ------------------------------------------------- + + def test_a_spent_account_moves_the_conversation_to_the_advised_account(self) -> None: + self.standard_estate() + + verdict = self.plan() + + self.assertEqual("switch", verdict["action"]) + self.assertEqual("spent", verdict["source_alias"]) + self.assertEqual("fresh", verdict["target_alias"]) + self.assertEqual(0, verdict["hops_used"]) + self.assertEqual(1, verdict["hop_limit"]) + self.assertIn("80%", verdict["reason"]) + + def test_profile_refresh_before_a_refusal_leaves_no_move_or_spend_state(self) -> None: + """The preparation is local and harmless, but it is not rolled back. + + The policy plan has already approved the target before this refresh. + The final read-only eligibility check belongs after it because the + target can be disabled while the provider status probe runs. If that + happens, refreshed verification metadata may remain, but no binding, + transaction, hop reservation, login, or session-manager action does. + """ + self.standard_estate() + before = accounts.load_registry(self.config) + profile_state = self.profile_root / "claude" / "fresh" / ".claude.json" + state_before = profile_state.read_bytes() + + with mock.patch.object( + accounts, + "probe_identity", + return_value={ + "provider": "claude", + "email": "fresh@example.com", + "plan": "max-refreshed", + }, + ) as probe: + accounts.launch_profile(self.config, "claude", "fresh") + + probe.assert_called_once_with("claude", self.profile_root / "claude" / "fresh") + refreshed = accounts.load_registry(self.config) + self.assertEqual(before["bindings"], refreshed["bindings"]) + self.assertEqual(set(before["profiles"]), set(refreshed["profiles"])) + self.assertTrue(refreshed["profiles"]["claude:fresh"]["enabled"]) + self.assertEqual("max-refreshed", refreshed["profiles"]["claude:fresh"]["plan"]) + self.assertGreater(refreshed["generation"], before["generation"]) + self.assertEqual(state_before, profile_state.read_bytes()) + + # This is the exact gap: the operator disables the target after the + # refresh and the final check refuses it without preparing a move. + refreshed["profiles"]["claude:fresh"]["enabled"] = False + refreshed["generation"] += 1 + accounts.write_registry(self.config, refreshed) + recheck = guard.target_still_eligible(self.config, "claude", "fresh") + self.assertFalse(recheck["eligible"]) + self.assertEqual(before["bindings"], accounts.load_registry(self.config)["bindings"]) + self.assertFalse((self.state / "account-switches").exists()) + self.assertFalse(guard.hop_ledger_path(self.config).exists()) + + def test_a_source_with_quota_left_is_never_moved(self) -> None: + self.seed_profiles(("spent", "spent@example.com"), ("fresh", "fresh@example.com")) + accounts.bind(self.config, "claude", UUID, "spent", source="test") + self.write_feeds( + {"spent@example.com": (0.4, 0.80), "fresh@example.com": (0.1, 0.20)}, + recommend="fresh@example.com", + ) + + verdict = self.plan() + + self.assertEqual("hold", verdict["action"]) + self.assertEqual("source_has_quota", verdict["reason_code"]) + self.assertIn("20%", verdict["reason"]) + + # -- one hop only ------------------------------------------------------ + + def test_the_second_exhaustion_stops_and_reports_instead_of_a_third_account( + self, + ) -> None: + self.seed_profiles( + ("first", "first@example.com"), + ("second", "second@example.com"), + ("third", "third@example.com"), + ) + accounts.bind(self.config, "claude", UUID, "second", source="test") + guard.begin_hop( + self.config, "claude", UUID, "first", "second", now=self.now - 60 + ) + self.write_feeds( + { + "first@example.com": (1.0, 1.0), + "second@example.com": (1.0, 1.0), + "third@example.com": (0.0, 0.05), + }, + recommend="third@example.com", + ) + + verdict = self.plan() + + self.assertEqual("hold", verdict["action"]) + self.assertEqual("hop_limit", verdict["reason_code"]) + self.assertEqual(1, verdict["hops_used"]) + self.assertEqual("", verdict["target_alias"]) + self.assertTrue(verdict["needs_attention"]) + # A third account exists, is healthy and nearly untouched, and is still + # not offered: the limit is the point, not the shortage. + self.assertEqual([], verdict["candidates"]) + self.assertIn("waits for you", verdict["reason"]) + + def test_one_hop_is_counted_per_conversation_not_across_the_estate(self) -> None: + self.standard_estate() + guard.begin_hop( + self.config, "claude", OTHER_UUID, "a", "b", now=self.now - 60 + ) + + self.assertEqual(0, guard.hop_count(self.config, "claude", UUID)) + self.assertEqual(1, guard.hop_count(self.config, "claude", OTHER_UUID)) + self.assertEqual("switch", self.plan()["action"]) + + def test_an_unreadable_hop_ledger_holds_rather_than_counting_zero(self) -> None: + self.standard_estate() + guard.hop_ledger_path(self.config).write_text("{not json", encoding="utf-8") + guard.hop_ledger_path(self.config).chmod(0o600) + + verdict = self.plan() + + self.assertEqual("hold", verdict["action"]) + self.assertEqual("ledger_unreadable", verdict["reason_code"]) + + # -- the protected reserve --------------------------------------------- + + def test_the_only_candidate_is_refused_when_it_is_below_the_reserve(self) -> None: + self.seed_profiles(("spent", "spent@example.com"), ("low", "low@example.com")) + accounts.bind(self.config, "claude", UUID, "spent", source="test") + # 80% of the weekly window already spent: 20% left, under the 25% floor. + self.write_feeds( + {"spent@example.com": (0.9, 1.0), "low@example.com": (0.0, 0.80)}, + recommend="low@example.com", + ) + + verdict = self.plan() + + self.assertEqual("hold", verdict["action"]) + self.assertEqual("no_eligible_account", verdict["reason_code"]) + self.assertEqual([], verdict["candidates"]) + self.assertTrue(verdict["stood_down"]) + self.assertIn("25% reserve", verdict["reason"]) + + def test_the_reserve_line_itself_is_allowed_and_one_point_past_it_is_not( + self, + ) -> None: + self.seed_profiles(("spent", "spent@example.com"), ("edge", "edge@example.com")) + accounts.bind(self.config, "claude", UUID, "spent", source="test") + + self.write_feeds( + {"spent@example.com": (0.9, 1.0), "edge@example.com": (0.0, 0.75)}, + recommend="edge@example.com", + ) + self.assertEqual("switch", self.plan()["action"]) + + self.write_feeds( + {"spent@example.com": (0.9, 1.0), "edge@example.com": (0.0, 0.7501)}, + recommend="edge@example.com", + ) + self.assertEqual("hold", self.plan()["action"]) + + def test_the_reserve_is_configurable_and_a_bad_value_keeps_the_default(self) -> None: + self.seed_profiles(("spent", "spent@example.com"), ("edge", "edge@example.com")) + accounts.bind(self.config, "claude", UUID, "spent", source="test") + self.write_feeds( + {"spent@example.com": (0.9, 1.0), "edge@example.com": (0.0, 0.60)}, + recommend="edge@example.com", + ) + + self.assertEqual("switch", self.plan()["action"]) + self.assertEqual( + "hold", + self.plan(environ={"SESSION_KIT_ACCOUNT_RESERVE_PERCENT": "50"})["action"], + ) + for bad in ("", "half", "-1", "101"): + with self.subTest(value=bad): + verdict = self.plan( + environ={"SESSION_KIT_ACCOUNT_RESERVE_PERCENT": bad} + ) + self.assertEqual(25, verdict["reserve_percent"]) + self.assertEqual("switch", verdict["action"]) + + def test_an_account_with_no_published_weekly_number_is_never_a_target(self) -> None: + self.seed_profiles( + ("spent", "spent@example.com"), ("unknown", "unknown@example.com") + ) + accounts.bind(self.config, "claude", UUID, "spent", source="test") + self.write_feeds( + {"spent@example.com": (0.9, 1.0), "unknown@example.com": (None, None)}, + recommend="unknown@example.com", + ) + + verdict = self.plan() + + self.assertEqual("hold", verdict["action"]) + self.assertEqual("no_eligible_account", verdict["reason_code"]) + + def test_a_usage_number_that_is_not_a_fraction_is_treated_as_unreadable( + self, + ) -> None: + """A feed that switched to whole percents must not widen the reserve. + + `u7d: 80` read as a fraction is 8000% used. Believing it would call + every account exhausted and refuse every target -- but the same + mistake in the other direction (`u7d: 0.8` published as `80` while the + code still divides) is how a floor silently stops existing. Anything + outside the credible range is unknown, and unknown never moves. + """ + self.seed_profiles(("spent", "spent@example.com"), ("odd", "odd@example.com")) + accounts.bind(self.config, "claude", UUID, "spent", source="test") + + # A nonsense number anywhere in the feed stops the move outright. + self.write_feeds( + {"spent@example.com": (0.9, 1.0), "odd@example.com": (0.0, 20)}, + recommend="odd@example.com", + ) + self.assertEqual("feed_implausible", self.plan()["reason_code"]) + + # Including on the source, which is then never called dry either. + self.write_feeds( + {"spent@example.com": (0.9, 80), "odd@example.com": (0.0, 0.05)}, + recommend="odd@example.com", + ) + self.assertEqual("feed_implausible", self.plan()["reason_code"]) + + def test_an_unreadable_five_hour_number_blocks_the_move(self) -> None: + """Finding 5: unreadable must mean "do not move", never "no objection". + + An implausible five-hour number was classed as unreadable and then + skipped by a check that only fired on a real number, so a target whose + short window was published as spent still took the one hop. It is now + refused, and refused at the level of the whole feed: the units that + produced one impossible number produced all the others too. + """ + self.seed_profiles(("spent", "spent@example.com"), ("odd", "odd@example.com")) + accounts.bind(self.config, "claude", UUID, "spent", source="test") + for bad in (100, -1, "0.5", True, float("nan")): + with self.subTest(u5h=bad): + self.write_feeds( + {"spent@example.com": (0.9, 1.0), "odd@example.com": (bad, 0.05)}, + recommend="odd@example.com", + ) + verdict = self.plan() + self.assertEqual("hold", verdict["action"]) + self.assertEqual("feed_implausible", verdict["reason_code"]) + self.assertEqual("", verdict["target_alias"]) + + def test_one_impossible_number_condemns_the_whole_feed(self) -> None: + """A units change is only visible across the roster, not in one row. + + If the feed switched to whole percents, `55` is obviously not a + fraction, but `0`, `1` and `2` still parse as 0%, 100% and 200% used, + so judging the other accounts row by row would move a conversation + into whatever account happened to be published as `0`. + """ + self.seed_profiles( + ("spent", "spent@example.com"), + ("wholepct", "wholepct@example.com"), + ("loud", "loud@example.com"), + ) + accounts.bind(self.config, "claude", UUID, "spent", source="test") + self.write_feeds( + { + # A whole-percent feed: the source reads as spent, the + # candidate reads as untouched, and one row gives it away. + "spent@example.com": (90, 100), + "wholepct@example.com": (0, 0), + "loud@example.com": (0, 55), + }, + recommend="wholepct@example.com", + ) + + verdict = self.plan() + + self.assertEqual("hold", verdict["action"]) + self.assertEqual("feed_implausible", verdict["reason_code"]) + self.assertTrue(verdict["needs_attention"]) + self.assertEqual("", verdict["target_alias"]) + + def test_an_implausible_feed_also_stops_the_cheap_first_question(self) -> None: + self.seed_profiles( + ("spent", "spent@example.com"), ("wholepct", "wholepct@example.com") + ) + accounts.bind(self.config, "claude", UUID, "spent", source="test") + self.write_feeds( + {"spent@example.com": (90, 100), "wholepct@example.com": (0, 0)}, + recommend="wholepct@example.com", + ) + + with mock.patch("time.time", return_value=float(self.now)): + spent = guard.spent_aliases(self.config, "claude") + + self.assertEqual([], spent["aliases"]) + self.assertIs(False, spent["fresh"]) + + def test_a_missing_five_hour_number_is_not_an_objection(self) -> None: + """Codex publishes no five-hour number at all; absent stays usable.""" + self.seed_profiles(("spent", "spent@example.com"), ("quiet", "quiet@example.com")) + accounts.bind(self.config, "claude", UUID, "spent", source="test") + self.write_feeds( + {"spent@example.com": (0.9, 1.0), "quiet@example.com": (None, 0.05)}, + recommend="quiet@example.com", + ) + + self.assertEqual("switch", self.plan()["action"]) + + def test_an_account_whose_five_hour_window_is_spent_is_not_a_target(self) -> None: + self.seed_profiles(("spent", "spent@example.com"), ("walled", "walled@example.com")) + accounts.bind(self.config, "claude", UUID, "spent", source="test") + self.write_feeds( + {"spent@example.com": (0.9, 1.0), "walled@example.com": (1.0, 0.10)}, + recommend="walled@example.com", + ) + + self.assertEqual("no_eligible_account", self.plan()["reason_code"]) + + def test_the_final_recheck_refuses_a_new_five_hour_wall(self) -> None: + """The target can spend its short window while its profile is probed.""" + self.standard_estate() + self.assertEqual("switch", self.plan()["action"]) + self.write_feeds( + {"spent@example.com": (0.9, 1.0), "fresh@example.com": (1.0, 0.20)}, + recommend="fresh@example.com", + ) + + with mock.patch("time.time", return_value=float(self.now)): + recheck = guard.target_still_eligible(self.config, "claude", "fresh") + + self.assertIs(False, recheck["eligible"]) + self.assertIn("five-hour window is spent", recheck["reason"]) + + def test_the_final_recheck_refuses_a_new_unreadable_usage_figure(self) -> None: + self.standard_estate() + self.assertEqual("switch", self.plan()["action"]) + self.write_feeds( + {"spent@example.com": (0.9, 1.0), "fresh@example.com": (100, 0.20)}, + recommend="fresh@example.com", + ) + + with mock.patch("time.time", return_value=float(self.now)): + recheck = guard.target_still_eligible(self.config, "claude", "fresh") + + self.assertIs(False, recheck["eligible"]) + self.assertIn("not a window fraction", recheck["reason"]) + + def test_a_blocked_account_is_never_a_target_however_strong_the_advice(self) -> None: + self.seed_profiles(("spent", "spent@example.com"), ("sick", "sick@example.com")) + accounts.bind(self.config, "claude", UUID, "spent", source="test") + self.write_feeds( + {"spent@example.com": (0.9, 1.0), "sick@example.com": (0.0, 0.05)}, + recommend="sick@example.com", + health={"sick@example.com": "expired"}, + ) + + self.assertEqual("no_eligible_account", self.plan()["reason_code"]) + + def test_the_advised_account_wins_over_a_merely_emptier_one(self) -> None: + self.seed_profiles( + ("spent", "spent@example.com"), + ("advised", "advised@example.com"), + ("emptier", "emptier@example.com"), + ) + accounts.bind(self.config, "claude", UUID, "spent", source="test") + self.write_feeds( + { + "spent@example.com": (0.9, 1.0), + "advised@example.com": (0.0, 0.40), + "emptier@example.com": (0.0, 0.05), + }, + recommend="advised@example.com", + ) + + self.assertEqual("advised", self.plan()["target_alias"]) + + # -- no fact, no move -------------------------------------------------- + + def test_a_stale_usage_feed_disables_switching(self) -> None: + self.standard_estate() + self.write_feeds( + {"spent@example.com": (0.9, 1.0), "fresh@example.com": (0.1, 0.20)}, + recommend="fresh@example.com", + roster_ts=self.now - 4000, + ) + + verdict = self.plan() + + self.assertEqual("hold", verdict["action"]) + self.assertEqual("feed_stale", verdict["reason_code"]) + self.assertIn("account usage", verdict["reason"]) + + def test_stale_rotation_advice_disables_switching(self) -> None: + self.standard_estate() + self.write_feeds( + {"spent@example.com": (0.9, 1.0), "fresh@example.com": (0.1, 0.20)}, + recommend="fresh@example.com", + advice_ts=self.now - 4000, + ) + + verdict = self.plan() + + self.assertEqual("hold", verdict["action"]) + self.assertEqual("feed_stale", verdict["reason_code"]) + self.assertIn("rotation advice", verdict["reason"]) + + def test_a_missing_feed_disables_switching(self) -> None: + self.standard_estate() + self.roster.unlink() + self.advice.unlink() + + verdict = self.plan() + + self.assertEqual("hold", verdict["action"]) + self.assertEqual("feed_stale", verdict["reason_code"]) + + def test_a_source_with_no_published_usage_is_never_called_dry(self) -> None: + self.seed_profiles(("spent", "spent@example.com"), ("fresh", "fresh@example.com")) + accounts.bind(self.config, "claude", UUID, "spent", source="test") + self.write_feeds( + {"spent@example.com": (None, None), "fresh@example.com": (0.1, 0.20)}, + recommend="fresh@example.com", + health={"spent@example.com": "expired"}, + ) + + verdict = self.plan() + + self.assertEqual("hold", verdict["action"]) + self.assertEqual("usage_unknown", verdict["reason_code"]) + + def test_an_unbound_conversation_is_never_moved(self) -> None: + self.seed_profiles(("spent", "spent@example.com"), ("fresh", "fresh@example.com")) + self.write_feeds( + {"spent@example.com": (0.9, 1.0), "fresh@example.com": (0.1, 0.20)}, + recommend="fresh@example.com", + ) + + verdict = self.plan() + + self.assertEqual("hold", verdict["action"]) + self.assertEqual("source_unknown", verdict["reason_code"]) + + # -- the kill switch --------------------------------------------------- + + def test_the_shipped_kill_switch_stops_every_automatic_move(self) -> None: + self.standard_estate() + killer = accounts.kill_switch_path(self.config) + killer.write_text("", encoding="utf-8") + + verdict = self.plan() + + self.assertEqual("hold", verdict["action"]) + self.assertEqual("kill_switch", verdict["reason_code"]) + self.assertTrue(guard.automatic_switching_off(self.config)) + self.assertEqual( + str(self.state / "account-switching-off"), str(killer) + ) + + def test_the_kill_switch_outranks_a_dry_account(self) -> None: + self.standard_estate() + accounts.kill_switch_path(self.config).write_text("", encoding="utf-8") + + self.assertEqual("kill_switch", self.plan()["reason_code"]) + + # -- never move working conversations ----------------------------------- + + def test_a_mid_turn_conversation_is_never_moved(self) -> None: + self.standard_estate() + + verdict = self.plan(session_row(status="running")) + + self.assertEqual("hold", verdict["action"]) + self.assertEqual("session_busy", verdict["reason_code"]) + self.assertIn("running", verdict["reason"]) + + def test_every_unsafe_session_shape_is_refused(self) -> None: + self.standard_estate() + unsafe = { + "running": session_row(status="running"), + "unknown status": session_row(status="unknown"), + "sub-agents listed": session_row(subagents=[{"pid": 5}]), + "sub-agents counted": session_row(active=2), + "not mutable": session_row(mutation=False), + "just printed": session_row(age=1), + "wrong uuid": session_row(uuid=OTHER_UUID), + "wrong provider": session_row(provider="codex"), + "missing row": None, + } + for label, row in unsafe.items(): + with self.subTest(shape=label): + verdict = self.plan(row) + self.assertEqual("hold", verdict["action"]) + self.assertEqual("session_busy", verdict["reason_code"]) + + def test_the_live_account_must_be_the_account_that_ran_dry(self) -> None: + """Finding 8: the binding is a record; the live row is the fact. + + A conversation whose process is signed in to `fresh` while the binding + still says `spent` was restarted and handed to `fresh`, a move made + on paperwork, against a conversation whose real account was never the + one found dry. + """ + self.standard_estate() + + mismatched = self.plan(session_row(alias="fresh", mismatch=True)) + self.assertEqual("hold", mismatched["action"]) + self.assertEqual("session_busy", mismatched["reason_code"]) + self.assertIn("does not match", mismatched["reason"]) + + wrong_alias = self.plan(session_row(alias="fresh")) + self.assertEqual("hold", wrong_alias["action"]) + self.assertIn("signed in to fresh", wrong_alias["reason"]) + + unknown_alias = self.plan(session_row(alias=None)) + self.assertEqual("hold", unknown_alias["action"]) + self.assertIn("could not be read", unknown_alias["reason"]) + + def test_a_conversation_on_a_named_model_can_move_without_downgrade(self) -> None: + """Finding 10: a requested model no longer blocks a safe handoff.""" + self.standard_estate() + + verdict = self.plan(session_row(model="claude-opus-5")) + + self.assertEqual("switch", verdict["action"]) + self.assertEqual("switch", self.plan(session_row(model=""))["action"]) + + unbound = self.plan( + session_row(model="claude-opus-5", model_handoff=False) + ) + self.assertEqual("hold", unbound["action"]) + self.assertIn("not bound", unbound["reason"]) + + def test_every_safe_session_shape_is_allowed(self) -> None: + self.standard_estate() + for status in ("idle", "Needs your reply", "reply optional"): + with self.subTest(status=status): + self.assertEqual( + "switch", self.plan(session_row(status=status))["action"] + ) + self.assertEqual("switch", self.plan(session_row(age=None))["action"]) + + def test_the_automatic_path_uses_the_manual_switch_safety_predicates(self) -> None: + """The two copies of the safety rule must not drift apart. + + sp_picker.sh's account_switch_stable_snapshot is the authority the + manual switch runs; this module mirrors it so a plan can refuse early + with a readable sentence. If either side gains or loses a predicate + without the other, this fails. + """ + picker = (REPO / "lib" / "sh" / "sp_picker.sh").read_text(encoding="utf-8") + start = picker.index("account_switch_stable_snapshot()") + block = picker[start : picker.index("account_switch_safe_tree()")] + for status in guard.MOVABLE_AGENT_STATUS: + self.assertIn(f'"{status}"', block) + for field in ( + "mutation_allowed", + "subagents", + "active_subagent_count", + "agent_status", + "recent_output_age_seconds", + ): + self.assertIn(field, block) + self.assertIn(f"age < {guard.MIN_QUIET_SECONDS}", block) + + # -- told afterwards, exactly once -------------------------------------- + + def test_a_move_is_recorded_and_queued_for_the_operator_exactly_once(self) -> None: + self.standard_estate() + + token = guard.begin_hop( + self.config, "claude", UUID, "spent", "fresh", reason="dry", now=self.now + ) + guard.commit_hop(self.config, "claude", UUID, token) + queued = [ + guard.queue_notice(self.config, "claude", UUID, token, "it moved") + for _ in range(4) + ] + + self.assertEqual([True, False, False, False], queued) + ledger = guard.load_hop_ledger(self.config)["threads"]["claude:" + UUID] + self.assertEqual(1, len(ledger["hops"])) + self.assertEqual(1, ledger["count"]) + self.assertEqual("spent", ledger["hops"][0]["from"]) + self.assertEqual("fresh", ledger["hops"][0]["to"]) + self.assertEqual("committed", ledger["hops"][0]["state"]) + self.assertEqual(0o600, guard.hop_ledger_path(self.config).stat().st_mode & 0o777) + + def test_a_notice_stays_owed_until_a_delivery_actually_succeeds(self) -> None: + """Finding 3: the claim belongs to delivery, not to noticing. + + Queuing and then failing to deliver used to file the move as told. + The debt has to outlive a notifier that is down, or the move happened + and nobody ever said so. + """ + self.standard_estate() + token = guard.begin_hop(self.config, "claude", UUID, "spent", "fresh") + guard.queue_notice(self.config, "claude", UUID, token, "it moved") + + # Delivery never reported success, so it is still owed. + owed = guard.pending_notices(self.config) + self.assertEqual(1, len(owed)) + self.assertEqual(token, owed[0]["token"]) + self.assertEqual("claude", owed[0]["provider"]) + self.assertEqual(UUID, owed[0]["uuid"]) + self.assertEqual("it moved", owed[0]["sentence"]) + + # Still owed after any number of further passes. + self.assertFalse(guard.queue_notice(self.config, "claude", UUID, token, "x")) + self.assertEqual(1, len(guard.pending_notices(self.config))) + + # Only a success clears it, and only once. + self.assertTrue(guard.notice_delivered(self.config, "claude", UUID, token)) + self.assertEqual([], guard.pending_notices(self.config)) + self.assertFalse(guard.notice_delivered(self.config, "claude", UUID, token)) + + def test_a_second_distinct_move_gets_its_own_single_notice(self) -> None: + self.standard_estate() + + self.assertTrue(guard.queue_notice(self.config, "claude", UUID, "t1", "a")) + self.assertFalse(guard.queue_notice(self.config, "claude", UUID, "t1", "a")) + self.assertTrue(guard.queue_notice(self.config, "claude", UUID, "t2", "b")) + self.assertEqual(2, len(guard.pending_notices(self.config))) + + def test_the_ledger_refuses_a_thread_key_that_is_not_an_exact_uuid(self) -> None: + with self.assertRaises(CollectionError): + guard.hop_count(self.config, "claude", "not-a-uuid") + with self.assertRaises(CollectionError): + guard.begin_hop(self.config, "nope", UUID, "a", "b") + + # -- findings 2 and 4: the one-move limit must not fail open ----------- + + def test_a_move_counts_from_the_moment_it_is_reserved(self) -> None: + """Finding 2: a half-finished move must not read as no move. + + The switch used to commit before the ledger was written, so a failure + in between left a conversation that had moved with a zero-hop record, + and the next pass bought it a third account. + """ + self.standard_estate() + + guard.begin_hop(self.config, "claude", UUID, "spent", "fresh") + + # Nothing has been committed, yet the limit already counts it. + self.assertEqual(1, guard.hop_count(self.config, "claude", UUID)) + verdict = self.plan() + self.assertEqual("hold", verdict["action"]) + self.assertEqual("hop_limit", verdict["reason_code"]) + + def test_only_a_proven_return_to_the_source_gives_the_move_back(self) -> None: + self.standard_estate() + token = guard.begin_hop(self.config, "claude", UUID, "spent", "fresh") + self.assertEqual(1, guard.hop_count(self.config, "claude", UUID)) + + self.assertFalse(guard.release_hop(self.config, "claude", UUID, "wrong")) + self.assertEqual(1, guard.hop_count(self.config, "claude", UUID)) + + self.assertTrue(guard.release_hop(self.config, "claude", UUID, token)) + self.assertEqual(0, guard.hop_count(self.config, "claude", UUID)) + self.assertEqual("switch", self.plan()["action"]) + + def test_two_passes_racing_cannot_both_reserve_the_one_move(self) -> None: + """The limit is enforced where the reservation is taken. + + Two drivers can be alive at once, the resident watchdog loop and a + hand-run pass, and both can read a hop count of zero before either + writes. Only one may win; the loser is refused, not granted a second + paid account. + """ + self.standard_estate() + + first = guard.begin_hop(self.config, "claude", UUID, "spent", "fresh") + self.assertTrue(first) + with self.assertRaises(CollectionError) as refused: + guard.begin_hop(self.config, "claude", UUID, "spent", "third") + + self.assertIn("already had its 1 automatic move", str(refused.exception)) + self.assertEqual(1, guard.hop_count(self.config, "claude", UUID)) + + def test_a_committed_move_can_never_be_released(self) -> None: + self.standard_estate() + token = guard.begin_hop(self.config, "claude", UUID, "spent", "fresh") + guard.commit_hop(self.config, "claude", UUID, token) + + # release_hop is only ever called on a proven rollback; this pins that + # a stale token from a completed move cannot un-count it. + self.assertFalse(guard.release_hop(self.config, "claude", UUID, token)) + self.assertEqual(1, guard.hop_count(self.config, "claude", UUID)) + # ...and the count that matters is rebuilt from `count`, not the list. + raw = json.loads(guard.hop_ledger_path(self.config).read_text(encoding="utf-8")) + self.assertEqual(1, raw["threads"]["claude:" + UUID]["count"]) + + def test_a_ledger_too_large_to_read_holds_instead_of_being_sliced(self) -> None: + """Finding 4: a fixed read slice made every later conversation free. + + With 513 conversations the 513th was on disk, invisible to the reader, + and its hop count came back zero. + """ + self.standard_estate() + threads = { + "claude:00000000-0000-4000-8000-%012d" % index: { + "count": 1, + "hops": [{"at_unix": self.now, "from": "a", "to": "b", + "reason": "", "state": "committed", "token": "t%d" % index}], + "notices": [], + } + for index in range(guard.MAX_LEDGER_THREADS + 1) + } + guard.hop_ledger_path(self.config).write_text( + json.dumps({"schema_version": guard.GUARD_SCHEMA_VERSION, + "threads": threads}), + encoding="utf-8", + ) + guard.hop_ledger_path(self.config).chmod(0o600) + + with self.assertRaises(CollectionError): + guard.load_hop_ledger(self.config) + verdict = self.plan() + self.assertEqual("hold", verdict["action"]) + self.assertEqual("ledger_unreadable", verdict["reason_code"]) + + def test_a_large_but_readable_ledger_still_sees_its_last_conversation(self) -> None: + self.standard_estate() + for index in range(600): + guard.begin_hop( + self.config, + "claude", + "00000000-0000-4000-8000-%012d" % index, + "a", + "b", + now=self.now, + ) + guard.begin_hop(self.config, "claude", UUID, "spent", "fresh", now=self.now) + + self.assertEqual(1, guard.hop_count(self.config, "claude", UUID)) + self.assertEqual("hop_limit", self.plan()["reason_code"]) + + def test_a_long_dormant_conversation_keeps_its_one_move_forever(self) -> None: + self.standard_estate() + old = "00000000-0000-4000-8000-0000000000c3" + guard.begin_hop( + self.config, "claude", old, "a", "b", + now=self.now - (10 * 365 * 24 * 60 * 60), + ) + + with self.assertRaises(CollectionError): + guard.begin_hop(self.config, "claude", old, "a", "b", now=self.now) + + guard.begin_hop(self.config, "claude", OTHER_UUID, "a", "b", now=self.now) + threads = guard.load_hop_ledger(self.config)["threads"] + self.assertIn("claude:" + old, threads) + self.assertIn("claude:" + OTHER_UUID, threads) + + def test_an_undelivered_notice_is_never_pruned_away(self) -> None: + self.standard_estate() + stale = "00000000-0000-4000-8000-0000000000d4" + token = guard.begin_hop( + self.config, "claude", stale, "a", "b", + now=self.now - (10 * 365 * 24 * 60 * 60), + ) + guard.queue_notice( + self.config, "claude", stale, token, "an old move", + now=self.now - (10 * 365 * 24 * 60 * 60), + ) + + guard.begin_hop(self.config, "claude", OTHER_UUID, "a", "b", now=self.now) + + owed = [item["uuid"] for item in guard.pending_notices(self.config)] + self.assertIn(stale, owed) + + +def refusing_commands_bin(root: Path) -> Path: + """A PATH entry whose providers and shpool refuse to do anything. + + `probe_identity` runs `claude auth status --json` against a profile + directory, and it finds that binary on PATH -- not through any + SESSION_KIT_* variable, so pinning the state directory does not pin this. + No test here should reach a provider or session manager at all; if one ever + does, it must hit a shim that fails loudly rather than a real signed-in CLI + or a daemon-capable shpool. + """ + directory = root / "refusing-bin" + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + for name in ("claude", "codex", "shpool"): + shim = directory / name + shim.write_text( + "#!/usr/bin/env bash\n" + f'echo "test sandbox: a real {name} invocation was attempted" >&2\n' + "exit 97\n", + encoding="utf-8", + ) + shim.chmod(0o700) + return directory + + +def sandbox_environment(root: Path) -> dict[str, str]: + """Every path a kit binary can resolve, pinned inside one temporary root. + + Inheriting the real HOME is enough to reach the operator's own config, + account profiles and session manager. Nothing in this file may do that, so + each variable is named here rather than left to a default -- including the + ones a given test does not obviously need, because the point is that no + default can reach outside `root`. The fixture also installs its own + refusing `shpool` at `$HOME/.local/bin/shpool`: the managed bashrc has one + deliberate hardcoded `command shpool detach`, so the environment override + alone is not a complete sandbox. + """ + home = root / "home" + for path in (home, root / "state", root / "journals", root / "archives", + root / "recovery", root / "start", root / "config"): + path.mkdir(mode=0o700, parents=True, exist_ok=True) + (root / "projects.tsv").touch(mode=0o600, exist_ok=True) + home_bin = home / ".local" / "bin" + home_bin.mkdir(mode=0o700, parents=True, exist_ok=True) + shpool = home_bin / "shpool" + shpool.write_text( + "#!/bin/sh\necho 'test sandbox: shpool was invoked' >&2\nexit 97\n", + encoding="utf-8", + ) + shpool.chmod(0o700) + environment = os.environ.copy() + environment.update( + { + "PATH": f"{home_bin}:{refusing_commands_bin(root)}:{os.environ.get('PATH', '')}", + "HOME": str(home), + "XDG_STATE_HOME": str(root / "xdg-state"), + "XDG_DATA_HOME": str(root / "xdg-data"), + "XDG_CONFIG_HOME": str(root / "xdg-config"), + "SESSION_KIT_STATE_DIR": str(root / "state"), + "SESSION_KIT_JOURNAL_DIR": str(root / "journals"), + "SESSION_KIT_ARCHIVE_DIR": str(root / "archives"), + "SESSION_KIT_JOURNAL_RECOVERY_DIR": str(root / "recovery"), + "SESSION_KIT_START_DIR": str(root / "start"), + "SESSION_KIT_PROJECTS_FILE": str(root / "projects.tsv"), + "SESSION_KIT_CONFIG": str(root / "config" / "inventory.json"), + "SESSION_KIT_ACCOUNT_REGISTRY": str(root / "state" / "accounts.json"), + "SESSION_KIT_ACCOUNT_ROOT": str(root / "profiles"), + "SESSION_KIT_ACCOUNT_ROSTER": str(root / "cli_accounts.json"), + "SESSION_KIT_ROTATION_ADVICE": str(root / "rotation_advice.json"), + "SESSION_KIT_SHPOOL_CMD": str(shpool), + "SESSION_KIT_NONINTERACTIVE": "1", + "SESSION_KIT_BACKGROUND": "1", + "PYTHONDONTWRITEBYTECODE": "1", + } + ) + return environment + + +class AutomaticSwitchCommandTests(unittest.TestCase): + """The `account auto-plan` verb the shell driver reads.""" + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="session-kit-auto-cli.") + self.root = Path(self.temporary.name) + self.environment = sandbox_environment(self.root) + self.state = self.root / "state" + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_auto_plan_prints_a_json_verdict_and_never_moves_anything(self) -> None: + # No profiles, no feeds: the answer must still be a readable hold. + result = subprocess.run( + [ + "python3", + str(REPO / "lib" / "session_inventory.py"), + "account", + "auto-plan", + "claude", + UUID, + ], + cwd=REPO, + env=self.environment, + capture_output=True, + text=True, + ) + + self.assertEqual(0, result.returncode, result.stderr) + verdict = json.loads(result.stdout) + self.assertEqual("hold", verdict["action"]) + self.assertIn( + verdict["reason_code"], {"feed_stale", "source_unknown", "kill_switch"} + ) + self.assertEqual(25, verdict["reserve_percent"]) + self.assertEqual(1, verdict["hop_limit"]) + # Nothing was created anywhere but the sandbox. + self.assertFalse((self.state / "account-auto-hops.json").exists()) + + def test_the_sandbox_leaves_the_operators_own_state_untouched(self) -> None: + """A guard on the harness itself, not on the product. + + A test that inherits the real HOME can reach the operator's config, + their enrolled profiles and their session manager. This asserts the + environment every subprocess in this file runs under actually pins all + of them inside one temporary directory. + """ + for key in ( + "HOME", + "SESSION_KIT_STATE_DIR", + "SESSION_KIT_JOURNAL_DIR", + "SESSION_KIT_ARCHIVE_DIR", + "SESSION_KIT_JOURNAL_RECOVERY_DIR", + "SESSION_KIT_START_DIR", + "SESSION_KIT_PROJECTS_FILE", + "SESSION_KIT_CONFIG", + "SESSION_KIT_ACCOUNT_REGISTRY", + "SESSION_KIT_ACCOUNT_ROOT", + "SESSION_KIT_ACCOUNT_ROSTER", + "SESSION_KIT_ROTATION_ADVICE", + "SESSION_KIT_SHPOOL_CMD", + "XDG_STATE_HOME", + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + ): + with self.subTest(variable=key): + value = self.environment.get(key, "") + self.assertTrue(value, f"{key} is not pinned") + self.assertTrue( + Path(value).resolve().is_relative_to(self.root.resolve()), + f"{key} escapes the sandbox: {value}", + ) + self.assertEqual("1", self.environment["SESSION_KIT_NONINTERACTIVE"]) + self.assertEqual("1", self.environment["SESSION_KIT_BACKGROUND"]) + # Both the configurable command and bashrc's hardcoded detach resolve + # to the fixture's refusing shpool, never a host daemon. + self.assertEqual( + Path(self.environment["HOME"]) / ".local" / "bin" / "shpool", + Path(self.environment["SESSION_KIT_SHPOOL_CMD"]), + ) + self.assertTrue(Path(self.environment["SESSION_KIT_SHPOOL_CMD"]).is_file()) + # And so is a real provider CLI: every PATH prefix ahead of the host + # is inside this fixture, with the refusing provider shims second only + # to HOME's required shpool stub. + first = Path(self.environment["PATH"].split(":", 1)[0]).resolve() + self.assertTrue(first.is_relative_to(self.root.resolve())) + refused = subprocess.run( + ["claude", "auth", "status", "--json"], + env=self.environment, + capture_output=True, + text=True, + ) + self.assertEqual(97, refused.returncode) + self.assertIn("a real claude invocation was attempted", refused.stderr) + + +class DisableIsNeverUndoneTests(unittest.TestCase): + """Finding 1. The one rule with no exceptions. + + Preparing a target profile re-verifies it, and re-verification runs a + provider binary that can take seconds. The registry is shared. If the + operator switches an account off inside that window, the code that writes + the profile back must not write `enabled: True`, doing so silently undid + their decision, and whatever used the profile next spent their money on an + account they had switched off. + """ + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="session-kit-enable.") + self.root = Path(self.temporary.name) + self.state = self.root / "state" + self.state.mkdir(mode=0o700) + self.profiles = self.root / "profiles" + self.config = {"state_dir": str(self.state)} + self.registry = self.state / "accounts.json" + refusing_bin = refusing_commands_bin(self.root) + self.environment = mock.patch.dict( + os.environ, + { + "HOME": str(self.root), + "XDG_DATA_HOME": str(self.root), + "XDG_STATE_HOME": str(self.root), + "XDG_CONFIG_HOME": str(self.root), + "SESSION_KIT_STATE_DIR": str(self.state), + "SESSION_KIT_ACCOUNT_REGISTRY": str(self.registry), + "SESSION_KIT_ACCOUNT_ROOT": str(self.profiles), + "SESSION_KIT_ACCOUNT_ROSTER": str(self.root / "roster.json"), + "SESSION_KIT_ROTATION_ADVICE": str(self.root / "advice.json"), + "SESSION_KIT_CONFIG": str(self.root / "session-kit.json"), + "SESSION_KIT_SHPOOL_CMD": str(refusing_bin / "shpool"), + "PATH": f"{refusing_bin}:{os.environ.get('PATH', '')}", + }, + clear=False, + ) + self.environment.start() + directory = self.profiles / "claude" / "target" + directory.mkdir(mode=0o700, parents=True) + state = directory / ".claude.json" + state.write_text(json.dumps({"hasCompletedOnboarding": True}), encoding="utf-8") + state.chmod(0o600) + self.profile_dir = directory + accounts.write_registry( + self.config, + { + "schema_version": accounts.ACCOUNT_SCHEMA_VERSION, + "generation": 1, + "profiles": { + "claude:target": { + "provider": "claude", + "alias": "target", + "email": "target@example.com", + "profile_dir": str(directory), + "legacy": False, + "plan": "max", + "verified_at_unix_ms": 1_800_000_000_000, + "enabled": True, + } + }, + "bindings": {}, + }, + ) + + def tearDown(self) -> None: + self.environment.stop() + self.temporary.cleanup() + + def disable_target(self) -> None: + registry = accounts.load_registry(self.config) + registry["profiles"]["claude:target"]["enabled"] = False + registry["generation"] += 1 + accounts.write_registry(self.config, registry) + + def enabled_now(self) -> object: + return accounts.load_registry(self.config)["profiles"]["claude:target"]["enabled"] + + def test_an_account_disabled_during_the_probe_stays_disabled(self) -> None: + def probe(provider: str, profile_dir: Path) -> dict[str, object]: + # Exactly the reproduced race: the operator acts while the + # provider is being asked who it is. + self.disable_target() + return {"provider": "claude", "email": "target@example.com", "plan": "max"} + + with mock.patch.object(accounts, "probe_identity", probe): + with self.assertRaises(CollectionError) as refused: + accounts.verify_profile(self.config, "claude", "target") + + self.assertIn("disabled", str(refused.exception)) + self.assertIs(False, self.enabled_now()) + + def test_launch_profile_refuses_rather_than_re_enabling(self) -> None: + def probe(provider: str, profile_dir: Path) -> dict[str, object]: + self.disable_target() + return {"provider": "claude", "email": "target@example.com", "plan": "max"} + + with mock.patch.object(accounts, "probe_identity", probe): + with self.assertRaises(CollectionError): + accounts.launch_profile(self.config, "claude", "target") + + self.assertIs(False, self.enabled_now()) + + def test_an_account_already_disabled_is_never_verified_back_on(self) -> None: + self.disable_target() + + with mock.patch.object(accounts, "probe_identity") as probe: + with self.assertRaises(CollectionError): + accounts.verify_profile(self.config, "claude", "target") + + self.assertIs(False, self.enabled_now()) + # It never even asked the provider: a disabled account is not probed. + probe.assert_not_called() + + def test_ordinary_verification_of_an_enabled_account_still_works(self) -> None: + def probe(provider: str, profile_dir: Path) -> dict[str, object]: + return {"provider": "claude", "email": "target@example.com", "plan": "max"} + + with mock.patch.object(accounts, "probe_identity", probe): + item = accounts.verify_profile(self.config, "claude", "target") + + self.assertEqual("target", item["alias"]) + self.assertIs(True, self.enabled_now()) + + def test_an_unrelated_registry_write_does_not_break_verification(self) -> None: + """The check is on this profile, not on a global generation counter. + + Another session binding a conversation bumps the registry generation. + Refusing on that would make every switch fail whenever the estate is + busy, which is its own kind of broken. + """ + + def probe(provider: str, profile_dir: Path) -> dict[str, object]: + registry = accounts.load_registry(self.config) + registry["generation"] += 5 + accounts.write_registry(self.config, registry) + return {"provider": "claude", "email": "target@example.com", "plan": "max"} + + with mock.patch.object(accounts, "probe_identity", probe): + item = accounts.verify_profile(self.config, "claude", "target") + + self.assertEqual("target", item["alias"]) + self.assertIs(True, self.enabled_now()) + + def test_a_profile_change_during_the_probe_is_not_overwritten(self) -> None: + """The re-verification write is a compare-and-set on this profile.""" + + def probe(provider: str, profile_dir: Path) -> dict[str, object]: + registry = accounts.load_registry(self.config) + registry["profiles"]["claude:target"]["email"] = "new@example.com" + registry["generation"] += 1 + accounts.write_registry(self.config, registry) + return { + "provider": "claude", + "email": "target@example.com", + "plan": "max", + } + + with mock.patch.object(accounts, "probe_identity", probe): + with self.assertRaises(CollectionError) as refused: + accounts.verify_profile(self.config, "claude", "target") + + self.assertIn("changed while", str(refused.exception)) + current = accounts.load_registry(self.config)["profiles"]["claude:target"] + self.assertEqual("new@example.com", current["email"]) + self.assertIs(True, current["enabled"]) + + +SP = REPO / "bin" / "sp" +SESSION = "s20260101-000000-1" +SHELL_UUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + + +class AutomaticSwitchDriverTests(unittest.TestCase): + """`sp account-auto-switch`: what it does before the point of no return. + + Every account verb is a fixture stub that records its arguments, so these + tests prove the order of operations and the refusals without a profile, a + feed, a login or a provider anywhere near them. + """ + + def setUp(self) -> None: + from tests.test_commands import CommandFixture + + self.fixture = CommandFixture() + self.account_log = self.fixture.base / "account-calls.jsonl" + self.fixture.shpool_state.write_text( + json.dumps( + { + "sessions": [ + { + "name": SESSION, + "status": "Disconnected", + "started_at_unix_ms": 1_700_000_000_001, + } + ] + } + ), + encoding="utf-8", + ) + + def tearDown(self) -> None: + self.fixture.close() + + def plan_json(self, **overrides: object) -> str: + value: dict[str, object] = { + "action": "switch", + "reason_code": "move", + "reason": "spent is spent; moving this conversation to fresh, " + "which still has 80% of its weekly window.", + "source_alias": "spent", + "target_alias": "fresh", + } + value.update(overrides) + return json.dumps(value) + + def env(self, **overrides: str) -> dict[str, str]: + table = { + "processes": [ + {"pid": 1001, "ppid": 1, "start_ticks": 10001, "cmdline": ["bash"]}, + { + "pid": 2001, + "ppid": 1001, + "start_ticks": 20001, + "cmdline": ["claude", "--resume", SHELL_UUID], + }, + ] + } + env = { + **self.fixture.env(), + # CommandFixture already pins HOME, the state/journal/archive/ + # recovery/start directories, the projects file, the config, the + # session manager and the inventory core inside its own temporary + # tree. These add the account-side paths and the XDG roots it does + # not name, so no default can resolve to the operator's own + # profiles or feeds. Added here rather than in CommandFixture, + # which other suites share. + "SESSION_KIT_ACCOUNT_REGISTRY": str(self.fixture.base / "accounts.json"), + "SESSION_KIT_ACCOUNT_ROOT": str(self.fixture.base / "profiles"), + # The colour-and-names branch made the shared account stub + # honest: with no profile named, an account is simply not + # enrolled, which is what the real core reports. Every scenario + # in this suite moves a conversation ONTO an account that is + # enrolled, so the fixture has to say so. Leaving it unset made + # eight tests assert against "no such enrolled account" instead + # of against the behaviour they are named for. + "STUB_ACCOUNT_PROFILE": str(self.fixture.base / "profiles" / "target"), + "SESSION_KIT_ACCOUNT_ROSTER": str(self.fixture.base / "cli_accounts.json"), + "SESSION_KIT_ROTATION_ADVICE": str(self.fixture.base / "advice.json"), + "XDG_STATE_HOME": str(self.fixture.base / "xdg-state"), + "XDG_DATA_HOME": str(self.fixture.base / "xdg-data"), + "XDG_CONFIG_HOME": str(self.fixture.base / "xdg-config"), + "PATH": "%s:%s" + % (refusing_commands_bin(self.fixture.base), os.environ.get("PATH", "")), + "SESSION_KIT_BACKGROUND": "1", + "STUB_DYNAMIC_PROVIDER": "claude", + "STUB_DYNAMIC_UUID": SHELL_UUID, + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "STUB_DYNAMIC_AGENT_STATUS": "idle", + "STUB_DYNAMIC_ACCOUNT_ALIAS": "spent", + "STUB_DYNAMIC_ACCOUNT_CAPABLE": "1", + "STUB_PROCESS_TABLE": json.dumps(table), + "STUB_ACCOUNT_LOG": str(self.account_log), + "STUB_ACCOUNT_PLAN": self.plan_json(), + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + } + env.update(overrides) + return env + + def calls(self) -> list[list[str]]: + if not self.account_log.exists(): + return [] + return [ + json.loads(line) + for line in self.account_log.read_text(encoding="utf-8").splitlines() + if line + ] + + def verbs(self) -> list[str]: + return [call[1] for call in self.calls()] + + def run_switch(self, **overrides: str): + return subprocess.run( + [str(SP), "account-auto-switch", SESSION, "--apply"], + env={**os.environ, **self.env(**overrides)}, + capture_output=True, + text=True, + ) + + # -- the command exists and asks the rule first ------------------------- + + def test_it_asks_the_rule_for_the_exact_conversation_and_nothing_else(self) -> None: + held = self.run_switch( + STUB_ACCOUNT_PLAN=self.plan_json( + action="hold", + reason_code="source_has_quota", + reason="spent still has 40% of its weekly window; nothing was moved.", + target_alias="", + ) + ) + + self.assertEqual(0, held.returncode, held.stderr) + self.assertIn("still has 40%", held.stdout) + self.assertEqual(["auto-plan"], self.verbs()) + # The rule is asked about this conversation, by provider and UUID. + self.assertEqual( + ["account", "auto-plan", "claude", SHELL_UUID], + self.calls()[0][:4], + ) + self.assertIn("--shpool-id", self.calls()[0]) + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_a_hold_verdict_never_reaches_a_single_account_mutation(self) -> None: + for code, reason in ( + ("kill_switch", "Automatic account changes are switched off."), + ("feed_stale", "The account usage feed is stale or unreadable."), + ("hop_limit", "It stops here and waits for you."), + ("no_eligible_account", "automatic switching stood down."), + ): + with self.subTest(reason_code=code): + if self.account_log.exists(): + self.account_log.unlink() + result = self.run_switch( + STUB_ACCOUNT_PLAN=self.plan_json( + action="hold", reason_code=code, reason=reason, target_alias="" + ) + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(["auto-plan"], self.verbs()) + self.assertNotIn("moved from", result.stdout) + self.assertFalse(self.fixture.shpool_log.exists()) + + # -- never move a working conversation ---------------------------------- + + def test_a_working_conversation_is_not_moved_even_on_a_switch_verdict(self) -> None: + result = self.run_switch(STUB_DYNAMIC_AGENT_STATUS="working") + + self.assertEqual(1, result.returncode) + self.assertIn("the conversation is working", result.stderr) + self.assertEqual(["auto-plan"], self.verbs()) + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_an_unrecognized_background_child_stops_the_move(self) -> None: + table = { + "processes": [ + {"pid": 1001, "ppid": 1, "start_ticks": 10001, "cmdline": ["bash"]}, + {"pid": 2001, "ppid": 1001, "start_ticks": 20001, "cmdline": ["claude"]}, + {"pid": 3001, "ppid": 1001, "start_ticks": 30001, "cmdline": ["rsync"]}, + ] + } + result = self.run_switch(STUB_PROCESS_TABLE=json.dumps(table)) + + self.assertEqual(1, result.returncode) + self.assertIn("does not recognize", result.stderr) + self.assertEqual(["auto-plan"], self.verbs()) + self.assertFalse(self.fixture.shpool_log.exists()) + + # -- the target must prove itself before anything is prepared ----------- + + def test_a_target_that_cannot_be_prepared_stops_before_the_checkpoint(self) -> None: + """A failed local identity refresh cannot reach move state. + + launch-profile belongs after the plan's initial eligibility decision + and before the final target recheck: it verifies the already-enrolled + profile, but does not prepare, reserve, signal, or switch anything. + """ + marker = self.fixture.base / "launch-refresh.json" + result = self.run_switch( + STUB_ACCOUNT_LAUNCH_FAILS="1", + STUB_ACCOUNT_LAUNCH_MARKER=str(marker), + ) + + self.assertEqual(1, result.returncode) + self.assertIn("not selectable", result.stderr) + self.assertEqual(["auto-plan", "launch-profile"], self.verbs()) + self.assertNotIn("switch-prepare", self.verbs()) + self.assertFalse(marker.exists()) + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_a_window_that_cannot_change_account_in_place_is_left_alone(self) -> None: + result = self.run_switch(STUB_DYNAMIC_ACCOUNT_CAPABLE="0") + + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("cannot change account on its own", result.stdout) + self.assertEqual(["auto-plan"], self.verbs()) + # The legacy path recreates the shell. Never unattended. + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_a_verdict_naming_an_unusable_account_is_refused(self) -> None: + for source, target in (("spent", "spent"), ("spent", "NOPE"), ("", "fresh")): + with self.subTest(source=source, target=target): + result = self.run_switch( + STUB_ACCOUNT_PLAN=self.plan_json( + source_alias=source, target_alias=target + ) + ) + self.assertEqual(1, result.returncode) + self.assertIn("unusable account", result.stderr) + + def test_dry_run_names_the_move_and_touches_nothing(self) -> None: + result = subprocess.run( + [str(SP), "account-auto-switch", SESSION, "--dry-run"], + env={**os.environ, **self.env()}, + capture_output=True, + text=True, + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("Would move", result.stdout) + self.assertIn("spent", result.stdout) + self.assertIn("fresh", result.stdout) + self.assertEqual(["auto-plan"], self.verbs()) + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_a_target_switched_off_after_the_plan_is_refused(self) -> None: + """Finding 1's belt, at the driver. + + Preparing the target is a mutating call that runs a provider binary + and takes seconds. If the operator switches that account off inside + that window, their answer wins and nothing is prepared. + """ + marker = self.fixture.base / "launch-refresh.json" + result = self.run_switch( + STUB_ACCOUNT_TARGET_GONE="1", + STUB_ACCOUNT_LAUNCH_MARKER=str(marker), + ) + + self.assertEqual(1, result.returncode) + self.assertIn("no longer available", result.stderr) + self.assertEqual( + ["auto-plan", "launch-profile", "auto-target-ok"], self.verbs() + ) + self.assertNotIn("switch-prepare", self.verbs()) + self.assertNotIn("auto-begin", self.verbs()) + self.assertEqual( + {"provider": "claude", "alias": "fresh"}, + json.loads(marker.read_text(encoding="utf-8")), + ) + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_a_live_account_change_before_the_signal_is_refused(self) -> None: + """A refusal in the exact launch-to-recheck gap leaves only refresh state. + + The fresh snapshot must still show the source the plan judged. The + marker represents the harmless local verification metadata that real + launch-profile may retain; no checkpoint, reservation, target recheck, + provider signal, or session-manager call may follow this refusal. + """ + marker = self.fixture.base / "launch-refresh.json" + result = self.run_switch( + STUB_DYNAMIC_ACCOUNT_ALIAS_AFTER_FIRST="fresh", + STUB_ACCOUNT_LAUNCH_MARKER=str(marker), + ) + + self.assertEqual(1, result.returncode) + self.assertIn("changed while its account handoff was prepared", result.stderr) + self.assertEqual( + ["auto-plan", "launch-profile"], self.verbs() + ) + self.assertNotIn("switch-prepare", self.verbs()) + self.assertNotIn("auto-begin", self.verbs()) + self.assertNotIn("auto-target-ok", self.verbs()) + self.assertEqual( + {"provider": "claude", "alias": "fresh"}, + json.loads(marker.read_text(encoding="utf-8")), + ) + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_a_new_live_binding_mismatch_before_the_signal_is_refused(self) -> None: + """The same pre-recheck refusal applies to a new binding mismatch. + + launch-profile is safe here because it only refreshes an enrolled + profile. The mismatch stops the path before any move state is created. + """ + result = self.run_switch(STUB_DYNAMIC_ACCOUNT_MISMATCH_AFTER_FIRST="1") + + self.assertEqual(1, result.returncode) + self.assertIn("changed while its account handoff was prepared", result.stderr) + self.assertEqual(["auto-plan", "launch-profile"], self.verbs()) + self.assertNotIn("switch-prepare", self.verbs()) + + def test_a_move_that_cannot_be_counted_is_not_made(self) -> None: + """Finding 2: no reservation, no move. Ever.""" + result = self.run_switch(STUB_ACCOUNT_BEGIN_FAILS="1") + + self.assertEqual(1, result.returncode) + self.assertIn("could not be counted", result.stderr) + self.assertIn("switch-rollback", self.verbs()) + # The provider was never signalled, so the conversation never moved. + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_the_reservation_is_taken_before_the_handoff_request(self) -> None: + # The signal cannot succeed against a fixture pid, so the run fails + # after the reservation. What is pinned here is the ORDER: the move is + # counted before anything the operator could observe as a move. + self.run_switch() + + verbs = self.verbs() + self.assertIn("auto-begin", verbs) + self.assertLess(verbs.index("auto-target-ok"), verbs.index("switch-prepare")) + self.assertLess(verbs.index("switch-prepare"), verbs.index("auto-begin")) + # It failed before commit, and gave the reservation back, because + # nothing was signalled. + self.assertNotIn("switch-commit", verbs) + self.assertIn("auto-release", verbs) + + def test_the_command_refuses_arguments_it_does_not_understand(self) -> None: + for argv in ([SESSION, "--force"], [SESSION, "fresh"], []): + with self.subTest(argv=argv): + result = subprocess.run( + [str(SP), "account-auto-switch", *argv], + env={**os.environ, **self.env()}, + capture_output=True, + text=True, + ) + self.assertNotEqual(0, result.returncode) + + +class AutomaticSwitchEndToEndTests(unittest.TestCase): + """The whole move, driven through commit, against a process we own. + + Everything before this stopped at `launch-profile`, because going further + means the code signals a provider, and the signal is guarded by a check + against real `/proc`, which no fabricated pid can pass. So this starts its + own harmless process, names it `claude`, and hands the driver that pid. + The SIGTERM lands on our own sleeper and on nothing else; no real provider, + profile, account or session is involved at any point. + """ + + def setUp(self) -> None: + from tests.test_commands import CommandFixture + + self.fixture = CommandFixture() + self.account_log = self.fixture.base / "account-calls.jsonl" + self.fixture.shpool_state.write_text( + json.dumps( + { + "sessions": [ + { + "name": SESSION, + "status": "Disconnected", + "started_at_unix_ms": 1_700_000_000_001, + } + ] + } + ), + encoding="utf-8", + ) + # A process of our own, named so the identity guard recognizes it. + sleeper = self.fixture.base / "bin" + sleeper.mkdir(exist_ok=True) + self.provider_path = sleeper / "claude" + self.provider_path.write_bytes(Path("/bin/sleep").read_bytes()) + self.provider_path.chmod(0o755) + self.provider = subprocess.Popen( + [str(self.provider_path), "300"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + self.provider_start = self.read_start_ticks(self.provider.pid) + + def tearDown(self) -> None: + self.provider.terminate() + try: + self.provider.wait(timeout=10) + except subprocess.TimeoutExpired: # pragma: no cover - defensive + self.provider.kill() + self.provider.wait(timeout=10) + self.fixture.close() + + @staticmethod + def read_start_ticks(pid: int) -> int: + text = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8") + return int(text.rsplit(")", 1)[1].split()[19]) + + def env(self, **overrides: str) -> dict[str, str]: + table = { + "processes": [ + {"pid": 1001, "ppid": 1, "start_ticks": 10001, "cmdline": ["bash"]}, + { + "pid": self.provider.pid, + "ppid": 1001, + "start_ticks": self.provider_start, + "cmdline": ["claude", "--resume", SHELL_UUID], + }, + ] + } + env = { + **self.fixture.env(), + "SESSION_KIT_ACCOUNT_REGISTRY": str(self.fixture.base / "accounts.json"), + "SESSION_KIT_ACCOUNT_ROOT": str(self.fixture.base / "profiles"), + # The colour-and-names branch made the shared account stub + # honest: with no profile named, an account is simply not + # enrolled, which is what the real core reports. Every scenario + # in this suite moves a conversation ONTO an account that is + # enrolled, so the fixture has to say so. Leaving it unset made + # eight tests assert against "no such enrolled account" instead + # of against the behaviour they are named for. + "STUB_ACCOUNT_PROFILE": str(self.fixture.base / "profiles" / "target"), + "SESSION_KIT_ACCOUNT_ROSTER": str(self.fixture.base / "cli_accounts.json"), + "SESSION_KIT_ROTATION_ADVICE": str(self.fixture.base / "advice.json"), + "XDG_STATE_HOME": str(self.fixture.base / "xdg-state"), + "XDG_DATA_HOME": str(self.fixture.base / "xdg-data"), + "XDG_CONFIG_HOME": str(self.fixture.base / "xdg-config"), + "PATH": "%s:%s" + % (refusing_commands_bin(self.fixture.base), os.environ.get("PATH", "")), + "SESSION_KIT_BACKGROUND": "1", + "SESSION_KIT_AUTO_SWITCH_MARKER": "1", + "STUB_DYNAMIC_PROVIDER": "claude", + "STUB_DYNAMIC_UUID": SHELL_UUID, + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "STUB_DYNAMIC_AGENT_STATUS": "idle", + "STUB_DYNAMIC_ACCOUNT_ALIAS": "spent", + "STUB_DYNAMIC_ACCOUNT_ALIAS_AFTER_REQUEST": "fresh", + "STUB_DYNAMIC_ACCOUNT_CAPABLE": "1", + "STUB_DYNAMIC_PROVIDER_PID": str(self.provider.pid), + "STUB_DYNAMIC_PROVIDER_START": str(self.provider_start), + "STUB_PROCESS_TABLE": json.dumps(table), + "STUB_ACCOUNT_LOG": str(self.account_log), + "STUB_ACCOUNT_TXID": "a" * 32, + "STUB_ACCOUNT_PLAN": json.dumps( + { + "action": "switch", + "reason_code": "move", + "reason": "spent is spent; moving to fresh.", + "source_alias": "spent", + "target_alias": "fresh", + } + ), + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + } + env.update(overrides) + return env + + def verbs(self) -> list[str]: + if not self.account_log.exists(): + return [] + return [ + json.loads(line)[1] + for line in self.account_log.read_text(encoding="utf-8").splitlines() + if line + ] + + def run_apply(self, **overrides: str): + return subprocess.run( + [str(SP), "account-auto-switch", SESSION, "--apply"], + env={**os.environ, **self.env(**overrides)}, + capture_output=True, + text=True, + timeout=180, + ) + + def test_the_whole_move_runs_in_order_and_the_conversation_is_kept(self) -> None: + """Refresh, recheck, checkpoint and reservation keep their reviewed order. + + The launch step re-verifies the existing profile after policy's first + approval. The read-only target check follows it to catch a disable or + reserve change during that slower refresh. The move is still counted + before the handoff request or provider signal. + """ + result = self.run_apply() + + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("moved from spent to fresh", result.stdout) + self.assertIn("The same conversation is still open", result.stdout) + self.assertEqual( + [ + "auto-plan", + "launch-profile", + "auto-target-ok", + "switch-prepare", + "auto-begin", + "sync-ui", + "switch-commit", + "auto-commit", + ], + self.verbs(), + ) + # The reservation precedes the commit, and the session was never killed. + self.assertLess(self.verbs().index("auto-begin"), self.verbs().index("switch-commit")) + self.assertFalse(self.fixture.shpool_log.exists()) + # The receipt names the exact conversation and the reservation token. + marker = [ + line + for line in result.stderr.splitlines() + if line.startswith("SESSION_KIT_AUTO_SWITCH\t") + ] + self.assertEqual(1, len(marker), result.stderr) + fields = marker[0].split("\t") + self.assertEqual([SESSION, "claude", SHELL_UUID, "spent", "fresh"], fields[1:6]) + self.assertEqual(32, len(fields[6])) + + def test_the_same_run_without_apply_changes_nothing(self) -> None: + result = subprocess.run( + [str(SP), "account-auto-switch", SESSION], + env={**os.environ, **self.env()}, + capture_output=True, + text=True, + timeout=180, + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("Would move", result.stdout) + self.assertEqual(["auto-plan"], self.verbs()) + self.assertEqual(0, self.provider.poll() or 0) + + def test_a_commit_that_fails_never_kills_the_window(self) -> None: + """Finding 9/lane-A finding 1, proven at the driver. + + The rollback helper's last resort closes the session and re-creates + it. Unattended that is a lost window, so the automatic caller opts out + and reports instead. What must never appear here is a `shpool kill`. + """ + result = self.run_apply(STUB_ACCOUNT_COMMIT_FAILS="1") + + self.assertEqual(1, result.returncode) + self.assertIn("did not prove itself", result.stderr) + self.assertNotIn("sp recover", result.stderr) + self.assertFalse( + self.fixture.shpool_log.exists(), + "the automatic path closed the operator's session: %s" + % (self.fixture.shpool_log.read_text(encoding="utf-8") + if self.fixture.shpool_log.exists() else ""), + ) + + +class AutomaticSwitchSourceTests(unittest.TestCase): + """Structural promises that no fixture can prove on its own.""" + + def setUp(self) -> None: + self.commands = (REPO / "lib" / "sh" / "sp_commands.sh").read_text( + encoding="utf-8" + ) + start = self.commands.index("account_auto_switch_target()") + self.body = self.commands[start : self.commands.index("teardown_target()")] + + def test_the_automatic_path_reuses_the_manual_switch_safety_helpers(self) -> None: + for helper in ( + "account_switch_stable_snapshot", + "account_switch_safe_tree", + "account_switch_request", + "account_switch_signal", + "account_switch_wait_alias", + "account_switch_restore_source", + ): + self.assertIn(helper, self.body, helper) + + def test_the_automatic_path_never_kills_the_session(self) -> None: + """The exact conversation and its window survive a move. + + The in-place handoff signals the provider to reload a profile; it + never closes the shell. A `shpool kill` in this function would be the + legacy recreate path running with nobody watching. + """ + self.assertNotIn("SHPOOL\" kill", self.body) + self.assertNotIn("shpool kill", self.body) + self.assertIn("$capable != true", self.body) + + def test_the_move_is_counted_before_anything_irreversible(self) -> None: + """Finding 2, pinned in the order of operations itself. + + The reservation has to come before the request is written and before + the provider is signalled. Any other order leaves a window where the + conversation has moved and the ledger says it has not. + """ + reserve = self.body.index("account auto-begin") + request = self.body.index("account_switch_request") + signal = self.body.index("account_switch_signal") + commit = self.body.index("account switch-commit") + self.assertLess(reserve, request) + self.assertLess(reserve, signal) + self.assertLess(reserve, commit) + + def test_the_target_is_re_read_after_the_mutating_prepare(self) -> None: + """Finding 1's belt: the operator can disable an account at any moment.""" + launch = self.body.index("account launch-profile") + recheck = self.body.index("account auto-target-ok") + prepare = self.body.index("account switch-prepare") + self.assertLess(launch, recheck) + self.assertLess(recheck, prepare) + + def test_both_live_proofs_require_the_source_account(self) -> None: + """The alias can drift after policy chose it and before the signal.""" + lines = self.body.splitlines() + calls = [ + index + for index, line in enumerate(lines) + if line.lstrip().startswith( + ("account_switch_stable_snapshot ", "! account_switch_stable_snapshot ") + ) + ] + self.assertEqual(2, len(calls)) + for index in calls: + self.assertIn('"$source_alias"', "\n".join(lines[index : index + 3])) + picker = (REPO / "lib" / "sh" / "sp_picker.sh").read_text( + encoding="utf-8" + ) + stable = picker[ + picker.index("account_switch_stable_snapshot() {") : + picker.index("account_switch_safe_tree() {") + ] + self.assertIn('row.get("account_alias") != sys.argv[5]', stable) + self.assertIn('row.get("account_binding_mismatch") is True', stable) + + def test_the_handoff_carries_a_generation_bound_model_record(self) -> None: + """Finding 10: a named model reaches the replacement provider.""" + common = (REPO / "bin" / "session_kit_common").read_text(encoding="utf-8") + self.assertIn('row.get("model") or ""', common) + self.assertIn("SK_MODEL=${fields[21]}", common) + self.assertIn('local model=${SK_MODEL:-}', self.body) + self.assertIn('"$shell_start" "$model" "$provider"', self.body) + + inventory = (REPO / "lib" / "session_inventory.py").read_text( + encoding="utf-8" + ) + self.assertIn('item["model_handoff_capable"]', inventory) + self.assertIn('process.get("requested_model")', inventory) + + bashrc = (REPO / "bashrc" / "shpool.bashrc").read_text(encoding="utf-8") + switch = bashrc[bashrc.index("__sk_switch_request=") :] + self.assertIn("__sk_switch_model", switch) + self.assertIn("validate-worker-model", switch) + self.assertIn('"$__sk_switch_launch_tmp"', switch) + self.assertIn('mv -- "$__sk_switch_launch_tmp" "$__sk_launch"', switch) + + def test_the_legacy_picker_prefers_the_live_model_then_falls_back_to_its_row(self) -> None: + """The two merged model values are a priority order, not duplicates.""" + picker = (REPO / "lib" / "sh" / "sp_picker.sh").read_text(encoding="utf-8") + start = picker.index("picker_account_switch() {") + body = picker[start : picker.index("picker_alias() {")] + live = body.index( + 'carried_model=$(sk_session_model "$SNAPSHOT" "$id")' + ) + fallback = body.index( + '[[ -n $carried_model ]] || carried_model=$model' + ) + terminate = body.index("sk_terminate_exact_shell") + + # Read the live provider's command line while it still exists. A read + # failure degrades to the picker row captured at entry, never to an + # unnamed provider default, and both happen before the provider dies. + self.assertLess(live, fallback) + self.assertLess(fallback, terminate) + self.assertGreaterEqual( + body.count( + 'restore_exact "$provider" "$uuid" "$cwd" "$source_alias" ' + '"$carried_model"' + ), + 2, + ) + self.assertIn("pending checkpoint were left in place", body) + + def test_a_failed_hop_never_reaches_the_destructive_recreate(self) -> None: + """Finding 9: follow the helper, do not just grep this function. + + `account_switch_restore_source` ends by terminating the exact shell + and re-creating it. A conversation that has to be recovered is not a + conversation left working, so the unattended caller opts out of that + last resort and says so instead. + """ + self.assertIn("--no-recreate", self.body) + picker = (REPO / "lib" / "sh" / "sp_picker.sh").read_text(encoding="utf-8") + start = picker.index("account_switch_restore_source() {") + helper = picker[start : picker.index("picker_account_switch() {")] + # The helper still has the destructive path, for the human at the + # picker who is present and wants it... + self.assertIn("sk_terminate_exact_shell", helper) + self.assertNotIn('"$SK_SHPOOL" kill "$id"', helper) + # ...but it is now behind the opt-out, checked before termination. + guard_line = helper.index("(( allow_recreate )) || return 1") + self.assertLess(guard_line, helper.index("sk_terminate_exact_shell")) + self.assertIn("allow_recreate=0", helper) + + def test_the_recreate_opt_out_does_not_depend_on_argument_position(self) -> None: + """The opt-out is a promise, not a subscript. + + Carrying the model added an argument in front of `--no-recreate`. Every + source-text proof above stayed green through that change, because the + call site still writes the flag -- only the position the helper read it + from moved. A helper that honours the flag in exactly one position + turns the next such refactor into an unattended kill of a live window, + silently. So run the helper and watch for the kill -- once with the flag + where the caller put it before the model, and once where it puts it now. + Both are the same promise, so both must hold. + """ + picker = (REPO / "lib" / "sh" / "sp_picker.sh").read_text(encoding="utf-8") + start = picker.index("account_switch_restore_source() {") + helper = picker[start : picker.index("picker_account_switch() {")] + head = 'account_switch_restore_source sess claude %s spent fresh %s /tmp' % ( + UUID, + "0" * 32, + ) + vectors = { + "flag directly after the directory": head + " --no-recreate", + "flag behind a carried model": head + " sonnet-4 --no-recreate", + "flag behind an empty model": head + ' "" --no-recreate', + } + + for name, call in vectors.items(): + with self.subTest(vector=name): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + killed = root / "killed" + shpool = root / "fake-shpool" + shpool.write_text( + "#!/usr/bin/env bash\necho \"$*\" >> " + + json.dumps(str(killed)) + + "\nexit 0\n", + encoding="utf-8", + ) + shpool.chmod(0o755) + # Every collaborator is a stub and the snapshot read is + # forced to fail, so the run falls through to the + # destructive tail. Nothing here reaches a real session, + # account, or provider. + script = "\n".join( + [ + "set -u", + "SK_STATE_DIR=" + json.dumps(str(root)), + "SK_SHPOOL=" + json.dumps(str(shpool)), + "INVENTORY_CORE=/nonexistent/inventory", + "sk_guard_snapshot_file() { return 1; }", + "sk_resolve() { return 1; }", + "sk_reap_session_leftovers() { return 0; }", + "restore_exact() { return 0; }", + "account_switch_request() { return 1; }", + "account_switch_signal() { return 1; }", + "account_switch_wait_alias() { return 1; }", + "python3() { return 0; }", + helper, + call, + 'printf "status=%s\\n" "$?"', + ] + ) + result = subprocess.run( + ["bash", "-c", script], + capture_output=True, + text=True, + cwd=str(root), + env=sandbox_environment(root), + ) + ran_kill = killed.exists() + evidence = killed.read_text(encoding="utf-8") if ran_kill else "" + + self.assertFalse( + ran_kill, + "the destructive recreate ran despite --no-recreate: " + evidence, + ) + self.assertIn("status=1", result.stdout) + + def test_the_safety_proof_is_taken_twice_around_the_lock(self) -> None: + self.assertEqual(2, self.body.count("account_switch_stable_snapshot")) + self.assertEqual(2, self.body.count("account_switch_safe_tree")) + self.assertIn("picker_lock", self.body) + + def test_the_machine_receipt_is_off_unless_a_driver_asks_for_it(self) -> None: + self.assertIn("SESSION_KIT_AUTO_SWITCH_MARKER:-0", self.body) + watchdog = (REPO / "bin" / "session_kit_watchdog").read_text(encoding="utf-8") + self.assertIn("SESSION_KIT_AUTO_SWITCH_MARKER=1", watchdog) + + def test_the_watchdog_only_moves_a_session_in_its_acting_mode(self) -> None: + """Report mode is the watchdog's promise not to touch a live session.""" + watchdog = (REPO / "bin" / "session_kit_watchdog").read_text(encoding="utf-8") + start = watchdog.index("account_guard_pass() {") + body = watchdog[start : watchdog.index("announce_account_move() {")] + self.assertIn("if [[ $WATCHDOG_MODE == repair ]]; then", body) + self.assertIn("--dry-run", body) + + def test_acting_needs_an_explicit_apply(self) -> None: + """This verb spends money and any shell in the checkout can run it. + + Judging has to be what you get by default; moving a live conversation + between paid subscriptions has to be what somebody typed on purpose. + """ + self.assertIn("dry_run=1", self.body) + self.assertIn("--apply", self.body) + entry = self.commands # the dispatcher lives in bin/sp + sp_source = (REPO / "bin" / "sp").read_text(encoding="utf-8") + self.assertIn("$3 == --apply", sp_source) + self.assertIn("account-auto-switch <session> [--apply]", sp_source) + self.assertTrue(entry) + + def test_the_watchdog_takes_a_lock_so_one_move_per_pass_is_true(self) -> None: + watchdog = (REPO / "bin" / "session_kit_watchdog").read_text(encoding="utf-8") + self.assertIn("ACCOUNT_GUARD_LOCK", watchdog) + self.assertIn('flock -n "$ACCOUNT_GUARD_FD"', watchdog) + # And the acting form is the one the watchdog uses. + self.assertIn('account-auto-switch "$id" --apply', watchdog) + + def test_the_watchdog_honours_the_shipped_kill_switch(self) -> None: + watchdog = (REPO / "bin" / "session_kit_watchdog").read_text(encoding="utf-8") + self.assertIn("account-switching-off", watchdog) + self.assertIn("[[ -e $ACCOUNT_SWITCH_OFF ]] && return 0", watchdog) + # And it never creates it: a machine that disables the operator's own + # manual switch has taken their escape hatch away. + self.assertNotIn("touch \"$ACCOUNT_SWITCH_OFF\"", watchdog) + self.assertNotIn("> \"$ACCOUNT_SWITCH_OFF\"", watchdog) + + +class WatchdogAccountGuardTests(unittest.TestCase): + """The periodic driver, run for real against a sandboxed estate. + + The watchdog binary runs with `--once`; the account feeds, the profile + registry and `sp` are all fixtures inside a temporary directory. The + policy code underneath is the real one. + """ + + def setUp(self) -> None: + from tests import test_watchdog as harness + + self.harness = harness + self.fixture = harness.WatchdogFixture( + sessions=[ + harness.session( + "s20260101-000000-1", + provider="claude", + agent_status="idle", + uuid=UUID, + ) + ] + ) + row = json.loads(self.fixture.inventory.read_text(encoding="utf-8")) + row["sessions"][0]["account_alias"] = "spent" + row["sessions"][0]["account_switch_capable"] = True + self.fixture.inventory.write_text(json.dumps(row), encoding="utf-8") + self.roster = self.fixture.base / "cli_accounts.json" + self.advice = self.fixture.base / "rotation_advice.json" + self.registry = self.fixture.base / "accounts.json" + self.profiles = self.fixture.base / "profiles" + + def tearDown(self) -> None: + self.fixture.close() + + def seed(self, spent_weekly: float) -> None: + now = int(time.time()) + profiles = {} + for alias, email in (("spent", "spent@example.com"), ("fresh", "fresh@example.com")): + directory = self.profiles / "claude" / alias + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + state = directory / ".claude.json" + state.write_text(json.dumps({"hasCompletedOnboarding": True}), encoding="utf-8") + state.chmod(0o600) + profiles[f"claude:{alias}"] = { + "provider": "claude", + "alias": alias, + "email": email, + "profile_dir": str(directory), + "legacy": False, + "plan": "max", + "verified_at_unix_ms": 1_800_000_000_000, + "enabled": True, + } + self.registry.write_text( + json.dumps( + { + "schema_version": 1, + "generation": 1, + "profiles": profiles, + "bindings": {}, + } + ), + encoding="utf-8", + ) + self.registry.chmod(0o600) + self.roster.write_text( + json.dumps( + { + "ts": now, + "accounts": [ + { + "email": "spent@example.com", + "enabled": True, + "serving": True, + "health": "ok", + "u5h": 0.9, + "u7d": spent_weekly, + }, + { + "email": "fresh@example.com", + "enabled": True, + "serving": True, + "health": "ok", + "u5h": 0.1, + "u7d": 0.2, + }, + ], + } + ), + encoding="utf-8", + ) + self.roster.chmod(0o600) + self.advice.write_text( + json.dumps({"ts": now, "use_now": {"account": "fresh@example.com"}}), + encoding="utf-8", + ) + self.advice.chmod(0o600) + + def run_once(self, **overrides: str): + # WatchdogFixture already pins HOME, the state, journal and archive + # directories, the session manager, the status command and `sp` inside + # its own temporary tree. The rest is named explicitly so no default + # can resolve to the operator's own profiles, feeds or config. + environment = { + "SESSION_KIT_ACCOUNT_REGISTRY": str(self.registry), + "SESSION_KIT_ACCOUNT_ROOT": str(self.profiles), + "SESSION_KIT_ACCOUNT_ROSTER": str(self.roster), + "SESSION_KIT_ROTATION_ADVICE": str(self.advice), + "SESSION_KIT_JOURNAL_RECOVERY_DIR": str(self.fixture.base / "recovery"), + "SESSION_KIT_START_DIR": str(self.fixture.base / "start"), + "SESSION_KIT_PROJECTS_FILE": str(self.fixture.base / "projects.tsv"), + "SESSION_KIT_CONFIG": str(self.fixture.base / "inventory.json"), + "XDG_STATE_HOME": str(self.fixture.base / "xdg-state"), + "XDG_DATA_HOME": str(self.fixture.base / "xdg-data"), + "XDG_CONFIG_HOME": str(self.fixture.base / "xdg-config"), + "SESSION_KIT_NONINTERACTIVE": "1", + "SESSION_KIT_BACKGROUND": "1", + # Refuse every bare shpool/provider lookup first. The fixture's + # explicitly configured safe commands remain available by their + # full paths. + "PATH": "%s:%s:%s" + % ( + refusing_commands_bin(self.fixture.base), + self.fixture.bin, + os.environ.get("PATH", ""), + ), + } + environment.update(overrides) + return self.fixture.run(**environment) + + def test_the_fixture_never_names_the_operators_own_paths(self) -> None: + environment = self.fixture.env() + base = self.fixture.base.resolve() + for key in ( + "HOME", + "SESSION_KIT_STATE_DIR", + "SESSION_KIT_JOURNAL_DIR", + "SESSION_KIT_ARCHIVE_DIR", + "SESSION_KIT_SHPOOL_CMD", + "SESSION_KIT_STATUS_CMD", + "SESSION_KIT_SP_CMD", + "SESSION_KIT_WATCHDOG_LOG", + "SESSION_KIT_WATCHDOG_REPAIRS", + "SESSION_KIT_WATCHDOG_REPORT_STATE", + "SESSION_KIT_WATCHDOG_NOTIFY", + ): + with self.subTest(variable=key): + self.assertTrue( + Path(environment[key]).resolve().is_relative_to(base), + f"{key} escapes the sandbox: {environment[key]}", + ) + # The notifier is a fixture stub inside the sandbox, checked by the + # containment loop above, so no real alert can ever be sent. + self.assertTrue( + Path(environment["SESSION_KIT_WATCHDOG_NOTIFY"]).is_relative_to(base) + ) + + def sp_calls(self) -> list[str]: + if not self.fixture.sp_log.exists(): + return [] + return [ + line + for line in self.fixture.sp_log.read_text(encoding="utf-8").splitlines() + if "account-auto-switch" in line + ] + + def test_a_healthy_account_costs_one_feed_read_and_no_session_work(self) -> None: + self.seed(spent_weekly=0.40) + + result = self.run_once() + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual([], self.sp_calls()) + + def test_a_spent_account_reaches_the_session_that_is_on_it(self) -> None: + self.seed(spent_weekly=1.0) + + result = self.run_once() + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(1, len(self.sp_calls()), self.fixture.log_text()) + self.assertIn("s20260101-000000-1", self.sp_calls()[0]) + + def test_report_mode_judges_the_session_but_never_moves_it(self) -> None: + self.seed(spent_weekly=1.0) + + result = self.run_once(SESSION_KIT_WATCHDOG_MODE="report") + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(1, len(self.sp_calls())) + self.assertIn("--dry-run", self.sp_calls()[0]) + + def test_the_kill_switch_stops_the_pass_before_it_reads_anything(self) -> None: + # The control for this is + # test_a_spent_account_reaches_the_session_that_is_on_it: the same + # fixture, the same spent account, and one `sp` call. Without that + # pair this test would pass on any machine where the pass never ran, + # which is exactly how it passed while the sentinel path was wrong. + self.seed(spent_weekly=1.0) + killer = self.fixture.state / "account-switching-off" + killer.write_text("", encoding="utf-8") + self.assertTrue(killer.exists()) + + result = self.run_once() + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual([], self.sp_calls()) + + def test_the_sentinel_the_kill_switch_test_writes_is_the_one_read(self) -> None: + """Pins the path itself, so it cannot drift somewhere harmless again.""" + watchdog = (REPO / "bin" / "session_kit_watchdog").read_text(encoding="utf-8") + self.assertIn( + 'ACCOUNT_SWITCH_OFF=${SESSION_KIT_ACCOUNT_SWITCH_OFF:-"$SK_STATE_DIR/account-switching-off"}', + watchdog, + ) + self.assertEqual( + str(self.fixture.state / "account-switching-off"), + str(self.fixture.state / "account-switching-off"), + ) + + def test_a_stale_feed_stops_the_pass(self) -> None: + self.seed(spent_weekly=1.0) + stale = json.loads(self.roster.read_text(encoding="utf-8")) + stale["ts"] = int(time.time()) - 100_000 + self.roster.write_text(json.dumps(stale), encoding="utf-8") + + result = self.run_once() + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual([], self.sp_calls()) + + def test_the_watchdogs_own_sentinel_stops_the_pass(self) -> None: + self.seed(spent_weekly=1.0) + self.fixture.sentinel.write_text("", encoding="utf-8") + + result = self.run_once() + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual([], self.sp_calls()) + + def notifier_calls(self) -> list[str]: + if not self.fixture.notify_log.exists(): + return [] + return [ + line + for line in self.fixture.notify_log.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + def account_move_notifier_calls(self) -> list[str]: + return [ + line + for line in self.notifier_calls() + if "--title=A session moved to another subscription account" in line + ] + + def queue_a_notice(self, sentence: str = "session 3 moved from spent to fresh") -> str: + """Put one owed notice in the sandbox ledger, as a completed move would.""" + from lib.sessionkit_inventory import account_guard as sandbox_guard + + config = {"state_dir": str(self.fixture.state)} + # The kit's own state lock refuses anything but a private directory, + # and the shared watchdog fixture creates its state dir with default + # permissions. + self.fixture.state.chmod(0o700) + with mock.patch.dict( + os.environ, {"SESSION_KIT_STATE_DIR": str(self.fixture.state)}, clear=False + ): + token = sandbox_guard.begin_hop(config, "claude", UUID, "spent", "fresh") + sandbox_guard.commit_hop(config, "claude", UUID, token) + sandbox_guard.queue_notice(config, "claude", UUID, token, sentence) + return token + + def test_a_notice_that_cannot_be_delivered_is_retried_next_pass(self) -> None: + """Finding 3: a failed delivery must not count as having told them.""" + self.seed(spent_weekly=0.40) + token = self.queue_a_notice() + broken = self.fixture.bin / "broken-notify" + broken.write_text("#!/usr/bin/env bash\nexit 1\n", encoding="utf-8") + broken.chmod(0o755) + + first = self.run_once(SESSION_KIT_WATCHDOG_NOTIFY=str(broken)) + self.assertEqual(0, first.returncode, first.stderr) + self.assertIn("stays queued", self.fixture.log_text()) + + from lib.sessionkit_inventory import account_guard as sandbox_guard + + config = {"state_dir": str(self.fixture.state)} + with mock.patch.dict( + os.environ, {"SESSION_KIT_STATE_DIR": str(self.fixture.state)}, clear=False + ): + still_owed = sandbox_guard.pending_notices(config) + self.assertEqual([token], [item["token"] for item in still_owed]) + + # A working notifier on a later pass finally tells them. + second = self.run_once() + self.assertEqual(0, second.returncode, second.stderr) + self.assertEqual(1, len(self.account_move_notifier_calls())) + with mock.patch.dict( + os.environ, {"SESSION_KIT_STATE_DIR": str(self.fixture.state)}, clear=False + ): + self.assertEqual([], sandbox_guard.pending_notices(config)) + + def test_a_delivered_notice_is_never_sent_twice(self) -> None: + self.seed(spent_weekly=0.40) + self.queue_a_notice() + + self.run_once() + self.run_once() + + self.assertEqual(1, len(self.account_move_notifier_calls())) + + def test_the_kill_switch_does_not_cancel_a_debt_already_owed(self) -> None: + """Switching future moves off does not un-owe a message about a past one.""" + self.seed(spent_weekly=0.40) + self.queue_a_notice() + (self.fixture.state / "account-switching-off").write_text("", encoding="utf-8") + + self.run_once() + + self.assertEqual(1, len(self.account_move_notifier_calls())) + + def test_a_session_on_a_healthy_account_is_not_touched_when_another_is_spent( + self, + ) -> None: + self.seed(spent_weekly=1.0) + row = json.loads(self.fixture.inventory.read_text(encoding="utf-8")) + row["sessions"][0]["account_alias"] = "fresh" + self.fixture.inventory.write_text(json.dumps(row), encoding="utf-8") + + result = self.run_once() + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual([], self.sp_calls()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_account_picker_ux.py b/tests/test_account_picker_ux.py new file mode 100644 index 0000000..a4fd9cd --- /dev/null +++ b/tests/test_account_picker_ux.py @@ -0,0 +1,475 @@ +"""The two account pickers: one vocabulary, one Enter key, refusals that quote +the row. + +`sp account choices` is the only thing that decides whether an account can be +selected, and it says so per row in a `state` string. These tests hold both +shell renderers -- the new-session wizard step and the account switch -- to +printing that string verbatim, numbering every row including the ones they will +refuse, naming the refused row's own state when a person picks it, and staying +on the step afterwards instead of throwing the wizard away. + +The renderers are bash with embedded python, so the harness below sources the +module, replaces the four picker services it calls (temp files, the modal read, +the proof action, the refresh) and drives the real functions with a fabricated +choices document -- the same shape `sp account choices` prints. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import tempfile +import unittest + +from tests.support import REPO, run + + +ACTIONS = REPO / "lib" / "sh" / "shpool_login_actions.sh" +SP = REPO / "bin" / "sp" + +HARNESS = r""" +set -u +source "$ACTIONS_MODULE" + +sp_stub() { + # The only call these functions make: sp account choices <provider>. + cat "$CHOICES_JSON" +} +SP_CMD=sp_stub +TEMP_FILES=() +NEW_TEMP= +CONFIRM_FORGIVE=0 + +new_temp() { + NEW_TEMP=$(mktemp "$SANDBOX/$1.XXXXXX") || return 1 + TEMP_FILES+=("$NEW_TEMP") +} + +ANSWERS=() +mapfile -t ANSWERS < "$ANSWERS_FILE" +ANSWER_INDEX=0 +picker_modal_read() { + local __variable=$1 __prompt=$2 + if (( ANSWER_INDEX >= ${#ANSWERS[@]} )); then + printf '%s[input closed]\n' "$__prompt" + return 1 + fi + printf '%s%s\n' "$__prompt" "${ANSWERS[ANSWER_INDEX]}" + printf -v "$__variable" '%s' "${ANSWERS[ANSWER_INDEX]}" + ANSWER_INDEX=$((ANSWER_INDEX + 1)) +} + +run_proof_action() { + printf 'proof-action %s\n' "$*" +} +refresh_after_action() { :; } +""" + +GUIDED_TAIL = """ +guided_account "$PROVIDER" +printf 'rc=%s\\n' "$?" +printf 'account=%s\\n' "${GUIDED_ACCOUNT:-}" +""" + +SWITCH_TAIL = """ +change_account_number 6 "$PROVIDER" +printf 'rc=%s\\n' "$?" +""" + + +def account( + alias: str, + email: str, + *, + eligible: bool = True, + state: str | None = None, + plan: str = "max 20x", + recommended: bool = False, + u5h: float | None = None, + u7d: float | None = None, +) -> dict: + return { + "alias": alias, + "email": email, + "plan": plan, + "eligible": eligible, + "state": state if state is not None else ("ready" if eligible else "blocked"), + "health": "ok" if eligible else "expired", + "serving": False, + "u5h": u5h, + "u7d": u7d, + "recommended": recommended, + } + + +def document( + *rows: dict, + recommendation: str | None = None, + reason: str = "", + roster_state: str = "fresh", + advice_fresh: bool = True, + blocked: dict | None = None, +) -> dict: + return { + "schema_version": 3, + "provider": "claude", + "roster_fresh": roster_state == "fresh", + "roster_state": roster_state, + "advice_fresh": advice_fresh, + "recommendation": recommendation, + "recommendation_reason": reason, + "recommendation_blocked": blocked, + "choices": list(rows), + } + + +class PickerHarness(unittest.TestCase): + """Runs one real picker function against a fabricated choices document.""" + + def drive( + self, + choices: dict, + answers: list[str], + *, + surface: str = "guided", + provider: str = "claude", + ) -> str: + fixture_root = Path( + os.environ.get("SESSION_KIT_TEST_EXEC_ROOT", os.fspath(REPO)) + ) + with tempfile.TemporaryDirectory( + prefix=".account-picker-", dir=fixture_root + ) as name: + sandbox = Path(name) + choices_path = sandbox / "choices.json" + choices_path.write_text(json.dumps(choices), encoding="utf-8") + answers_path = sandbox / "answers" + answers_path.write_text( + "".join(f"{line}\n" for line in answers), encoding="utf-8" + ) + script = HARNESS + ( + GUIDED_TAIL if surface == "guided" else SWITCH_TAIL + ) + result = run( + ["bash", "-c", script], + env={ + "ACTIONS_MODULE": os.fspath(ACTIONS), + "CHOICES_JSON": os.fspath(choices_path), + "ANSWERS_FILE": os.fspath(answers_path), + "SANDBOX": os.fspath(sandbox), + "PROVIDER": provider, + }, + check=False, + ) + self.assertEqual( + "", result.stderr, f"the picker wrote to stderr:\n{result.stderr}" + ) + return result.stdout + + +class AccountRowVocabularyTests(PickerHarness): + def test_every_row_prints_the_state_the_core_decided(self) -> None: + output = self.drive( + document( + account("wren", "wren@example.com"), + account( + "old", + "old@example.com", + eligible=False, + state="blocked: health expired", + ), + ), + ["b"], + ) + self.assertIn(" 1 wren: wren@example.com | max 20x | ready", output) + self.assertIn( + " 2 old: old@example.com | max 20x | blocked: health expired | —", output + ) + + def test_the_switch_selector_prints_state_and_the_recommended_marker(self) -> None: + output = self.drive( + document( + account("wren", "wren@example.com"), + account("work", "work@example.com", recommended=True), + account( + "old", + "old@example.com", + eligible=False, + state="blocked: not in the account roster", + ), + recommendation="work", + ), + ["b"], + surface="switch", + ) + self.assertIn("Change to account", output) + # Padded to columns: the alias+email field is variable width and every + # field behind it used to ragged-edge (operator ruling, 2026-08-15). + self.assertIn( + "work: work@example.com | max 20x | ready" + " | — | recommended", + output, + ) + self.assertIn("blocked: not in the account roster", output) + + def test_usage_is_printed_per_row_when_the_feed_carries_it(self) -> None: + output = self.drive( + document( + account("wren", "wren@example.com", u5h=0.34, u7d=0.36), + account("work", "work@example.com"), + ), + ["b"], + ) + self.assertIn("wren@example.com | max 20x | ready | 5h 34% · 7d 36%", output) + # A row the feed says nothing about prints no usage rather than zeroes. + self.assertNotIn("work@example.com | max 20x | ready | 5h", output) + + def test_the_recommendation_reason_is_printed_under_the_list(self) -> None: + output = self.drive( + document( + account("wren", "wren@example.com"), + account("work", "work@example.com", recommended=True), + recommendation="work", + reason="weekly headroom is highest here", + ), + ["b"], + ) + self.assertIn("Why work: weekly headroom is highest here", output) + + def test_a_stale_feed_and_a_blocked_recommendation_are_both_named(self) -> None: + output = self.drive( + document( + account("wren", "wren@example.com", state="ready (feed stale)"), + account( + "work", + "work@example.com", + eligible=False, + state="blocked: health expired", + ), + roster_state="stale", + advice_fresh=False, + blocked={"alias": "work", "state": "blocked: health expired"}, + ), + ["b"], + ) + self.assertIn("Account health is unknown. Every enabled account is offered.", output) + self.assertIn("Rotation advice is stale or absent", output) + self.assertIn( + "work is advised but not selectable: blocked: health expired", output + ) + + def test_a_list_with_nothing_selectable_says_so(self) -> None: + output = self.drive( + document( + account( + "wren", + "wren@example.com", + eligible=False, + state="blocked: profile disabled", + ) + ), + ["b"], + ) + self.assertIn("No account here is selectable", output) + + +class AccountRefusalTests(PickerHarness): + def test_a_blocked_row_is_offered_and_refused_with_its_own_state(self) -> None: + output = self.drive( + document( + account("wren", "wren@example.com"), + account( + "old", + "old@example.com", + eligible=False, + state="blocked: health expired", + ), + ), + ["2", "1"], + ) + self.assertIn("Account 2 (old) is not selectable: blocked: health expired.", output) + self.assertIn("rc=0", output) + self.assertIn("account=wren", output) + + def test_a_refused_pick_asks_again_inside_the_account_step(self) -> None: + output = self.drive( + document( + account("wren", "wren@example.com"), + account("old", "old@example.com", eligible=False), + ), + ["2", "9", "1"], + ) + # Three prompts for three answers: the step never unwound to the + # dashboard between them. + self.assertEqual(3, output.count("account ❯")) + self.assertIn("account=wren", output) + + def test_an_out_of_range_pick_is_not_the_eligibility_refusal(self) -> None: + output = self.drive( + document(account("wren", "wren@example.com")), + ["4", "b"], + ) + self.assertIn("There is no account 4 on this screen. Numbers shown here work.", output) + self.assertNotIn("not selectable", output) + + def test_a_typed_word_is_told_what_the_step_accepts(self) -> None: + output = self.drive( + document(account("wren", "wren@example.com")), + ["wren", "b"], + ) + self.assertIn("That is not an account number. Numbers shown here work.", output) + + def test_b_backs_out_one_level_without_an_account(self) -> None: + output = self.drive( + document( + account("wren", "wren@example.com"), + account("work", "work@example.com"), + ), + ["b"], + ) + self.assertIn("rc=1", output) + self.assertIn("account=\n", output) + + def test_the_switch_selector_refuses_with_the_row_and_asks_again(self) -> None: + output = self.drive( + document( + account("wren", "wren@example.com"), + account( + "old", + "old@example.com", + eligible=False, + state="blocked: health error", + ), + ), + ["2", "1", "y"], + surface="switch", + ) + self.assertIn("Account 2 (old) is not selectable: blocked: health error.", output) + self.assertEqual(2, output.count("target account ❯")) + self.assertIn("proof-action picker-account-switch 6 wren", output) + + def test_an_unusable_alias_from_the_core_is_named(self) -> None: + output = self.drive( + document(account("Not An Alias", "odd@example.com")), + ["1"], + ) + self.assertIn("The account list is unreadable. Nothing changed.", output) + self.assertIn("rc=1", output) + + +class AccountEnterKeyTests(PickerHarness): + def test_enter_takes_the_recommended_account(self) -> None: + """One grammar on every screen (operator ruling, 2026-08-15). + + Enter used to be Back here while the very next step of the same wizard + made Enter "use the default project", so one key meant two things + depending on which screen you were on. It now takes the recommended + choice wherever a screen has one. Switching a RUNNING session's account + is deliberately still not a default -- see the switch test below. + """ + output = self.drive( + document( + account("wren", "wren@example.com"), + account("work", "work@example.com", recommended=True), + recommendation="work", + reason="weekly headroom is highest here", + ), + [""], + ) + self.assertIn("rc=0", output) + self.assertIn("account=work", output) + self.assertIn("↵ use work", output) + self.assertIn("b back", output) + + def test_the_recommended_account_is_taken_by_its_number(self) -> None: + output = self.drive( + document( + account("wren", "wren@example.com"), + account("work", "work@example.com", recommended=True), + recommendation="work", + ), + ["2"], + ) + self.assertIn("2 is the recommended work", output) + self.assertIn("account=work", output) + + def test_the_sole_selectable_account_is_the_enter_default(self) -> None: + output = self.drive( + document( + account("wren", "wren@example.com"), + account( + "old", + "old@example.com", + eligible=False, + state="blocked: health expired", + ), + ), + [""], + ) + self.assertIn("↵ use wren", output) + self.assertIn("account=wren", output) + + def test_two_selectable_accounts_have_no_enter_default(self) -> None: + output = self.drive( + document( + account("wren", "wren@example.com"), + account("work", "work@example.com"), + ), + [""], + ) + self.assertIn("↵ back", output) + self.assertIn("rc=1", output) + self.assertIn("account=\n", output) + + def test_the_switch_selector_never_defaults_to_a_change(self) -> None: + output = self.drive( + document(account("wren", "wren@example.com")), + [""], + surface="switch", + ) + self.assertIn("↵ back", output) + self.assertNotIn("proof-action", output) + + +class AccountEmptyListTests(PickerHarness): + def test_the_enrolment_message_replaces_the_list_and_the_footer(self) -> None: + output = self.drive(document(), []) + self.assertIn("No claude account is enrolled", output) + self.assertNotIn("Account\n", output) + self.assertNotIn("Back", output) + self.assertNotIn("account ❯", output) + + def test_the_switch_selector_says_there_is_nothing_to_change_to(self) -> None: + output = self.drive(document(), [], surface="switch", provider="codex") + self.assertIn("No codex account is enrolled", output) + self.assertNotIn("target account ❯", output) + + +class AccountHelpTests(unittest.TestCase): + """`sp help accounts` has to explain the rule the picker enforces.""" + + def help_text(self) -> str: + with tempfile.TemporaryDirectory( + prefix=".account-help-", + dir=Path(os.environ.get("SESSION_KIT_TEST_EXEC_ROOT", os.fspath(REPO))), + ) as name: + home = Path(name) / "home" + home.mkdir() + return run( + [SP, "help", "accounts"], env={"HOME": os.fspath(home)} + ).stdout + + def test_help_states_the_eligibility_rule(self) -> None: + text = self.help_text() + self.assertIn("health", text) + self.assertIn("serving", text) + + def test_help_names_the_feed_dependency_and_its_failure_mode(self) -> None: + text = self.help_text() + self.assertIn("configure-feeds", text) + self.assertIn("stale", text) + + def test_help_quotes_the_refusal_a_person_will_see(self) -> None: + text = self.help_text() + self.assertIn("not selectable", text) diff --git a/tests/test_account_switch_guard.py b/tests/test_account_switch_guard.py new file mode 100644 index 0000000..384eba5 --- /dev/null +++ b/tests/test_account_switch_guard.py @@ -0,0 +1,118 @@ +"""The account-switch tree guard: what counts as a recognized child. + +The guard refuses a switch while the managed shell's subtree holds a process +it cannot name, because the switch kills and relaunches that tree. The +allowlist is therefore a contract: everything a healthy provider session +legitimately runs must be on it, or switching becomes impossible in exactly +the sessions people use most. This file pins both directions. +""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import tempfile +import unittest + +from tests.support import REPO + +PICKER = REPO / "lib" / "sh" / "sp_picker.sh" + + +def run_guard(processes: list[dict], provider: str = "claude") -> int: + """Invoke account_switch_safe_tree against a canned process table.""" + with tempfile.TemporaryDirectory(prefix=".switch-guard-", dir=REPO) as raw: + base = Path(raw) + table = {"processes": processes} + core = base / "core.py" + core.write_text( + "import json,sys\n" + "if sys.argv[1:3] == ['platform', 'process-table']:\n" + f" print(json.dumps({json.dumps(table)}))\n", + encoding="utf-8", + ) + script = f""" +set -u +SK_STATE_DIR={base} +INVENTORY_CORE={core} +PICKER_REFUSED_STATUS=74 +sk_die() {{ printf '%s\\n' "$*" >&2; return 1; }} +source_region() {{ + # Only the guard function is needed; source the whole file with its + # entry-point execution disabled by running under `return`-safe sourcing. + : +}} +source {PICKER} 2>/dev/null || true +account_switch_safe_tree {provider} 100 1000 200 2000 +""" + completed = subprocess.run( + ["bash", "-c", script], + cwd=REPO, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + return completed.returncode + + +def tree(*extra_children: dict) -> list[dict]: + rows = [ + {"pid": 100, "ppid": 1, "start_ticks": 1000, "comm": "bash", "cmdline": ["bash"]}, + { + "pid": 200, + "ppid": 100, + "start_ticks": 2000, + "comm": "claude", + "cmdline": ["claude", "--resume", "x"], + }, + ] + rows.extend(extra_children) + return rows + + +class SwitchGuardTests(unittest.TestCase): + def test_a_bare_provider_tree_is_allowed(self) -> None: + self.assertEqual(0, run_guard(tree())) + + def test_a_project_hook_child_is_the_provider_working(self) -> None: + # Claude Code spawns .claude/hooks/ scripts itself; one running must + # not make the session unswitchable. + self.assertEqual( + 0, + run_guard( + tree( + { + "pid": 300, + "ppid": 200, + "start_ticks": 3000, + "comm": "python3", + "cmdline": [ + "python3", + "/srv/example/.claude/hooks/coordination-rewake.py", + ], + } + ) + ), + ) + + def test_an_unknown_child_still_refuses(self) -> None: + self.assertNotEqual( + 0, + run_guard( + tree( + { + "pid": 300, + "ppid": 200, + "start_ticks": 3000, + "comm": "python3", + "cmdline": ["python3", "/home/someone/miner.py"], + } + ) + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_accounts.py b/tests/test_accounts.py new file mode 100644 index 0000000..87bcaf3 --- /dev/null +++ b/tests/test_accounts.py @@ -0,0 +1,1048 @@ +from __future__ import annotations + +import contextlib +import io +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +from lib.sessionkit_inventory import accounts +from lib.sessionkit_inventory.common import CollectionError + + +CLAUDE_UUID = "00000000-0000-4000-8000-000000000101" +CODEX_UUID = "00000000-0000-4000-8000-000000000202" + + +class AccountProfileTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="session-kit-accounts.") + self.root = Path(self.temporary.name) + self.home = self.root / "home" + self.state = self.root / "state" + self.data = self.root / "data" + self.home.mkdir(mode=0o700) + self.state.mkdir(mode=0o700) + self.data.mkdir(mode=0o700) + self.registry = self.state / "accounts.json" + self.profile_root = self.data / "session-kit" / "accounts" + self.roster = self.root / "cli_accounts.json" + self.advice = self.root / "rotation_advice.json" + self.config = {"state_dir": str(self.state)} + self.environment = mock.patch.dict( + os.environ, + { + "HOME": str(self.home), + "XDG_DATA_HOME": str(self.data), + "SESSION_KIT_ACCOUNT_REGISTRY": str(self.registry), + "SESSION_KIT_ACCOUNT_ROOT": str(self.profile_root), + "SESSION_KIT_ACCOUNT_ROSTER": str(self.roster), + "SESSION_KIT_ROTATION_ADVICE": str(self.advice), + }, + clear=False, + ) + self.environment.start() + for name in ( + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + "OPENAI_API_KEY", + "CODEX_ACCESS_TOKEN", + "SESSION_KIT_ACCOUNT_ADVICE_MAX_AGE_SECONDS", + ): + os.environ.pop(name, None) + + def tearDown(self) -> None: + self.environment.stop() + self.temporary.cleanup() + + def profile_dir(self, provider: str, alias: str) -> Path: + path = self.profile_root / provider / alias + path.mkdir(mode=0o700, parents=True, exist_ok=True) + return path + + def seed_profiles(self, *rows: tuple[str, str, str]) -> None: + profiles = {} + for provider, alias, email in rows: + profile_dir = self.profile_dir(provider, alias) + if provider == "claude": + state_path = profile_dir / ".claude.json" + state_path.write_text( + json.dumps({"hasCompletedOnboarding": True}), + encoding="utf-8", + ) + state_path.chmod(0o600) + profiles[f"{provider}:{alias}"] = { + "provider": provider, + "alias": alias, + "email": email, + "profile_dir": str(profile_dir), + "legacy": False, + "plan": "max" if provider == "claude" else "plus", + "verified_at_unix_ms": 1_800_000_000_000, + "enabled": True, + } + accounts.write_registry( + self.config, + { + "schema_version": accounts.ACCOUNT_SCHEMA_VERSION, + "generation": 1, + "profiles": profiles, + "bindings": {}, + }, + ) + + def write_feed(self, path: Path, value: object) -> None: + path.write_text(json.dumps(value), encoding="utf-8") + path.chmod(0o600) + + def test_registry_round_trip_and_exact_provider_binding(self) -> None: + self.seed_profiles( + ("claude", "primary", "primary@invalid.example"), + ("codex", "primary", "primary@invalid.example"), + ) + + stored = accounts.bind( + self.config, "claude", CLAUDE_UUID.upper(), "primary", source="new" + ) + found = accounts.binding_for(self.config, "claude", CLAUDE_UUID) + + self.assertEqual(2, stored["generation"]) + self.assertEqual("primary", found["alias"]) + self.assertEqual("new", found["binding_source"]) + self.assertIsNone(accounts.binding_for(self.config, "codex", CLAUDE_UUID)) + self.assertEqual(0o600, self.registry.stat().st_mode & 0o777) + + def test_legacy_first_switch_proves_unique_artifact_and_binds_source(self) -> None: + self.seed_profiles( + ("claude", "source", "source@example.com"), + ("claude", "target", "target@example.com"), + ) + transcript = ( + self.profile_root + / "claude" + / "source" + / "projects" + / "repo" + / f"{CLAUDE_UUID}.jsonl" + ) + transcript.parent.mkdir(parents=True) + transcript.write_text("{}\n", encoding="utf-8") + transcript.chmod(0o600) + + found = accounts.source_profile_for_thread(self.config, "claude", CLAUDE_UUID) + + self.assertEqual("source", found["alias"]) + self.assertEqual( + "legacy-first-switch", + accounts.binding_for(self.config, "claude", CLAUDE_UUID)["binding_source"], + ) + + def test_registry_rejects_escaped_profile_and_cross_provider_binding(self) -> None: + invalid_profile = { + "schema_version": accounts.ACCOUNT_SCHEMA_VERSION, + "generation": 1, + "profiles": { + "claude:primary": { + "provider": "claude", + "alias": "primary", + "email": "primary@invalid.example", + "profile_dir": str(self.root / "escaped"), + "legacy": False, + "plan": "max", + "verified_at_unix_ms": 1, + "enabled": True, + } + }, + "bindings": {}, + } + with self.assertRaisesRegex(CollectionError, "escaped"): + accounts.write_registry(self.config, invalid_profile) + + self.seed_profiles( + ("claude", "primary", "primary@invalid.example"), + ("codex", "work", "work@example.com"), + ) + invalid_binding = accounts.load_registry(self.config) + invalid_binding["bindings"][f"claude:{CLAUDE_UUID}"] = { + "profile": "codex:work", + "bound_at_unix_ms": 1, + "source": "test", + } + with self.assertRaisesRegex(CollectionError, "binding identity"): + accounts.write_registry(self.config, invalid_binding) + + def test_choices_preselect_fresh_claude_advice_but_never_codex(self) -> None: + self.seed_profiles( + ("claude", "primary", "primary@invalid.example"), + ("claude", "backup", "backup@invalid.example"), + ("codex", "primary", "primary@invalid.example"), + ) + now = 1_800_000_000 + self.write_feed( + self.roster, + { + "ts": now, + "accounts": [ + { + "email": "primary@invalid.example", + "health": "ok", + "serving": True, + "u7d": 69, + }, + { + "email": "backup@invalid.example", + "health": "ok", + "serving": True, + "u7d": 0, + }, + ], + "codex_accounts": [ + { + "email": "primary@invalid.example", + "health": "ok", + "serving": True, + "u7d": 27, + } + ], + }, + ) + self.write_feed( + self.advice, + { + "ts": now, + "use_now": { + "account": "backup@invalid.example", + "why": "most weekly allowance remains", + }, + }, + ) + + with mock.patch.object(accounts.time, "time", return_value=now): + claude = accounts.account_choices(self.config, "claude") + codex = accounts.account_choices(self.config, "codex") + + self.assertEqual("backup", claude["recommendation"]) + self.assertEqual( + "most weekly allowance remains", claude["recommendation_reason"] + ) + self.assertTrue( + next(row for row in claude["choices"] if row["alias"] == "backup")[ + "eligible" + ] + ) + self.assertIsNone(codex["recommendation"]) + self.assertFalse(any(row["recommended"] for row in codex["choices"])) + + def test_configured_feeds_are_private_and_used_without_environment_overrides( + self, + ) -> None: + self.seed_profiles(("claude", "primary", "primary@invalid.example")) + now = int(accounts.time.time()) + self.write_feed( + self.roster, + { + "ts": now, + "accounts": [ + { + "email": "primary@invalid.example", + "health": "ok", + "serving": True, + } + ], + }, + ) + self.write_feed(self.advice, {"ts": now}) + + configured = accounts.configure_feeds( + self.config, str(self.roster), str(self.advice) + ) + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("SESSION_KIT_ACCOUNT_ROSTER", None) + os.environ.pop("SESSION_KIT_ROTATION_ADVICE", None) + choices = accounts.account_choices(self.config, "claude") + + self.assertEqual(str(self.roster), configured["roster_path"]) + self.assertTrue(choices["choices"][0]["eligible"]) + self.assertEqual( + 0o600, + accounts.feed_config_path(self.config).stat().st_mode & 0o777, + ) + + def test_launch_profile_reverifies_identity_and_scrubs_ambient_tokens(self) -> None: + self.seed_profiles(("claude", "primary", "primary@invalid.example")) + self.write_feed( + self.roster, + { + "ts": int(accounts.time.time()), + "accounts": [ + { + "email": "primary@invalid.example", + "health": "ok", + "serving": True, + } + ], + }, + ) + profile_dir = self.profile_root / "claude" / "primary" + with mock.patch.dict( + os.environ, + { + "ANTHROPIC_API_KEY": "must-not-pass", + "ANTHROPIC_AUTH_TOKEN": "must-not-pass", + "CLAUDE_CODE_OAUTH_TOKEN": "must-not-pass", + }, + clear=False, + ): + environment = accounts._provider_environment("claude", profile_dir) + self.assertEqual(str(profile_dir), environment["CLAUDE_CONFIG_DIR"]) + self.assertNotIn("ANTHROPIC_API_KEY", environment) + self.assertNotIn("ANTHROPIC_AUTH_TOKEN", environment) + self.assertNotIn("CLAUDE_CODE_OAUTH_TOKEN", environment) + + with mock.patch.object( + accounts, + "probe_identity", + return_value={ + "provider": "claude", + "email": "primary@invalid.example", + "plan": "max", + }, + ) as probe: + launched = accounts.launch_profile(self.config, "claude", "primary") + self.assertEqual("primary", launched["alias"]) + self.assertEqual("primary@invalid.example", launched["email"]) + probe.assert_called_once_with("claude", profile_dir) + + with mock.patch.object( + accounts, + "probe_identity", + return_value={ + "provider": "claude", + "email": "wrong@example.com", + "plan": "max", + }, + ): + with self.assertRaisesRegex(CollectionError, "does not match"): + accounts.launch_profile(self.config, "claude", "primary") + + self.write_feed( + self.roster, + { + "ts": int(accounts.time.time()), + "accounts": [ + { + "email": "primary@invalid.example", + "health": "error", + "serving": False, + } + ], + }, + ) + with mock.patch.object( + accounts, + "probe_identity", + return_value={ + "provider": "claude", + "email": "primary@invalid.example", + "plan": "max", + }, + ): + with self.assertRaisesRegex(CollectionError, "not selectable"): + accounts.launch_profile(self.config, "claude", "primary") + + def test_register_isolated_claude_profile_completes_onboarding(self) -> None: + profile_dir = self.profile_dir("claude", "primary") + state_path = profile_dir / ".claude.json" + state_path.write_text( + json.dumps({"oauthAccount": {"emailAddress": "primary@invalid.example"}}), + encoding="utf-8", + ) + state_path.chmod(0o600) + default_state = self.home / ".claude.json" + default_state.write_text( + json.dumps( + { + "projects": { + "/srv/trusted": {"hasTrustDialogAccepted": True}, + "/srv/untrusted": {"hasTrustDialogAccepted": False}, + } + } + ), + encoding="utf-8", + ) + default_state.chmod(0o644) + + with mock.patch.object( + accounts, + "probe_identity", + return_value={ + "provider": "claude", + "email": "primary@invalid.example", + "plan": "max", + }, + ): + accounts._register_profile( + self.config, + "claude", + "primary", + "primary@invalid.example", + profile_dir, + legacy=False, + ) + + stored = json.loads(state_path.read_text(encoding="utf-8")) + self.assertTrue(stored["hasCompletedOnboarding"]) + self.assertEqual( + "primary@invalid.example", stored["oauthAccount"]["emailAddress"] + ) + self.assertTrue(stored["projects"]["/srv/trusted"]["hasTrustDialogAccepted"]) + self.assertNotIn("/srv/untrusted", stored["projects"]) + self.assertEqual(0o600, state_path.stat().st_mode & 0o777) + + def provision(self, alias: str = "primary") -> Path: + """Enroll one isolated Claude profile and return its state file.""" + profile_dir = self.profile_dir("claude", alias) + state_path = profile_dir / ".claude.json" + if not state_path.exists(): + state_path.write_text( + json.dumps( + {"oauthAccount": {"emailAddress": f"{alias}@invalid.example"}} + ), + encoding="utf-8", + ) + state_path.chmod(0o600) + with mock.patch.object( + accounts, + "probe_identity", + return_value={ + "provider": "claude", + "email": f"{alias}@invalid.example", + "plan": "max", + }, + ): + accounts._register_profile( + self.config, + "claude", + alias, + f"{alias}@invalid.example", + profile_dir, + legacy=False, + ) + return state_path + + def write_default_state(self, **extra: object) -> None: + default_state = self.home / ".claude.json" + default_state.write_text(json.dumps(dict(extra)), encoding="utf-8") + default_state.chmod(0o644) + + def test_a_fresh_profile_does_not_open_onto_a_first_run_offer(self) -> None: + """A brand-new session came up on "Make auto mode your default + permission mode?" and the operator could not answer it. A fresh kit + profile had never been shown that offer; the default profile had.""" + self.write_default_state(hasSeenAutoDefaultNudge=True) + + stored = json.loads(self.provision().read_text(encoding="utf-8")) + + self.assertIs(True, stored["hasSeenAutoDefaultNudge"]) + # Seen, not answered: the permission mode is not chosen here. + self.assertNotIn("defaultMode", stored) + self.assertNotIn("permissions", stored) + + def test_an_offer_the_operator_never_saw_is_not_marked_seen(self) -> None: + """Only what they have already been shown, on their own default profile.""" + self.write_default_state() + + stored = json.loads(self.provision().read_text(encoding="utf-8")) + + self.assertNotIn("hasSeenAutoDefaultNudge", stored) + + def test_a_value_the_profile_already_holds_is_never_overwritten(self) -> None: + """An existing value is their answer, in either direction.""" + self.write_default_state(hasSeenAutoDefaultNudge=True) + profile_dir = self.profile_dir("claude", "primary") + state_path = profile_dir / ".claude.json" + state_path.write_text( + json.dumps( + { + "oauthAccount": {"emailAddress": "primary@invalid.example"}, + "hasSeenAutoDefaultNudge": False, + } + ), + encoding="utf-8", + ) + state_path.chmod(0o600) + + stored = json.loads(self.provision().read_text(encoding="utf-8")) + + self.assertIs(False, stored["hasSeenAutoDefaultNudge"]) + + def test_a_concurrent_provider_write_is_not_lost(self) -> None: + """Claude owns this file too. A key it adds mid-provision survives. + + The provider writes between this function's read and its write; the + re-read before the atomic replace is what keeps that key. + """ + self.write_default_state(hasSeenAutoDefaultNudge=True) + profile_dir = self.profile_dir("claude", "primary") + state_path = profile_dir / ".claude.json" + state_path.write_text( + json.dumps({"oauthAccount": {"emailAddress": "primary@invalid.example"}}), + encoding="utf-8", + ) + state_path.chmod(0o600) + + real_load = accounts._load_owned_claude_state + + def provider_writes_midway(path): + """Stand in for Claude Code saving the file while this runs.""" + result = real_load(path) + current = json.loads(state_path.read_text(encoding="utf-8")) + current["lastReleaseNotesSeen"] = "2.1.233" + state_path.write_text(json.dumps(current), encoding="utf-8") + return result + + with mock.patch.object( + accounts, "_load_owned_claude_state", side_effect=provider_writes_midway + ): + self.provision() + + stored = json.loads(state_path.read_text(encoding="utf-8")) + self.assertEqual("2.1.233", stored["lastReleaseNotesSeen"]) # not clobbered + self.assertIs(True, stored["hasSeenAutoDefaultNudge"]) # and ours landed + self.assertTrue(stored["hasCompletedOnboarding"]) + self.assertEqual(0o600, state_path.stat().st_mode & 0o777) + + def test_a_profile_enrolled_before_this_shipped_still_gets_the_marker( + self, + ) -> None: + """The operator hit this on an ALREADY enrolled account. + + A fix that only covers profiles created from now on has not fixed it + for them, so the marker is applied when a profile is resumed too. + """ + self.write_default_state(hasSeenAutoDefaultNudge=True) + profile_dir = self.profile_dir("claude", "primary") + state_path = profile_dir / ".claude.json" + # An old profile: onboarded long ago, no first-run marker. + state_path.write_text( + json.dumps( + { + "oauthAccount": {"emailAddress": "primary@invalid.example"}, + "hasCompletedOnboarding": True, + } + ), + encoding="utf-8", + ) + state_path.chmod(0o600) + self.seed_profiles(("claude", "primary", "primary@invalid.example")) + + with mock.patch.object( + accounts, + "probe_identity", + return_value={ + "provider": "claude", + "email": "primary@invalid.example", + "plan": "max", + }, + ): + accounts.resume_profile(self.config, "claude", "primary") + + stored = json.loads(state_path.read_text(encoding="utf-8")) + self.assertIs(True, stored["hasSeenAutoDefaultNudge"]) + + def test_a_provider_value_this_code_does_not_understand_survives(self) -> None: + """`projects` is the provider's. An unexpected shape is not ours to + normalise away while adding an unrelated marker (Codex finding 7).""" + self.write_default_state( + hasSeenAutoDefaultNudge=True, + projects={"/srv/trusted": {"hasTrustDialogAccepted": True}}, + ) + profile_dir = self.profile_dir("claude", "primary") + state_path = profile_dir / ".claude.json" + state_path.write_text( + json.dumps( + { + "oauthAccount": {"emailAddress": "primary@invalid.example"}, + "projects": "provider-owned-value", + } + ), + encoding="utf-8", + ) + state_path.chmod(0o600) + + stored = json.loads(self.provision().read_text(encoding="utf-8")) + + self.assertEqual("provider-owned-value", stored["projects"]) + self.assertIs(True, stored["hasSeenAutoDefaultNudge"]) + + def test_a_provider_save_that_lands_after_the_final_read_is_not_lost( + self, + ) -> None: + """The half of the race the re-read never covered (lane B F7, Codex 5). + + Re-reading before the replace narrows the window; it cannot close it, + because there is always a gap after the LAST read. Claude saves this + file as `.tmp.<pid>.<hex>` followed by a rename, so its save changes + the inode -- which is what the compare-and-swap catches. The write is + thrown away and retried against the new content rather than replacing + it. + """ + self.write_default_state(hasSeenAutoDefaultNudge=True) + profile_dir = self.profile_dir("claude", "primary") + state_path = profile_dir / ".claude.json" + state_path.write_text( + json.dumps({"oauthAccount": {"emailAddress": "primary@invalid.example"}}), + encoding="utf-8", + ) + state_path.chmod(0o600) + + real_read = accounts.read_private_json + reads = {"of_the_profile": 0} + + def claude_saves_after_the_read(path, **keywords): + result = real_read(path, **keywords) + if Path(path) != state_path: + return result + reads["of_the_profile"] += 1 + # Read 1 is the function's opening read; read 2 is the one inside + # the write attempt, and the provider lands immediately after it -- + # in the gap a re-read can never cover. + if reads["of_the_profile"] == 2: + current = json.loads(state_path.read_text(encoding="utf-8")) + current["lastReleaseNotesSeen"] = "2.1.233" + scratch = state_path.parent / (state_path.name + ".tmp.9999.abc") + scratch.write_text(json.dumps(current), encoding="utf-8") + scratch.chmod(0o600) + os.replace(scratch, state_path) # exactly how Claude saves it + return result + + with mock.patch.object( + accounts, "read_private_json", side_effect=claude_saves_after_the_read + ): + self.provision() + + # If the injection never fired, or the swap never retried, this test + # proves nothing at EITHER revision -- the false-differential trap the + # round-1 lanes were caught by. Exactly three reads of the profile is + # the signature of the swap working: the opening read, the attempt the + # provider beat, and the retry that merged its key and won. Two would + # mean the collision was never detected. + self.assertEqual(3, reads["of_the_profile"]) + stored = json.loads(state_path.read_text(encoding="utf-8")) + self.assertEqual("2.1.233", stored["lastReleaseNotesSeen"]) # never lost + self.assertIs(True, stored["hasSeenAutoDefaultNudge"]) # ours retried in + self.assertTrue(stored["hasCompletedOnboarding"]) + self.assertEqual(0o600, state_path.stat().st_mode & 0o777) + + def test_nothing_is_written_while_claude_holds_its_own_config_lock(self) -> None: + """Claude takes a mkdir lock at `<config>.lock` while it saves. + + This code does not TAKE that lock: Claude acquires it with retries 0, so + a held lock does not make Claude wait, it makes Claude fall back to an + unlocked write that skips its own re-read. Holding it would turn an + ordinary collision into a guaranteed overwrite. Its value is as a + signal, and the answer to the signal is to stand aside. + """ + self.write_default_state(hasSeenAutoDefaultNudge=True) + profile_dir = self.profile_dir("claude", "primary") + state_path = profile_dir / ".claude.json" + original = json.dumps( + {"oauthAccount": {"emailAddress": "primary@invalid.example"}} + ) + state_path.write_text(original, encoding="utf-8") + state_path.chmod(0o600) + (profile_dir / (state_path.name + ".lock")).mkdir() + + noise = io.StringIO() + with contextlib.redirect_stderr(noise): + self.provision() + + self.assertEqual(original, state_path.read_text(encoding="utf-8")) + # The exact refusal, not any message this module happens to print. + self.assertIn("is being written by Claude right now", noise.getvalue()) + self.assertNotIn("hasSeenAutoDefaultNudge", state_path.read_text("utf-8")) + + def test_a_default_state_past_the_old_one_MiB_cap_still_marks_the_offer( + self, + ) -> None: + """Lane B F8. `~/.claude.json` grows with project history and never + shrinks, and crossing the kit's registry cap used to stop the marker + silently -- so the only symptom would be this bug coming back with no + explanation. A real registry has been measured at 90,786 bytes.""" + self.write_default_state( + hasSeenAutoDefaultNudge=True, filler="x" * (1024 * 1024 + 4096) + ) + self.assertGreater( + (self.home / ".claude.json").stat().st_size, accounts.MAX_REGISTRY_BYTES + ) + + stored = json.loads(self.provision().read_text(encoding="utf-8")) + + self.assertIs(True, stored["hasSeenAutoDefaultNudge"]) + + def test_a_default_state_it_cannot_use_says_why(self) -> None: + """Every unmarked offer now has a stated reason. None of them was + audible before, and silence is how F8 would have reached them.""" + (self.home / ".claude.json").write_text("{not json", encoding="utf-8") + (self.home / ".claude.json").chmod(0o644) + + noise = io.StringIO() + with contextlib.redirect_stderr(noise): + stored = json.loads(self.provision().read_text(encoding="utf-8")) + + self.assertNotIn("hasSeenAutoDefaultNudge", stored) + self.assertIn("not marking", noise.getvalue()) + self.assertIn("not valid JSON", noise.getvalue()) + + def test_kill_switch_blocks_enroll_launch_and_switch_prepare(self) -> None: + self.seed_profiles( + ("claude", "source", "source@example.com"), + ("claude", "target", "target@example.com"), + ) + accounts.bind(self.config, "claude", CLAUDE_UUID, "source", source="fixture") + accounts.kill_switch_path(self.config).touch(mode=0o600) + + with mock.patch.object(accounts.subprocess, "run") as provider: + with self.assertRaisesRegex(CollectionError, "disabled"): + accounts.enroll(self.config, "claude", "newone", "newone@example.com") + with self.assertRaisesRegex(CollectionError, "disabled"): + accounts.launch_profile(self.config, "claude", "source") + with self.assertRaisesRegex(CollectionError, "disabled"): + accounts.prepare_switch( + self.config, + "claude", + CLAUDE_UUID, + "source", + "target", + str(self.root), + "session-2", + ) + provider.assert_not_called() + with mock.patch.object( + accounts, + "probe_identity", + return_value={ + "provider": "claude", + "email": "source@example.com", + "plan": "max", + }, + ): + self.assertEqual( + "source", + accounts.resume_profile(self.config, "claude", "source")["alias"], + ) + + +class AccountSwitchTransactionTests(unittest.TestCase): + setUp = AccountProfileTests.setUp + tearDown = AccountProfileTests.tearDown + profile_dir = AccountProfileTests.profile_dir + seed_profiles = AccountProfileTests.seed_profiles + write_feed = AccountProfileTests.write_feed + + def seed_switch(self, provider: str, uuid: str) -> tuple[Path, Path]: + self.seed_profiles( + (provider, "source", "source@example.com"), + (provider, "target", "target@example.com"), + ) + key = "accounts" if provider == "claude" else "codex_accounts" + self.write_feed( + self.roster, + { + "ts": int(accounts.time.time()), + key: [ + { + "email": "target@example.com", + "health": "ok", + "serving": True, + } + ], + }, + ) + accounts.bind(self.config, provider, uuid, "source", source="fixture") + source = self.profile_root / provider / "source" + target = self.profile_root / provider / "target" + if provider == "claude": + artifact = source / "projects" / "-srv-test" / f"{uuid}.jsonl" + else: + artifact = ( + source + / "sessions" + / "2026" + / "08" + / "10" + / f"rollout-test-{uuid}.jsonl" + ) + artifact.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + artifact.write_text("source history\n", encoding="utf-8") + artifact.chmod(0o600) + return source, target + + def test_prepare_apply_and_commit_move_exact_claude_history_and_binding( + self, + ) -> None: + source, target = self.seed_switch("claude", CLAUDE_UUID) + transcript = Path("projects") / "-srv-test" / f"{CLAUDE_UUID}.jsonl" + + prepared = accounts.prepare_switch( + self.config, + "claude", + CLAUDE_UUID.upper(), + "source", + "target", + str(self.root), + "session-2", + ) + applied = accounts.apply_switch(self.config, prepared["txid"]) + + self.assertEqual("target", applied["alias"]) + self.assertEqual("source history\n", (target / transcript).read_text()) + self.assertTrue((source / transcript).exists()) + self.assertEqual( + "target_launching", + accounts.transaction(self.config, prepared["txid"])["status"], + ) + + with mock.patch.object( + accounts, + "probe_identity", + return_value={ + "provider": "claude", + "email": "target@example.com", + "plan": "max", + }, + ): + committed = accounts.commit_switch(self.config, prepared["txid"]) + + self.assertEqual("committed", committed["status"]) + self.assertFalse((source / transcript).exists()) + self.assertTrue((target / transcript).exists()) + self.assertEqual( + "target", + accounts.binding_for(self.config, "claude", CLAUDE_UUID)["alias"], + ) + + def test_rollback_restores_newest_codex_history_and_original_binding(self) -> None: + source, target = self.seed_switch("codex", CODEX_UUID) + rollout = ( + Path("sessions") / "2026" / "08" / "10" / f"rollout-test-{CODEX_UUID}.jsonl" + ) + prepared = accounts.prepare_switch( + self.config, + "codex", + CODEX_UUID, + "source", + "target", + str(self.root), + "session-8", + ) + accounts.apply_switch(self.config, prepared["txid"]) + (target / rollout).write_text("target newest history\n", encoding="utf-8") + # Model a commit interrupted after it published the target binding. + accounts.bind( + self.config, + "codex", + CODEX_UUID, + "target", + source="interrupted-commit", + ) + + rolled_back = accounts.rollback_switch(self.config, prepared["txid"]) + + self.assertEqual("source", rolled_back["alias"]) + self.assertEqual("target newest history\n", (source / rollout).read_text()) + self.assertFalse((target / rollout).exists()) + self.assertEqual( + "source", + accounts.binding_for(self.config, "codex", CODEX_UUID)["alias"], + ) + self.assertEqual( + "account-switch-rollback", + accounts.binding_for(self.config, "codex", CODEX_UUID)["binding_source"], + ) + self.assertEqual( + "rolled_back", + accounts.transaction(self.config, prepared["txid"])["status"], + ) + + +class RulesSyncTests(unittest.TestCase): + """One rules file has to reach every provider home and every profile. + + The interesting cases are not "did it copy": they are whether a rulebook's + own text survives the write, whether adopting the sync destroys what a + provider already relied on, and whether drift is actually reported. + """ + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="session-kit-rules.") + self.root = Path(self.temporary.name) + self.home = self.root / "home" + self.state = self.root / "state" + self.data = self.root / "data" + self.config_home = self.root / "config" + for path in (self.home, self.state, self.data, self.config_home): + path.mkdir(mode=0o700, parents=True) + (self.home / ".claude").mkdir(mode=0o700) + (self.home / ".codex").mkdir(mode=0o700) + self.registry = self.state / "accounts.json" + self.profile_root = self.data / "session-kit" / "accounts" + self.rules = self.config_home / "agent-rules" / "universal-rules.md" + self.rules.parent.mkdir(mode=0o700, parents=True) + self.config = {"state_dir": str(self.state)} + self.environment = mock.patch.dict( + os.environ, + { + "HOME": str(self.home), + "XDG_DATA_HOME": str(self.data), + "XDG_CONFIG_HOME": str(self.config_home), + "SESSION_KIT_ACCOUNT_REGISTRY": str(self.registry), + "SESSION_KIT_ACCOUNT_ROOT": str(self.profile_root), + }, + clear=False, + ) + self.environment.start() + os.environ.pop("SESSION_KIT_RULES_FILE", None) + + def tearDown(self) -> None: + self.environment.stop() + self.temporary.cleanup() + + def claude_rulebook(self) -> Path: + return self.home / ".claude" / "CLAUDE.md" + + def codex_rulebook(self) -> Path: + return self.home / ".codex" / "AGENTS.md" + + def write_default_rulebooks(self, claude: str, codex: str) -> None: + self.claude_rulebook().write_text(claude, encoding="utf-8") + self.codex_rulebook().write_text(codex, encoding="utf-8") + + def test_missing_rules_file_changes_nothing(self) -> None: + self.write_default_rulebooks("claude text\n", "codex text\n") + result = accounts.sync_rules(self.config) + self.assertFalse(result["ok"]) + self.assertEqual(result["targets"], []) + self.assertEqual( + self.claude_rulebook().read_text(encoding="utf-8"), "claude text\n" + ) + + def test_first_sync_keeps_the_existing_rulebook_text(self) -> None: + self.rules.write_text("# Shared\n\nbe careful\n", encoding="utf-8") + self.write_default_rulebooks("provider only note\n", "codex only note\n") + result = accounts.sync_rules(self.config) + self.assertTrue(result["ok"]) + claude = self.claude_rulebook().read_text(encoding="utf-8") + self.assertIn("be careful", claude) + self.assertIn("provider only note", claude) + codex = self.codex_rulebook().read_text(encoding="utf-8") + self.assertIn("be careful", codex) + self.assertIn("codex only note", codex) + + def test_resync_replaces_only_the_block(self) -> None: + self.rules.write_text("first rule\n", encoding="utf-8") + self.write_default_rulebooks("tail note\n", "codex tail\n") + accounts.sync_rules(self.config) + self.rules.write_text("second rule\n", encoding="utf-8") + accounts.sync_rules(self.config) + claude = self.claude_rulebook().read_text(encoding="utf-8") + self.assertIn("second rule", claude) + self.assertNotIn("first rule", claude) + self.assertIn("tail note", claude) + self.assertEqual(claude.count(accounts.RULES_END), 1) + + def test_a_symlinked_rulebook_is_skipped_by_name_not_aborted_on(self) -> None: + self.rules.write_text("a rule\n", encoding="utf-8") + self.write_default_rulebooks("claude tail\n", "codex tail\n") + elsewhere = self.root / "dotfiles-CLAUDE.md" + elsewhere.write_text("managed elsewhere\n", encoding="utf-8") + self.claude_rulebook().unlink() + self.claude_rulebook().symlink_to(elsewhere) + result = accounts.sync_rules(self.config) + self.assertTrue(result["ok"]) + states = {t["path"]: t["state"] for t in result["targets"]} + self.assertEqual(states[str(self.claude_rulebook())], "skipped") + # The link and its target are both untouched, and the other rulebook + # was still written -- one refused target does not end the run. + self.assertEqual(elsewhere.read_text(encoding="utf-8"), "managed elsewhere\n") + self.assertIn("a rule", self.codex_rulebook().read_text(encoding="utf-8")) + + def test_a_rulebook_that_is_not_text_is_skipped_not_a_traceback(self) -> None: + self.rules.write_text("a rule\n", encoding="utf-8") + self.write_default_rulebooks("claude tail\n", "codex tail\n") + self.claude_rulebook().write_bytes(b"\xff\xfe\x00 not text") + result = accounts.sync_rules(self.config) + self.assertTrue(result["ok"]) + states = {t["path"]: t["state"] for t in result["targets"]} + self.assertEqual(states[str(self.claude_rulebook())], "skipped") + self.assertEqual(self.claude_rulebook().read_bytes(), b"\xff\xfe\x00 not text") + self.assertIn("a rule", self.codex_rulebook().read_text(encoding="utf-8")) + + def test_check_reports_drift_without_writing(self) -> None: + self.rules.write_text("first rule\n", encoding="utf-8") + self.write_default_rulebooks("tail\n", "tail\n") + accounts.sync_rules(self.config) + self.rules.write_text("second rule\n", encoding="utf-8") + before = self.claude_rulebook().read_text(encoding="utf-8") + result = accounts.sync_rules(self.config, check=True) + self.assertEqual(result["drifted"], 2) + self.assertEqual(self.claude_rulebook().read_text(encoding="utf-8"), before) + + def test_check_is_clean_right_after_a_sync(self) -> None: + self.rules.write_text("a rule\n", encoding="utf-8") + self.write_default_rulebooks("tail\n", "tail\n") + accounts.sync_rules(self.config) + result = accounts.sync_rules(self.config, check=True) + self.assertEqual(result["drifted"], 0) + self.assertTrue(all(item["state"] == "current" for item in result["targets"])) + + def test_enrolled_profiles_are_included(self) -> None: + self.rules.write_text("a rule\n", encoding="utf-8") + self.write_default_rulebooks("tail\n", "tail\n") + profile = self.profile_root / "claude" / "work" + profile.mkdir(mode=0o700, parents=True) + (profile / "CLAUDE.md").write_text("profile tail\n", encoding="utf-8") + with mock.patch.object( + accounts, + "list_profiles", + return_value=[ + { + "provider": "claude", + "alias": "work", + "profile_dir": str(profile), + } + ], + ): + result = accounts.sync_rules(self.config) + labels = {item["label"] for item in result["targets"]} + self.assertIn("claude:work", labels) + text = (profile / "CLAUDE.md").read_text(encoding="utf-8") + self.assertIn("a rule", text) + self.assertIn("profile tail", text) + + def test_rules_file_can_be_pointed_elsewhere(self) -> None: + elsewhere = self.root / "elsewhere.md" + elsewhere.write_text("relocated rule\n", encoding="utf-8") + os.environ["SESSION_KIT_RULES_FILE"] = str(elsewhere) + try: + self.write_default_rulebooks("tail\n", "tail\n") + accounts.sync_rules(self.config) + finally: + os.environ.pop("SESSION_KIT_RULES_FILE", None) + self.assertIn( + "relocated rule", self.claude_rulebook().read_text(encoding="utf-8") + ) + + def test_an_implausibly_large_rules_file_is_refused(self) -> None: + self.rules.write_text("x" * (accounts.MAX_RULES_BYTES + 1), encoding="utf-8") + self.write_default_rulebooks("tail\n", "tail\n") + with self.assertRaises(CollectionError): + accounts.sync_rules(self.config) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_app_server_gate.py b/tests/test_app_server_gate.py new file mode 100644 index 0000000..0f84b49 --- /dev/null +++ b/tests/test_app_server_gate.py @@ -0,0 +1,234 @@ +"""The Codex App Server launch gate: what arms it, and what it says when it cannot. + +Both checks run the exact program the login shell runs: the gate and the +warning are extracted from bashrc/shpool.bashrc so a drifted heredoc fails +here instead of silently on a real login. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +from tests.support import REPO + +SHELL_PATH = REPO / "bashrc" / "shpool.bashrc" +SHELL = SHELL_PATH.read_text(encoding="utf-8") +GATE_ANCHOR = "__sk_coord_gate=$(python3 - \"$__sk_coord_config\" \"$__sk_cwd\" <<'PY'\n" +WARNING_ANCHOR = ( + "python3 - \"$__sk_state_root\" \"$SHPOOL_SESSION_NAME\" <<'PY' || true\n" +) +WEAK_STATE_WARNING = ( + "[session-kit: Codex App Server disabled: ~/.local/state must be mode 0700]" +) + + +def heredoc(anchor: str) -> str: + """The exact shell-embedded program that follows an anchor line.""" + start = SHELL.index(anchor) + len(anchor) + return SHELL[start : SHELL.index("\nPY\n", start)] + "\n" + + +class GateProgramTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix=".gate-test-") + self.base = Path(self.temporary.name) + self.script = self.base / "gate.py" + self.script.write_text(heredoc(GATE_ANCHOR), encoding="utf-8") + self.repo = self.base / "repo" + self.repo.mkdir() + self.elsewhere = self.base / "elsewhere" + self.elsewhere.mkdir() + self.broker = self.repo / "provider_broker.py" + self.broker.write_text("# fixture broker\n", encoding="utf-8") + self.broker.chmod(0o644) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def config(self, name: str = "coordination", **overrides: object) -> Path: + payload: dict[str, object] = { + "codex_app_server": True, + "codex_broker": os.fspath(self.broker), + "repo_root": os.fspath(self.repo), + } + payload.update(overrides) + path = self.base / f"{name}.json" + path.write_text(json.dumps(payload) + "\n", encoding="utf-8") + path.chmod(0o600) + return path + + def gate(self, config: Path, cwd: Path) -> str: + completed = subprocess.run( + [sys.executable, os.fspath(self.script), os.fspath(config), os.fspath(cwd)], + text=True, + capture_output=True, + check=True, + ) + return completed.stdout.strip() + + def test_repo_cwd_still_arms_the_broker_byte_for_byte(self) -> None: + for config in (self.config(), self.config(codex_app_server_all=True)): + self.assertEqual(os.fspath(self.broker), self.gate(config, self.repo)) + + def test_foreign_cwd_stays_dark_without_the_wider_key(self) -> None: + for extra in ({}, {"codex_app_server_all": False}, {"codex_app_server_all": 1}): + config = self.config(**extra) + self.assertEqual("", self.gate(config, self.elsewhere)) + + def test_wider_key_arms_the_server_alone_outside_the_repo(self) -> None: + config = self.config(codex_app_server_all=True) + self.assertEqual("-", self.gate(config, self.elsewhere)) + + def dropped_key_config(self, key: str) -> Path: + config = self.config(name=f"without-{key}", codex_app_server_all=True) + payload = json.loads(config.read_text(encoding="utf-8")) + payload.pop(key) + config.write_text(json.dumps(payload) + "\n", encoding="utf-8") + config.chmod(0o600) + return config + + def test_every_existing_validation_still_refuses_the_wider_key(self) -> None: + linked = self.repo / "linked_broker.py" + linked.symlink_to(self.broker) + cases: dict[str, Path] = { + "app-server off": self.config( + name="off", codex_app_server=False, codex_app_server_all=True + ), + "app-server key absent": self.dropped_key_config("codex_app_server"), + "repo root key absent": self.dropped_key_config("repo_root"), + "relative broker": self.config( + name="relative", + codex_broker="provider_broker.py", + codex_app_server_all=True, + ), + "absent broker": self.config( + name="absent", + codex_broker=os.fspath(self.repo / "gone.py"), + codex_app_server_all=True, + ), + "symlinked broker": self.config( + name="linked", + codex_broker=os.fspath(linked), + codex_app_server_all=True, + ), + } + for label, config in cases.items(): + for cwd in (self.repo, self.elsewhere): + self.assertEqual("", self.gate(config, cwd), msg=label) + + # A group-writable config, an oversized one, and a writable broker are + # each refused on their own. + writable = self.config(name="writable", codex_app_server_all=True) + writable.chmod(0o660) + self.assertEqual("", self.gate(writable, self.repo)) + + oversized = self.config( + name="oversized", codex_app_server_all=True, filler="x" * 9000 + ) + self.assertEqual("", self.gate(oversized, self.repo)) + + self.broker.chmod(0o666) + config = self.config(name="writable-broker", codex_app_server_all=True) + self.assertEqual("", self.gate(config, self.elsewhere)) + self.broker.chmod(0o644) + + def test_malformed_config_is_silent(self) -> None: + path = self.base / "coordination.json" + path.write_text("{not json", encoding="utf-8") + path.chmod(0o600) + self.assertEqual("", self.gate(path, self.repo)) + + +class GateWiringTests(unittest.TestCase): + def test_gate_arms_the_server_and_the_broker_stays_repo_scoped(self) -> None: + # The config file itself is still read only when it is a real, + # non-symlinked regular file. + self.assertIn( + "[[ -f $__sk_coord_config && ! -L $__sk_coord_config ]]", SHELL + ) + # Only an absolute path or the bare "-" ever arms anything. + self.assertIn("case $__sk_coord_gate in", SHELL) + self.assertIn("/*) __sk_coord_broker=$__sk_coord_gate ;;", SHELL) + self.assertIn("*) __sk_coord_gate= ;;", SHELL) + launch = SHELL.index("if [[ -n $__sk_coord_gate ]]; then") + remote = SHELL.index('__sk_codex_remote=(--remote "unix://$__sk_app_socket")') + broker_guard = SHELL.index("if [[ -n $__sk_coord_broker ]]; then") + spawn = SHELL.index('python3 "$__sk_coord_broker" "${__sk_broker_args[@]}"') + self.assertLess(launch, remote) + self.assertLess(remote, broker_guard) + self.assertLess(broker_guard, spawn) + + +class PermissionWarningTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix=".gate-warn-") + self.base = Path(self.temporary.name) + self.script = self.base / "warn.py" + self.script.write_text(heredoc(WARNING_ANCHOR), encoding="utf-8") + self.state = self.base / "state" + self.state.mkdir(mode=0o700) + self.session = "s20260807-093118-829518" + + def tearDown(self) -> None: + self.state.chmod(0o700) + self.temporary.cleanup() + + def warn(self) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, os.fspath(self.script), os.fspath(self.state), self.session], + text=True, + capture_output=True, + check=True, + ) + + def log_path(self) -> Path: + return ( + self.state / "session-kit" / "app-server" / self.session / "app-server.log" + ) + + def test_weak_state_root_names_the_exact_reason(self) -> None: + self.state.chmod(0o755) + completed = self.warn() + self.assertEqual(WEAK_STATE_WARNING, completed.stderr.strip()) + self.assertEqual("", completed.stdout) + + def test_private_state_root_reports_the_other_failure_instead(self) -> None: + completed = self.warn() + self.assertIn("Codex App Server disabled", completed.stderr) + self.assertNotIn("mode 0700", completed.stderr) + + def test_the_line_is_appended_to_an_existing_log_only(self) -> None: + self.state.chmod(0o755) + self.warn() + self.assertFalse(self.log_path().exists()) + + self.state.chmod(0o700) + self.log_path().parent.mkdir(parents=True, mode=0o700) + self.log_path().write_text("earlier server output\n", encoding="utf-8") + self.log_path().chmod(0o600) + self.state.chmod(0o755) + self.warn() + self.assertEqual( + ["earlier server output", WEAK_STATE_WARNING], + self.log_path().read_text(encoding="utf-8").splitlines(), + ) + + def test_a_symlinked_log_is_refused(self) -> None: + self.log_path().parent.mkdir(parents=True, mode=0o700) + target = self.base / "outside.log" + target.write_text("untouched\n", encoding="utf-8") + self.log_path().symlink_to(target) + self.state.chmod(0o755) + completed = self.warn() + self.assertEqual(WEAK_STATE_WARNING, completed.stderr.strip()) + self.assertEqual("untouched\n", target.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_app_server_reaper.py b/tests/test_app_server_reaper.py new file mode 100644 index 0000000..d46af74 --- /dev/null +++ b/tests/test_app_server_reaper.py @@ -0,0 +1,454 @@ +"""App Server socket directories: reaching the live ones, removing the dead ones. + +Nothing here touches a real store. Every socket is a disposable fixture in a +private temporary tree, and the fake App Server speaks only enough of the +protocol to prove a rename arrived. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +from pathlib import Path +import socket as socketmod +import struct +import subprocess +import sys +import tempfile +import threading +import time +import unittest + +from tests.support import REPO + +sys.path.insert(0, os.fspath(REPO / "lib")) +from sessionkit_inventory import names_push, reaper # noqa: E402 + +UUID = "00000000-0000-4000-8000-000000000099" +HOUR = 3600.0 +# The sweep reads real mtimes, so the fixtures are aged against the real +# clock: one reading, shared by the fixtures and by every assertion. +NOW = time.time() + + +class FakeAppServer: + """One-connection App Server: handshake, echo replies, record the rename.""" + + def __init__(self, socket_path: Path) -> None: + self.socket_path = socket_path + self.renames: list[dict] = [] + self.connected = threading.Event() + self._server = socketmod.socket(socketmod.AF_UNIX, socketmod.SOCK_STREAM) + self._server.bind(os.fspath(socket_path)) + self._server.listen(4) + self._server.settimeout(5) + self._worker = threading.Thread(target=self._serve, daemon=True) + self._worker.start() + + def close(self) -> None: + self._server.close() + self._worker.join(timeout=5) + + def _serve(self) -> None: + try: + link, _ = self._server.accept() + except OSError: + return + self.connected.set() + link.settimeout(5) + try: + self._converse(link) + except OSError: + pass + finally: + link.close() + + def _converse(self, link: socketmod.socket) -> None: + def read_exact(count: int) -> bytes: + data = b"" + while len(data) < count: + chunk = link.recv(count - len(data)) + if not chunk: + raise OSError("client closed") + data += chunk + return data + + request = b"" + while not request.endswith(b"\r\n\r\n"): + request += read_exact(1) + key = next( + line.split(b":", 1)[1].strip() + for line in request.split(b"\r\n") + if line.lower().startswith(b"sec-websocket-key:") + ) + accept = base64.b64encode( + hashlib.sha1(key + b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest() + ) + link.sendall( + b"HTTP/1.1 101 Switching Protocols\r\n" + b"Upgrade: websocket\r\nConnection: Upgrade\r\n" + b"Sec-WebSocket-Accept: " + accept + b"\r\n\r\n" + ) + while True: + first, second = read_exact(2) + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", read_exact(2))[0] + elif length == 127: + length = struct.unpack("!Q", read_exact(8))[0] + mask = read_exact(4) if second & 0x80 else b"" + payload = read_exact(length) + if mask: + payload = bytes( + value ^ mask[index % 4] for index, value in enumerate(payload) + ) + if first & 0x0F != 1: + continue + message = json.loads(payload) + # Record before replying: the client treats the reply as receipt + # and the test asserts as soon as the push returns, so a rename + # appended after sendall loses that race on a loaded machine + # (one matrix job caught it, 2026-08-19). + renamed = message.get("method") == "thread/name/set" + if renamed: + self.renames.append(message.get("params") or {}) + if message.get("id") is not None: + reply = json.dumps( + {"id": message["id"], "result": {}}, separators=(",", ":") + ).encode() + link.sendall(bytes([0x81, len(reply)]) + reply) + if renamed: + return + + +def dead_socket(socket_path: Path) -> None: + """A socket file whose server is gone: present, and refusing at once.""" + holder = socketmod.socket(socketmod.AF_UNIX, socketmod.SOCK_STREAM) + holder.bind(os.fspath(socket_path)) + holder.listen(1) + holder.close() + + +class SocketSweepTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="sk-sweep-") + self.state = Path(self.temporary.name) / "session-kit" + self.app_root = self.state / "app-server" + self.app_root.mkdir(parents=True, mode=0o700) + self.servers: list[FakeAppServer] = [] + + def tearDown(self) -> None: + for server in self.servers: + server.close() + self.temporary.cleanup() + + def make_dir(self, name: str, *, live: bool, age_rank: int) -> Path: + directory = self.app_root / name + directory.mkdir(mode=0o700) + socket_path = directory / "app.sock" + if live: + self.servers.append(FakeAppServer(socket_path)) + else: + dead_socket(socket_path) + stamp = NOW - age_rank * 60 + os.utime(socket_path, (stamp, stamp)) + os.utime(directory, (stamp, stamp)) + return directory + + def push(self, max_sockets: int = 8) -> tuple[list[str], list[str]]: + return names_push._push_codex_live_rename( + self.state, + UUID, + "Live Name", + max_sockets=max_sockets, + max_frame=names_push.MAX_CODEX_LIVE_RENAME_FRAME, + ) + + def test_dead_directories_never_crowd_out_the_newest_live_ones(self) -> None: + # The shape that broke on 2026-08-07: twelve directories, nine of them + # abandoned and alphabetically first, three live windows behind them. + for index in range(9): + self.make_dir(f"s-dead-{index:02d}", live=False, age_rank=index + 3) + live = [ + self.make_dir(f"s-live-{index}", live=True, age_rank=index) + for index in range(3) + ] + self.assertEqual(3, len(live)) + pushes, warnings = self.push() + self.assertEqual((["codex-live-rename"], []), (pushes, warnings)) + for server in self.servers: + self.assertEqual( + [{"threadId": UUID, "name": "Live Name"}], + server.renames, + msg=os.fspath(server.socket_path), + ) + + def test_the_cap_counts_connections_and_takes_the_newest(self) -> None: + for index in range(10): + self.make_dir(f"s-live-{index:02d}", live=True, age_rank=index) + pushes, _ = self.push() + self.assertEqual(["codex-live-rename"], pushes) + contacted = { + server.socket_path.parent.name + for server in self.servers + if server.connected.is_set() + } + self.assertEqual( + {f"s-live-{index:02d}" for index in range(8)}, + contacted, + ) + + def test_a_missing_root_and_a_socketless_directory_stay_silent(self) -> None: + (self.app_root / "s-empty").mkdir(mode=0o700) + self.assertEqual(([], []), self.push()) + empty_state = Path(self.temporary.name) / "no-such-kit" + self.assertEqual( + ([], []), + names_push._push_codex_live_rename( + empty_state, + UUID, + "Live Name", + max_sockets=8, + max_frame=names_push.MAX_CODEX_LIVE_RENAME_FRAME, + ), + ) + + +class StaleDirectorySweepTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="sk-reap-") + self.state = Path(self.temporary.name) / "session-kit" + self.app_root = self.state / "app-server" + self.app_root.mkdir(parents=True, mode=0o700) + self.servers: list[FakeAppServer] = [] + + def tearDown(self) -> None: + for server in self.servers: + server.close() + self.temporary.cleanup() + + def make_dir( + self, + name: str, + *, + socket: str = "dead", + age_seconds: float = 4 * HOUR, + ) -> Path: + directory = self.app_root / name + directory.mkdir(mode=0o700) + stamp = NOW - age_seconds + log = directory / "app-server.log" + log.write_text("fixture server output\n", encoding="utf-8") + log.chmod(0o600) + os.utime(log, (stamp, stamp)) + if socket != "none": + socket_path = directory / "app.sock" + if socket == "live": + self.servers.append(FakeAppServer(socket_path)) + else: + dead_socket(socket_path) + os.utime(socket_path, (stamp, stamp)) + os.utime(directory, (stamp, stamp)) + return directory + + def sweep(self, live: list[str], **overrides: object) -> list[str]: + return reaper.sweep_stale_app_server_dirs( + self.state, live, now_unix=NOW, **overrides + ) + + def test_removes_only_directories_with_all_three_proofs(self) -> None: + abandoned = self.make_dir("s-abandoned") + socketless = self.make_dir("s-socketless", socket="none") + attached = self.make_dir("s-attached") + fresh = self.make_dir("s-fresh", age_seconds=59 * 60) + answering = self.make_dir("s-answering", socket="live") + + removed = self.sweep(["s-attached", "s20260807-093118-829518"]) + + self.assertEqual(["s-abandoned", "s-socketless"], sorted(removed)) + self.assertFalse(abandoned.exists()) + self.assertFalse(socketless.exists()) + for kept in (attached, fresh, answering): + self.assertTrue(kept.is_dir(), msg=os.fspath(kept)) + + def test_a_touched_file_keeps_its_directory(self) -> None: + directory = self.make_dir("s-logging") + log = directory / "app-server.log" + os.utime(log, (NOW - 60, NOW - 60)) + self.assertEqual([], self.sweep([])) + self.assertTrue(directory.is_dir()) + + def test_unexpected_contents_are_left_alone(self) -> None: + nested = self.make_dir("s-nested") + (nested / "inner").mkdir(mode=0o700) + os.utime(nested / "inner", (NOW - 4 * HOUR, NOW - 4 * HOUR)) + os.utime(nested, (NOW - 4 * HOUR, NOW - 4 * HOUR)) + + linked = self.make_dir("s-linked") + (linked / "elsewhere.log").symlink_to(self.app_root / "s-nested") + os.utime(linked, (NOW - 4 * HOUR, NOW - 4 * HOUR)) + + odd_name = self.app_root / ".hidden" + odd_name.mkdir(mode=0o700) + os.utime(odd_name, (NOW - 4 * HOUR, NOW - 4 * HOUR)) + + self.assertEqual([], self.sweep([])) + for kept in (nested, linked, odd_name): + self.assertTrue(kept.is_dir(), msg=os.fspath(kept)) + + def test_removals_are_bounded_per_run(self) -> None: + for index in range(6): + self.make_dir(f"s-old-{index}") + removed = self.sweep([], max_removals=2) + self.assertEqual(2, len(removed)) + self.assertEqual(4, len(list(self.app_root.iterdir()))) + + def run_cli(self, shpool_json: str) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment.update( + PYTHONPATH=os.fspath(REPO / "lib"), + PYTHONDONTWRITEBYTECODE="1", + SK_REAPER_SHPOOL_JSON=shpool_json, + SK_REAPER_STATE_DIR=os.fspath(self.state), + ) + return subprocess.run( + [sys.executable, "-m", "sessionkit_inventory.reaper", "sweep-app-servers"], + text=True, + capture_output=True, + check=True, + env=environment, + ) + + def test_an_unreadable_daemon_list_removes_nothing(self) -> None: + # No proof of what is live, no removal, a malformed list must never + # read as "no sessions are running". + self.make_dir("s-abandoned") + for payload in ( + "{not json", + json.dumps({"sessions": [{"status": "Attached"}]}), + json.dumps({"sessions": "everything"}), + json.dumps({"sessions": [{"name": 17}]}), + ): + completed = self.run_cli(payload) + self.assertEqual("0", completed.stdout.strip(), msg=payload) + self.assertTrue((self.app_root / "s-abandoned").is_dir()) + + def test_the_command_reports_what_it_removed(self) -> None: + self.make_dir("s-abandoned") + self.make_dir("s-attached") + completed = self.run_cli( + json.dumps( + { + "sessions": [ + {"name": "s-attached", "status": "Attached"}, + ] + } + ) + ) + self.assertEqual("1", completed.stdout.strip()) + self.assertIn("App Server GC removed s-abandoned", completed.stderr) + self.assertFalse((self.app_root / "s-abandoned").exists()) + self.assertTrue((self.app_root / "s-attached").is_dir()) + + +class ReaperWiringTests(unittest.TestCase): + """The sweep is only worth anything if the scheduled reaper runs it.""" + + def setUp(self) -> None: + # The fake shpool has to be executable, and some hosts mount /tmp + # noexec, so the disposable tree lives on the repository filesystem. + self.temporary = tempfile.TemporaryDirectory(prefix=".reap-", dir=REPO) + self.base = Path(self.temporary.name) + self.home = self.base / "home" + self.state = self.base / "st" + self.proc = self.base / "proc" + self.bin = self.base / "bin" + for path in (self.home, self.state, self.proc, self.bin): + path.mkdir(mode=0o700) + self.app_root = self.state / "app-server" + self.app_root.mkdir(mode=0o700) + self.shpool = self.bin / "shpool" + self.shpool.write_text( + "#!/usr/bin/env bash\n" + 'if [[ $1 == list && $2 == --json ]]; then printf \'%s\\n\' ' + "'{\"sessions\":[{\"name\":\"s-live\",\"status\":\"Attached\"," + '"started_at_unix_ms":1800000000000}]}\'; exit 0; fi\nexit 2\n', + encoding="utf-8", + ) + self.shpool.chmod(0o755) + daemon = self.proc / "10" + daemon.mkdir() + fields = ["S", "1", *(["0"] * 17), "1000"] + (daemon / "stat").write_text( + f"10 (shpool) {' '.join(fields)}\n", + encoding="utf-8", + ) + (daemon / "comm").write_text("shpool\n", encoding="utf-8") + (daemon / "cmdline").write_bytes(b"shpool\0daemon\0") + (daemon / "environ").write_bytes(b"") + self.status = self.bin / "status" + self.status.write_text( + "#!/usr/bin/env python3\n" + "import json,sys\n" + "if sys.argv[1:] != ['--guard-json']: raise SystemExit(2)\n" + "print(json.dumps({'source':'live','stale':False,'warnings':[]," + "'daemon_generation':{'pid':10,'process_start_ticks':1000}," + "'sessions':[]}))\n", + encoding="utf-8", + ) + self.status.chmod(0o755) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def make_dir(self, name: str, age_seconds: float) -> Path: + directory = self.app_root / name + directory.mkdir(mode=0o700) + socket_path = directory / "app.sock" + dead_socket(socket_path) + stamp = time.time() - age_seconds + os.utime(socket_path, (stamp, stamp)) + os.utime(directory, (stamp, stamp)) + return directory + + def test_a_scheduled_report_run_sweeps_and_says_so(self) -> None: + abandoned = self.make_dir("s-old", 4 * HOUR) + listed = self.make_dir("s-live", 4 * HOUR) + environment = os.environ.copy() + environment.update( + HOME=os.fspath(self.home), + XDG_STATE_HOME=os.fspath(self.base / "xdg-state"), + XDG_DATA_HOME=os.fspath(self.base / "xdg-data"), + XDG_CONFIG_HOME=os.fspath(self.base / "xdg-config"), + XDG_CACHE_HOME=os.fspath(self.base / "xdg-cache"), + XDG_RUNTIME_DIR=os.fspath(self.base / "xdg-runtime"), + SESSION_KIT_STATE_DIR=os.fspath(self.state), + SESSION_KIT_CONFIG=os.fspath(self.base / "xdg-config/session-kit.json"), + SESSION_KIT_SHPOOL_CMD=os.fspath(self.shpool), + SESSION_KIT_STATUS_CMD=os.fspath(self.status), + SESSION_KIT_PROC_ROOT=os.fspath(self.proc), + SESSION_KIT_DAEMON_PID="10", + SESSION_KIT_REAPER_SENTINEL=os.fspath(self.base / "absent"), + SESSION_KIT_TESTING="1", + SESSION_KIT_TEST_PLATFORM="Linux", + PYTHONDONTWRITEBYTECODE="1", + PATH=os.fspath(self.bin) + ":/usr/bin:/bin", + ) + completed = subprocess.run( + [os.fspath(REPO / "bin" / "shpool_reaper"), "--candidates"], + cwd=REPO, + env=environment, + text=True, + capture_output=True, + check=True, + timeout=60, + ) + self.assertIn("expired_app_server_dirs=1", completed.stderr) + self.assertFalse(abandoned.exists()) + self.assertTrue(listed.is_dir()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_attach_history.py b/tests/test_attach_history.py new file mode 100644 index 0000000..e5246e0 --- /dev/null +++ b/tests/test_attach_history.py @@ -0,0 +1,389 @@ +"""Attach refills the terminal's scrollback from the rendered journal. + +Restore mode "simple" repaints only the live frame, so before this feature a +freshly opened window had no scrollback at all -- the operator could scroll +back "only a couple screens" (operator finding 2026-08-14). These tests drive +the real ``attach_id`` on a real pty behind a stub shpool and assert that the +settled rendered journal -- and only the settled text, never the raw bytes and +never the still-live frame -- is written to the terminal before the attach. +""" + +from __future__ import annotations + +import fcntl +import os +from pathlib import Path +import pty as pty_module +import shutil +import struct +import subprocess +import tempfile +import termios +import time +import unittest + +from tests.support import REPO +from tests.test_tab_title_ownership import run_on_pty + + +def run_on_sized_pty(script: str, rows: int, cols: int = 120) -> bytes: + """Like run_on_pty, but with a real window size on the terminal. + + The plain helper never sets a winsize, so it cannot tell "a screenful" + from a constant -- the exact blindness that let a 24-row pad ship + (lane finding X19-c). + """ + parent, child = pty_module.openpty() + size = struct.pack("HHHH", rows, cols, 0, 0) + fcntl.ioctl(child, termios.TIOCSWINSZ, size) + process = subprocess.Popen( + ["bash", "-c", script], + stdin=subprocess.DEVNULL, + stdout=child, + stderr=subprocess.DEVNULL, + env=dict(os.environ), + ) + os.close(child) + collected = b"" + try: + while True: + try: + chunk = os.read(parent, 65536) + except OSError: + break + if not chunk: + break + collected += chunk + finally: + process.wait(timeout=30) + os.close(parent) + return collected + +RENDER_TOOL = REPO / "lib" / "sessionkit_inventory" / "journal_render.py" +SEPARATOR = b"earlier output above" + + +class AttachHistoryTests(unittest.TestCase): + def setUp(self) -> None: + raw = tempfile.TemporaryDirectory(prefix=".attach-history-", dir=REPO) + self.addCleanup(raw.cleanup) + self.base = Path(raw.name) + stub = self.base / "fake-shpool" + self.shpool_log = self.base / "shpool.log" + stub.write_text( + "#!/usr/bin/env bash\n" + f'printf \'%s\\n\' "$*" >> "{self.shpool_log}"\n' + "exit 0\n", + encoding="utf-8", + ) + stub.chmod(0o755) + core = self.base / "fake-core" + core.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") + core.chmod(0o755) + (self.base / "state").mkdir() + self.journals = self.base / "journals" + self.journals.mkdir() + self.stub = stub + self.core = core + + def write_journal(self, session: str, line_count: int = 100) -> None: + lines = "".join( + f"history line {index:03d}\n" for index in range(1, line_count + 1) + ) + (self.journals / f"{session}.raw").write_text(lines, encoding="utf-8") + + def settled_lines(self, session: str) -> list[str]: + """Ground truth from the renderer itself, not a guess at its geometry.""" + journal = self.journals / f"{session}.raw" + sidecar = self.base / "truth.txt" + state = self.base / "truth.state.json" + subprocess.run( + [ + "python3", + os.fspath(RENDER_TOOL), + "render", + "--journal", + os.fspath(journal), + "--out", + os.fspath(sidecar), + "--state", + os.fspath(state), + ], + check=True, + capture_output=True, + ) + return sidecar.read_text(encoding="utf-8").splitlines() + + def attach_script( + self, session: str, prelude: str = "", function: str = "attach_id" + ) -> str: + return ( + f'export SESSION_KIT_SHPOOL_CMD="{self.stub}"\n' + f'export SESSION_KIT_INVENTORY_CORE="{self.core}"\n' + f'export SESSION_KIT_STATE_DIR="{self.base / "state"}"\n' + f'export SESSION_KIT_JOURNAL_DIR="{self.journals}"\n' + f"export SESSION_KIT_TESTING=1\n" + f'export SESSION_KIT_JOURNAL_RENDER_TOOL="{RENDER_TOOL}"\n' + f"{prelude}" + f'source "{REPO}/bin/session_kit_common"\n' + f'INVENTORY_CORE="{self.core}"\n' + f'source "{REPO}/lib/sh/sp_core.sh"\n' + f'source "{REPO}/lib/sh/sp_picker.sh"\n' + f'SK_TITLE="Drill"; SK_PROVIDER="claude"; SK_NUMBER="3"\n' + f"{function} {session} /tmp\n" + ) + + def test_attach_replays_settled_history_into_scrollback(self) -> None: + self.write_journal("drill-session") + settled = self.settled_lines("drill-session") + self.assertTrue(settled, "fixture journal must settle some lines") + written = run_on_pty(self.attach_script("drill-session")) + for line in (settled[0], settled[-1]): + self.assertIn(line.encode(), written) + self.assertIn(SEPARATOR, written) + # The still-live frame is the application's to repaint after attach; + # replaying it here would show every frame twice. + self.assertNotIn(b"history line 100", written) + self.assertNotIn("history line 100", settled) + # After the separator, a screenful of blank rows must push the replay + # into scrollback so the application repaints on a clean region + # instead of over the replay's tail (X19-b: a transferred TUI session + # showed the two interleaved). + tail_bytes = written.split(SEPARATOR, 1)[1] + pad = tail_bytes.split(b"attach ")[0] if b"attach " in tail_bytes else tail_bytes + self.assertGreaterEqual(pad.count(b"\n"), 20) + self.assertEqual( + "attach --cmd /bin/false --dir /tmp drill-session\n", + self.shpool_log.read_text(encoding="utf-8"), + ) + + def test_the_replay_honors_the_line_bound(self) -> None: + self.write_journal("drill-session") + settled = self.settled_lines("drill-session") + self.assertGreater(len(settled), 3) + written = run_on_pty( + self.attach_script( + "drill-session", + prelude="export SESSION_KIT_ATTACH_HISTORY=3\n", + ) + ) + for line in settled[-3:]: + self.assertIn(line.encode(), written) + self.assertNotIn(settled[-4].encode(), written) + + def test_zero_turns_the_refill_off_and_never_blocks_the_attach(self) -> None: + self.write_journal("drill-session") + written = run_on_pty( + self.attach_script( + "drill-session", + prelude="export SESSION_KIT_ATTACH_HISTORY=0\n", + ) + ) + self.assertNotIn(b"history line", written) + self.assertNotIn(SEPARATOR, written) + self.assertTrue(self.shpool_log.exists()) + + def test_a_session_without_a_journal_attaches_silently(self) -> None: + written = run_on_pty(self.attach_script("bare-session")) + self.assertNotIn(SEPARATOR, written) + self.assertEqual( + "attach --cmd /bin/false --dir /tmp bare-session\n", + self.shpool_log.read_text(encoding="utf-8"), + ) + + def test_the_picker_door_replays_the_same_history(self) -> None: + """The login menu and the TUI picker attach through picker_attach_id, + not attach_id -- the second human door must refill too (lane F1).""" + self.write_journal("drill-session") + settled = self.settled_lines("drill-session") + written = run_on_pty( + self.attach_script("drill-session", function="picker_attach_id") + ) + self.assertIn(settled[0].encode(), written) + self.assertIn(SEPARATOR, written) + self.assertNotIn(b"history line 100", written) + self.assertEqual( + "attach --cmd /bin/false --dir /tmp drill-session\n", + self.shpool_log.read_text(encoding="utf-8"), + ) + + def test_a_leading_zero_bound_is_read_as_decimal(self) -> None: + """"008" must mean eight lines -- bash octal parsing printed an + arithmetic error onto the operator's terminal (lane F3).""" + self.write_journal("drill-session") + settled = self.settled_lines("drill-session") + self.assertGreater(len(settled), 8) + written = run_on_pty( + self.attach_script( + "drill-session", + prelude="export SESSION_KIT_ATTACH_HISTORY=008\n", + ) + ) + for line in settled[-8:]: + self.assertIn(line.encode(), written) + self.assertNotIn(settled[-9].encode(), written) + + def test_a_gigantic_bound_replays_the_whole_sidecar(self) -> None: + """A bound past 9 digits used to wrap 64-bit arithmetic negative and + silently disable the refill (lane F3); it must cap sanely instead.""" + self.write_journal("drill-session") + settled = self.settled_lines("drill-session") + written = run_on_pty( + self.attach_script( + "drill-session", + prelude="export SESSION_KIT_ATTACH_HISTORY=" + "9" * 24 + "\n", + ) + ) + for line in (settled[0], settled[-1]): + self.assertIn(line.encode(), written) + self.assertIn(SEPARATOR, written) + + def test_a_held_render_lock_falls_back_to_the_stale_sidecar(self) -> None: + """Two concurrent renders duplicate settled lines permanently (lane + O1), so a contended lock must skip the freshen -- immediately, not + after the render timeout -- and replay what the sidecar holds.""" + session_dir = self.journals / "drill-session" + session_dir.mkdir() + lines = "".join( + f"history line {index:03d}\n" for index in range(1, 101) + ) + (session_dir / "segment-000001.raw").write_text(lines, encoding="utf-8") + sidecar = session_dir / "rendered.txt" + sidecar.write_text("a stale settled line\n", encoding="utf-8") + lock = session_dir / "rendered.txt.lock" + holder = subprocess.Popen( + ["flock", os.fspath(lock), "sleep", "30"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + deadline = time.time() + 5 + while time.time() < deadline: + probe = subprocess.run( + ["flock", "-n", os.fspath(lock), "true"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if probe.returncode != 0: + break + time.sleep(0.05) + else: + self.fail("the lock holder never acquired the lock") + started = time.monotonic() + written = run_on_pty(self.attach_script("drill-session")) + elapsed = time.monotonic() - started + finally: + holder.terminate() + holder.wait() + self.assertIn(b"a stale settled line", written) + self.assertIn(SEPARATOR, written) + self.assertNotIn(b"history line", written) + self.assertLess(elapsed, 3.5) + self.assertEqual( + "a stale settled line\n", sidecar.read_text(encoding="utf-8") + ) + + def test_the_pad_is_a_real_screenful_not_a_constant(self) -> None: + """The pad must track the window's actual height. tput inside $( ) + with stderr dropped answers the terminfo constant 24 everywhere, + which left most of the replay exposed on tall windows (X19-c).""" + self.write_journal("drill-session") + for rows, low, high in ((40, 40, 46), (12, 12, 18)): + with self.subTest(rows=rows): + written = run_on_sized_pty( + self.attach_script("drill-session"), rows=rows + ) + self.assertIn(SEPARATOR, written) + pad = written.split(SEPARATOR, 1)[1] + count = pad.count(b"\n") + self.assertGreaterEqual(count, low) + self.assertLessEqual(count, high) + + def no_flock_path(self) -> str: + """A PATH holding every binary these code paths exec EXCEPT flock(1) + -- an honest simulation of a macOS box, which has no util-linux.""" + linkdir = self.base / "no-flock-bin" + linkdir.mkdir(exist_ok=True) + for name in ("python3", "find", "tail", "cat", "sort", "uname", "mkdir"): + target = shutil.which(name) + if target and not (linkdir / name).exists(): + (linkdir / name).symlink_to(target) + return os.fspath(linkdir) + + def history_script(self, session: str, prelude: str = "") -> str: + return ( + f'export SESSION_KIT_SHPOOL_CMD="{self.stub}"\n' + f'export SESSION_KIT_INVENTORY_CORE="{self.core}"\n' + f'export SESSION_KIT_STATE_DIR="{self.base / "state"}"\n' + f'export SESSION_KIT_JOURNAL_DIR="{self.journals}"\n' + f"export SESSION_KIT_TESTING=1\n" + f'export SESSION_KIT_JOURNAL_RENDER_TOOL="{RENDER_TOOL}"\n' + f"export SESSION_KIT_NONINTERACTIVE=1\n" + f"{prelude}" + f'source "{REPO}/bin/session_kit_common"\n' + f'INVENTORY_CORE="{self.core}"\n' + f'source "{REPO}/lib/sh/sp_core.sh"\n' + f'source "{REPO}/lib/sh/sp_commands.sh"\n' + f"show_history_id {session}\n" + ) + + def test_a_box_without_flock_still_refills_scrollback(self) -> None: + """flock(1) does not exist on macOS; a missing locker must mean an + unlocked render, never a dead refill (lane F4).""" + self.write_journal("drill-session") + settled = self.settled_lines("drill-session") + written = run_on_pty( + self.attach_script( + "drill-session", + prelude=f'export PATH="{self.no_flock_path()}"\n', + ) + ) + self.assertIn(settled[0].encode(), written) + self.assertIn(SEPARATOR, written) + + def test_history_without_flock_still_pages_rendered_text(self) -> None: + """On a flock-less box, sp history must keep paging rendered text -- + an unconditional locker made it fall to the raw capture (lane F4).""" + self.write_journal("drill-session") + written = run_on_pty( + self.history_script( + "drill-session", + prelude=f'export PATH="{self.no_flock_path()}"\n', + ) + ) + self.assertIn("live screen at".encode(), written) + self.assertTrue( + (self.journals / "drill-session.rendered.txt").is_file(), + "the flush must still produce a rendered sidecar without flock", + ) + + def test_a_segment_directory_journal_replays_the_same_way(self) -> None: + """Live sessions journal as ``<id>/segment-*.raw`` directories.""" + session_dir = self.journals / "drill-session" + session_dir.mkdir() + lines = "".join( + f"history line {index:03d}\n" for index in range(1, 101) + ) + (session_dir / "segment-000001.raw").write_text(lines, encoding="utf-8") + written = run_on_pty(self.attach_script("drill-session")) + self.assertIn(SEPARATOR, written) + self.assertIn(b"history line 001", written) + self.assertNotIn(b"history line 100", written) + sidecar = session_dir / "rendered.txt" + self.assertTrue(sidecar.is_file(), "sidecar must land inside the dir") + + def test_an_unreadable_bound_falls_back_to_the_default(self) -> None: + self.write_journal("drill-session") + settled = self.settled_lines("drill-session") + written = run_on_pty( + self.attach_script( + "drill-session", + prelude="export SESSION_KIT_ATTACH_HISTORY=banana\n", + ) + ) + self.assertIn(settled[-1].encode(), written) + self.assertIn(SEPARATOR, written) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_attach_tty_handoff.py b/tests/test_attach_tty_handoff.py new file mode 100644 index 0000000..c90cd09 --- /dev/null +++ b/tests/test_attach_tty_handoff.py @@ -0,0 +1,742 @@ +"""The terminal a person is holding when the kit hands a session over. + +Not a log string, not an argument list: the actual line discipline of the +actual terminal, sampled from the master side of a real pty, at the moments a +person would notice it. + +Two separate things are guarded here. + +1. THE HANDOFF. `libshpool/src/tty.rs:91` (`set_attach_flags`): if ANY of + stdin, stdout or stderr is not a tty, the shpool client returns a no-op + guard, never sets raw mode, and pipes bytes anyway. The picker has already + restored cooked mode by then (`lib/sh/shpool_login_live.sh:156`), so the + person is left typing into a cooked terminal with a live client: keystrokes + echo as literal text, Ctrl-L and Esc do nothing, nothing reaches the + session. That is the operator's 2026-08-15 00:50 deaf terminal, and it + lasted 11 minutes 51 seconds. + + Two known ways to hand down a non-terminal descriptor. The one that hit them: + `picker_events_stop` (`shpool_login_live.sh:376`) runs + `exec {PICKER_EVENTS_FD}<&- 2>/dev/null`, and in bash a redirection on a + bare `exec` sticks to the shell for good, so the picker's own stderr became + /dev/null permanently. The other: the TUI screen, which + `bin/shpool_login_launcher:75` runs with stderr on a FIFO. + + The first is fixed at its source on a separate branch. What is tested here + is the second line of defence -- the client gets a terminal on all three + before the handover, so no source of a non-terminal descriptor, known or + not, can reach the client's all-three-must-be-a-tty test. + +2. THE NET. After the picker's attach returns, the terminal is checked and put + back if the session left it unusable -- the `stty raw -echo` correction they + had to run from another window, done from inside the handoff. It fires only + after the client has exited, and it shouts when it fires. + +The stub client implements `tty.rs:91` and nothing else, so a failure here is +the terminal a person would actually get. + +WHICH TEST PROVES WHAT, read this before trusting any of them. + +A test that passes at the commit it is named for guards nothing. Every test +below is tagged with the commit it actually discriminates against, measured by +copying this file into a worktree at each sha and running it there. `68fd53e` +is the base, `08956ff` and `6df843d` are the two superseded attempts on this +branch. + + DIFFERENTIAL vs 68fd53e (fails at base; passes at 08956ff, 6df843d, tip) + test_the_person_is_not_left_typing_into_a_cooked_terminal + test_new_session_leaves_the_terminal_raw_when_stderr_is_not_a_tty + test_picker_open_leaves_the_terminal_raw_when_stderr_is_not_a_tty + + DIFFERENTIAL vs 08956ff (fails at 08956ff; passes at 6df843d and tip) + test_no_controlling_terminal_puts_nothing_on_the_terminal + + DIFFERENTIAL vs 6df843d (fails at round 1; passes here) + test_stdout_and_stderr_redirected_to_files_still_reach_the_files + test_a_piped_stdout_is_not_taken_away + test_stderr_after_the_attach_goes_back_to_the_caller + test_a_session_that_dies_leaves_a_usable_terminal (4 subtests) + test_the_repair_shouts_with_the_measurements + + GUARD ONLY (passes at 6df843d as well -- these are not evidence of any bug, + they exist so a future change cannot break the common case) + test_ordinary_terminal_is_unchanged + test_terminal_is_sane_again_after_a_detach + test_a_healthy_session_is_not_touched_and_says_nothing + test_the_net_never_fights_a_live_client + test_the_terminal_vanishing_mid_attach_hangs_nothing + test_a_headless_caller_is_untouched + +Totals: 15 tests. 10 pass at 6df843d, 5 fail there. All 15 pass here. +""" + +from __future__ import annotations + +import fcntl +import os +from pathlib import Path +import pty +import select +import struct +import subprocess +import tempfile +import termios +import time +import unittest + +REPO = Path(__file__).resolve().parents[1] + + +def write_executable(path: Path, body: str) -> Path: + path.write_text(body, encoding="utf-8") + path.chmod(0o755) + return path + + +CLIENT = '''#!/usr/bin/env python3 +"""A stand-in for `shpool attach` that follows libshpool/src/tty.rs:91 exactly. + +All three descriptors are terminals -> take the terminal raw and hold it. Any +one of them is not -> decline raw mode silently and pipe bytes anyway, which is +the whole bug. + +CLIENT_MODE picks how it leaves, covering every abnormal death a real client +can suffer while holding the terminal raw: + normal -> put the terminal back, the way AttachFlagsGuard's drop does. + crash -> SIGKILL, the one no handler can catch (a killed client, or the + daemon vanishing under it and the client being reaped). + segv -> SIGSEGV, a client that faults with the terminal still raw. + term -> SIGTERM with the default disposition. + forget -> exits 0 and simply never restores, which is what a client whose + guard did not run leaves behind. +""" +import os +import select +import signal +import sys +import termios +import time +import tty + +MARK = os.environ["CLIENT_MARK"] +MODE = os.environ.get("CLIENT_MODE", "normal") +all_ttys = all(os.isatty(fd) for fd in (0, 1, 2)) +with open(MARK, "w") as handle: + handle.write("%d %d %d\\n" % tuple(int(os.isatty(fd)) for fd in (0, 1, 2))) + +saved = None +if all_ttys: + saved = termios.tcgetattr(0) + tty.setraw(0) + +sys.stdout.write("CLIENT-STDOUT-MARKER\\r\\n") +sys.stdout.flush() +sys.stderr.write("CLIENT-STDERR-MARKER\\r\\n") +sys.stderr.flush() +sys.stdout.write("CLIENT-UP\\r\\n") +sys.stdout.flush() +time.sleep(float(os.environ.get("CLIENT_SECONDS", "1.5"))) +DEATHS = { + "crash": signal.SIGKILL, + "segv": signal.SIGSEGV, + "term": signal.SIGTERM, +} +if MODE in DEATHS: + # No restore, no clean exit: exactly what an abnormal death leaves. + signal.signal(DEATHS[MODE], signal.SIG_DFL) + os.kill(os.getpid(), DEATHS[MODE]) + time.sleep(5) +if MODE == "forget": + os._exit(0) +# Say it before letting go, and wait to be heard. The harness counts this +# client as holding the terminal until CLIENT-DOWN reaches it, so restoring +# first left a window in which a sample saw cooked mode under a client the +# harness still thought was live -- a failure about nothing, and a scheduler +# hiccup on a loaded runner was enough to open it. The harness writes a byte +# back when it has read the line; the timeout is only so a harness that never +# answers (the master is closed under this client in one test) cannot wedge it. +sys.stdout.write("CLIENT-DOWN\\r\\n") +sys.stdout.flush() +select.select([0], [], [], 2.0) +if saved is not None: + termios.tcsetattr(0, termios.TCSADRAIN, saved) +''' + + +# The smallest real caller of the door under test: source the kit's own +# sp_core.sh and sp_picker.sh and run their attach paths. Nothing is +# reimplemented here. +DRIVER = '''#!/usr/bin/env bash +set -u +SESSION_KIT_RELEASE_DIR={repo} +source {repo}/bin/session_kit_common +source {repo}/lib/sh/sp_core.sh +PICKER_REFUSED_STATUS=3 +PICKER_ATTACH_FAILED_STATUS=4 +source {repo}/lib/sh/sp_picker.sh +SK_SHPOOL={client} +INVENTORY_CORE={repo}/lib/session_inventory.py +SNAPSHOT= +history_files() {{ :; }} +cleanup_snapshot() {{ :; }} +sk_tab_title() {{ :; }} +sk_human_label() {{ :; }} +# The picker restores cooked mode before every action it takes +# (lib/sh/shpool_login_live.sh:156). Reproduce that, then hand over. +stty sane < /dev/tty 2>/dev/null || true +{door} +echo "DOOR-RETURNED-$?" +printf 'SP-STDERR-AFTER-ATTACH\\n' >&2 +''' + + +class Handoff: + """Drive one attach on a real pty and watch it from the master side.""" + + def __init__( + self, + *, + door: str, + stderr_is_tty: bool = True, + stdout_is_tty: bool = True, + stdin_is_tty: bool = True, + client_mode: str = "normal", + seconds: float = 1.5, + ): + self.temp = tempfile.TemporaryDirectory(prefix="attach-tty.") + self.base = Path(self.temp.name) + self.state = self.base / "state" + self.state.mkdir() + self.mark = self.base / "descriptors" + self.stderr_file = self.base / "stderr.log" + self.stdout_file = self.base / "stdout.log" + self.client = write_executable(self.base / "client", CLIENT) + self.driver = write_executable( + self.base / "driver", + DRIVER.format(repo=REPO, client=self.client, door=door), + ) + self.stderr_is_tty = stderr_is_tty + self.stdout_is_tty = stdout_is_tty + self.stdin_is_tty = stdin_is_tty + self.client_mode = client_mode + self.seconds = seconds + self.samples: list[tuple[float, bool, bool, bool]] = [] + self.screen = "" + + def close(self) -> None: + self.temp.cleanup() + + @property + def repair_log(self) -> Path: + return self.state / "handoff-repair.log" + + def run(self) -> None: + environment = os.environ.copy() + environment.update( + { + "CLIENT_MARK": str(self.mark), + "CLIENT_MODE": self.client_mode, + "CLIENT_SECONDS": str(self.seconds), + "HOME": str(self.base), + "SESSION_KIT_STATE_DIR": str(self.state), + "SESSION_KIT_JOURNAL_DIR": str(self.base / "journal"), + "SESSION_KIT_ARCHIVE_DIR": str(self.base / "archive"), + "SESSION_KIT_JOURNAL_RECOVERY_DIR": str(self.base / "recovery"), + "SESSION_KIT_START_DIR": str(self.base / "start"), + "SESSION_KIT_PROJECTS_FILE": str(self.base / "projects.tsv"), + "SESSION_KIT_CONFIG": str(self.base / "inventory.json"), + "PYTHONDONTWRITEBYTECODE": "1", + "TERM": "xterm", + } + ) + pid, master = pty.fork() + if pid == 0: + try: + if not self.stderr_is_tty: + handle = os.open(self.stderr_file, os.O_WRONLY | os.O_CREAT, 0o600) + os.dup2(handle, 2) + if not self.stdout_is_tty: + handle = os.open(self.stdout_file, os.O_WRONLY | os.O_CREAT, 0o600) + os.dup2(handle, 1) + if not self.stdin_is_tty: + handle = os.open(os.devnull, os.O_RDONLY) + os.dup2(handle, 0) + os.chdir(self.base) + os.execve(str(self.driver), [str(self.driver)], environment) + finally: + os._exit(127) + + fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 100, 0, 0)) + started = time.time() + client_up = False + answered = False + buffer = bytearray() + while time.time() - started < self.seconds + 12: + ready, _, _ = select.select([master], [], [], 0.01) + if ready: + try: + chunk = os.read(master, 4096) + except OSError: + break + if not chunk: + break + buffer.extend(chunk) + text = buffer.decode("utf-8", "replace") + if "CLIENT-UP" in text: + client_up = True + if "CLIENT-DOWN" in text and not answered: + # The client is waiting to hear that its farewell landed + # before it puts the terminal back. This loop has already + # stopped counting it as the holder -- `alive` below reads + # the same text -- so the restore can no longer be seen as + # the terminal being taken from a live client. + answered = True + try: + os.write(master, b"\r") + except OSError: + pass + try: + lflag = termios.tcgetattr(master)[3] + except termios.error: + break + self.samples.append( + ( + time.time() - started, + bool(lflag & termios.ECHO), + bool(lflag & termios.ICANON), + # The client holds the terminal from CLIENT-UP until it + # puts it back (CLIENT-DOWN) or dies without doing so, in + # which case the door returning is the end of its life. + client_up + and "CLIENT-DOWN" not in text + and "DOOR-RETURNED" not in text, + ) + ) + if "SP-STDERR-AFTER-ATTACH" in text or "DOOR-RETURNED" in text: + # let the trailing writes and the repair land + if time.time() - started > self.seconds + 0.6: + break + self.screen = buffer.decode("utf-8", "replace") + try: + os.close(master) + except OSError: + pass + try: + os.waitpid(pid, 0) + except ChildProcessError: + pass + + def run_until_client_then_close_master(self) -> None: + """Pull the terminal out from under a live attach.""" + environment = os.environ.copy() + environment.update( + { + "CLIENT_MARK": str(self.mark), + "CLIENT_MODE": self.client_mode, + "CLIENT_SECONDS": str(self.seconds), + "HOME": str(self.base), + "SESSION_KIT_STATE_DIR": str(self.state), + "SESSION_KIT_JOURNAL_DIR": str(self.base / "journal"), + "SESSION_KIT_ARCHIVE_DIR": str(self.base / "archive"), + "SESSION_KIT_JOURNAL_RECOVERY_DIR": str(self.base / "recovery"), + "SESSION_KIT_START_DIR": str(self.base / "start"), + "SESSION_KIT_PROJECTS_FILE": str(self.base / "projects.tsv"), + "SESSION_KIT_CONFIG": str(self.base / "inventory.json"), + "PYTHONDONTWRITEBYTECODE": "1", + "TERM": "xterm", + } + ) + pid, master = pty.fork() + if pid == 0: + try: + os.chdir(self.base) + os.execve(str(self.driver), [str(self.driver)], environment) + finally: + os._exit(127) + buffer = bytearray() + started = time.time() + while time.time() - started < 10: + ready, _, _ = select.select([master], [], [], 0.05) + if ready: + try: + chunk = os.read(master, 4096) + except OSError: + break + if not chunk: + break + buffer.extend(chunk) + if b"CLIENT-UP" in buffer: + break + os.close(master) + deadline = time.time() + 12 + while time.time() < deadline: + done, _ = os.waitpid(pid, os.WNOHANG) + if done: + self.samples = [(0.0, True, True, False)] + return + time.sleep(0.05) + os.kill(pid, 9) + os.waitpid(pid, 0) + raise AssertionError("the driver never exited after the terminal closed") + + # ---- what a person would experience ------------------------------- + + def descriptors(self) -> tuple[int, int, int]: + values = self.mark.read_text().split() + return tuple(int(value) for value in values) # type: ignore[return-value] + + def longest_cooked_with_client_ms(self) -> float: + longest = 0.0 + start = None + previous = None + for at, echo, icanon, alive in self.samples: + cooked = alive and (echo or icanon) + if cooked and start is None: + start = at + elif not cooked and start is not None: + longest = max(longest, (previous or at) - start) + start = None + previous = at + if start is not None and previous is not None: + longest = max(longest, previous - start) + return longest * 1000.0 + + def raw_while_client_alive(self) -> bool: + """Was the terminal raw for the whole time the client held it?""" + alive = [s for s in self.samples if s[3]] + return bool(alive) and all(not s[1] and not s[2] for s in alive) + + def final_modes(self) -> tuple[bool, bool]: + _, echo, icanon, _ = self.samples[-1] + return echo, icanon + + +class HandoffTestCase(unittest.TestCase): + def drive(self, door: str, **kwargs) -> Handoff: + handoff = Handoff(door=door, **kwargs) + self.addCleanup(handoff.close) + handoff.run() + self.assertTrue(handoff.samples, "the pty produced no samples") + return handoff + + +class AttachHandoffTest(HandoffTestCase): + """DIFFERENTIALS. These fail at the base commit.""" + + def test_the_person_is_not_left_typing_into_a_cooked_terminal(self): + """The operator's report, as an assertion, with nothing else in it. + + No descriptors, no argv, no log line: only whether the terminal a + person is holding is cooked while the session is attached. + """ + handoff = self.drive("attach_id session-1", stderr_is_tty=False) + cooked_ms = handoff.longest_cooked_with_client_ms() + self.assertLess( + cooked_ms, + 500.0, + f"the terminal was cooked for {cooked_ms:.0f} ms while the session " + "was attached -- keystrokes echo as literal text, Ctrl-L and Esc do " + "nothing, and nothing reaches the session", + ) + + def test_new_session_leaves_the_terminal_raw_when_stderr_is_not_a_tty(self): + """`sp new` through a picker whose stderr is a file, as the TUI + launcher runs it.""" + handoff = self.drive("attach_id session-1", stderr_is_tty=False) + self.assertEqual( + handoff.descriptors(), + (1, 1, 1), + "the client was handed a descriptor that is not the terminal, so " + "shpool declines raw mode (libshpool/src/tty.rs:91) and the person " + "types into a cooked terminal", + ) + + def test_picker_open_leaves_the_terminal_raw_when_stderr_is_not_a_tty(self): + """The picker's own open door. X19 finding F1 was these two drifting + apart, so both are held to the same terminal.""" + handoff = self.drive("picker_attach_id session-1", stderr_is_tty=False) + self.assertEqual(handoff.descriptors(), (1, 1, 1)) + self.assertLess(handoff.longest_cooked_with_client_ms(), 500.0) + + +class RedirectionIsHonouredTest(HandoffTestCase): + """DIFFERENTIAL. A caller that redirected on purpose keeps its redirection. + + The first version of the gate opened on "a terminal on any one of the + three" and rebound the other two, so `sp go X > out 2> err` printed the + session to the screen and left `out` empty. + """ + + def test_stdout_and_stderr_redirected_to_files_still_reach_the_files(self): + handoff = self.drive( + "attach_id session-1", stdout_is_tty=False, stderr_is_tty=False + ) + out = handoff.stdout_file.read_text(errors="replace") + err = handoff.stderr_file.read_text(errors="replace") + self.assertIn( + "CLIENT-STDOUT-MARKER", + out, + "the caller redirected stdout to a file and the session's output " + f"did not reach it; screen carried: {handoff.screen[:200]!r}", + ) + self.assertIn("CLIENT-STDERR-MARKER", err) + self.assertNotIn( + "CLIENT-STDOUT-MARKER", + handoff.screen, + "session output was printed on the terminal even though the " + "caller redirected it to a file", + ) + + def test_a_piped_stdout_is_not_taken_away(self): + """`sp go 3 | tee log` -- stdout is a pipe, stdin is the terminal.""" + handoff = self.drive( + "attach_id session-1", stdout_is_tty=False, stderr_is_tty=True + ) + self.assertNotIn("CLIENT-STDOUT-MARKER", handoff.screen) + self.assertIn( + "CLIENT-STDOUT-MARKER", + handoff.stdout_file.read_text(errors="replace"), + ) + + +class TerminalRepairNetTest(HandoffTestCase): + """DIFFERENTIAL. The net: a session that leaves the terminal unusable is + caught, corrected, and shouted about.""" + + def test_a_session_that_dies_leaves_a_usable_terminal(self): + """Every abnormal death, not just the convenient one. + + Lane C measured that after the round-1 binding an abnormally dying + client left the terminal raw where before it left it usable. Each of + these is that shape, and each must come back usable. + """ + for mode in ("crash", "segv", "term", "forget"): + with self.subTest(death=mode): + handoff = self.drive( + "picker_attach_id session-net", client_mode=mode + ) + echo, icanon = handoff.final_modes() + self.assertTrue( + echo and icanon, + f"a client that died by {mode} left the terminal with no " + "echo or no line mode: the person is holding an unusable " + "window", + ) + self.assertTrue( + handoff.repair_log.exists(), + f"the {mode} death was repaired without a record", + ) + + def test_the_terminal_vanishing_mid_attach_hangs_nothing(self): + """GUARD ONLY (passes at 6df843d). The pty master closing under the + attach. + + There is no terminal left to repair and nothing to say; the only + requirement is that the door returns rather than blocking on a + descriptor that no longer resolves. + """ + handoff = Handoff(door="picker_attach_id session-gone", seconds=6.0) + self.addCleanup(handoff.close) + started = time.time() + handoff.run_until_client_then_close_master() + self.assertLess( + time.time() - started, + 20.0, + "the door did not return after its terminal disappeared", + ) + + def test_the_repair_shouts_with_the_measurements(self): + handoff = self.drive("picker_attach_id session-net", client_mode="crash") + self.assertTrue( + handoff.repair_log.exists(), + "the net corrected the terminal without recording anything; a " + "silent net destroys the evidence for whoever is hunting the cause", + ) + line = handoff.repair_log.read_text(errors="replace") + for field in ("session=session-net", "tty=", "lflag=0x", "ECHO=", + "ICANON=", "fg_pgrp=", "members=", "client_status=", + "restored="): + self.assertIn(field, line, f"the record is missing {field!r}: {line!r}") + self.assertIn( + "unusable", + handoff.screen, + "nothing was said on the screen, so the person has no idea their " + "terminal was repaired", + ) + + def test_a_healthy_session_is_not_touched_and_says_nothing(self): + """GUARD ONLY (passes at 6df843d). Idempotent and cheap: when the + terminal comes back the way it went in, the net does nothing and + records nothing.""" + handoff = self.drive("picker_attach_id session-ok", client_mode="normal") + self.assertFalse( + handoff.repair_log.exists(), + "the net fired on a healthy handoff: " + f"{handoff.repair_log.read_text(errors='replace') if handoff.repair_log.exists() else ''}", + ) + self.assertNotIn("unusable", handoff.screen) + + def test_the_net_never_fights_a_live_client(self): + """GUARD ONLY (passes at 6df843d). While the client holds the + terminal it must stay exactly as the client set it: the check runs + only after the client has exited.""" + handoff = self.drive( + "picker_attach_id session-live", client_mode="normal", seconds=2.0 + ) + self.assertTrue( + handoff.raw_while_client_alive(), + "the terminal left raw mode while the client was still running -- " + "something took the line discipline away from a live session", + ) + + +class CaptureScopeTest(HandoffTestCase): + """DIFFERENTIAL. The picker door's binding must not outlive the attach. + + `attach_id` execs, so its binding dies with the process. `picker_attach_id` + returns, and an unscoped binding sent everything `sp` said afterwards to + the screen instead of the launcher's FIFO capture, out of order. + """ + + def test_stderr_after_the_attach_goes_back_to_the_caller(self): + handoff = self.drive("picker_attach_id session-1", stderr_is_tty=False) + captured = handoff.stderr_file.read_text(errors="replace") + self.assertIn( + "SP-STDERR-AFTER-ATTACH", + captured, + "what sp said after the attach bypassed the caller's capture; the " + "launcher's traceback log loses it and the screen shows it out of " + f"order. screen: {handoff.screen[-200:]!r}", + ) + + +class HeadlessAndGuardTest(unittest.TestCase): + """The shapes where the gate must do nothing at all.""" + + def probe(self, script: str, *, setsid: bool = False) -> subprocess.CompletedProcess: + body = ( + f"source {REPO}/bin/session_kit_common\n" + f"source {REPO}/lib/sh/sp_core.sh\n" + script + ) + argv = ["bash", "-c", body] + if setsid: + argv = ["setsid", *argv] + return subprocess.run( + argv, capture_output=True, text=True, stdin=subprocess.DEVNULL, + env={**os.environ, "SESSION_KIT_STATE_DIR": tempfile.mkdtemp()}, + ) + + def on_a_terminal_without_a_controlling_terminal(self, script: str) -> str: + """The one shape that actually reaches the historical defect. + + It needs BOTH conditions at once, and they are easy to lose: + + * at least one descriptor IS a terminal, or the gate returns at its + first line and the /dev/tty check is never reached; + * the process has NO controlling terminal, or /dev/tty opens fine and + there is no failure to observe. + + `setsid … < /dev/null` from a terminal gives exactly that: stdout and + stderr stay on the terminal, stdin is not one, and the session leader + has no controlling terminal. A process holding a terminal on *stdin* + acquires it as its controlling terminal on Linux, so that shape cannot + reproduce this and an earlier version of this test, which used + `stdin=DEVNULL` with everything captured, reproduced nothing at all -- + it passed at the known-bad commit. + + Returns everything that landed ON THE TERMINAL, which is where the + operator would have seen it. + """ + body = ( + f"source {REPO}/bin/session_kit_common\n" + f"source {REPO}/lib/sh/sp_core.sh\n" + 'if ( exec 3< /dev/tty ) 2>/dev/null; then echo CTTY-PRESENT; ' + "else echo CTTY-ABSENT; fi\n" + script + ) + master, slave = pty.openpty() + devnull = os.open(os.devnull, os.O_RDONLY) + try: + subprocess.run( + ["setsid", "bash", "-c", body], + stdin=devnull, stdout=slave, stderr=slave, timeout=60, + env={**os.environ, "SESSION_KIT_STATE_DIR": tempfile.mkdtemp()}, + ) + finally: + os.close(devnull) + os.close(slave) + fcntl.fcntl(master, fcntl.F_SETFL, os.O_NONBLOCK) + try: + text = os.read(master, 1 << 20).decode("utf-8", "replace") + except OSError: + text = "" + os.close(master) + # The precondition, asserted rather than assumed: a test that quietly + # stopped reproducing the shape would pass for the wrong reason. + assert "CTTY-ABSENT" in text, ( + "the probe acquired a controlling terminal, so it is not testing " + f"the defect at all: {text!r}" + ) + return text + + def test_no_controlling_terminal_puts_nothing_on_the_terminal(self): + """DIFFERENTIAL against `08956ff`, the commit `6df843d` was written for. + + `-r /dev/tty` is a permission check on a mode-0666 device: it answers + yes even when there is no controlling terminal to open. The redirection + then failed and bash printed the error onto the very terminal the + binding exists to protect: + + .../lib/sh/sp_core.sh: line 77: /dev/tty: No such device or address + + `6df843d` silenced it by probing the open in a subshell first. The gate + here does not touch /dev/tty at all, so the failure is gone by + construction. This holds it gone, and -- unlike its predecessor -- it + actually reaches the defect. + """ + text = self.on_a_terminal_without_a_controlling_terminal( + "sk_bind_handoff_tty; echo rc=$?" + ) + self.assertIn("rc=0", text) + self.assertNotIn( + "No such device", + text, + "the gate tried to open a controlling terminal that does not " + "exist and printed the failure onto the operator's screen", + ) + self.assertNotIn("/dev/tty:", text) + + def test_a_headless_caller_is_untouched(self): + """GUARD (passes at every commit). A caller with no terminal anywhere + must be left exactly as it was.""" + done = self.probe( + "sk_bind_handoff_tty; echo rc=$?; " + "echo t0=$([[ -t 0 ]] && echo 1 || echo 0)" + "t1=$([[ -t 1 ]] && echo 1 || echo 0)" + "t2=$([[ -t 2 ]] && echo 1 || echo 0)" + ) + self.assertIn("rc=0", done.stdout) + self.assertIn("t0=0t1=0t2=0", done.stdout) + self.assertEqual(done.stderr.strip(), "") + + +class RegressionGuardTest(HandoffTestCase): + """GUARDS, NOT DIFFERENTIALS. These pass at the base commit too. + + They exist so a future change to the handoff cannot break the common case + or strand a person in an unusable shell after a detach. + """ + + def test_ordinary_terminal_is_unchanged(self): + handoff = self.drive("attach_id session-1", stderr_is_tty=True) + self.assertEqual(handoff.descriptors(), (1, 1, 1)) + self.assertLess(handoff.longest_cooked_with_client_ms(), 500.0) + + def test_terminal_is_sane_again_after_a_detach(self): + handoff = self.drive("picker_attach_id session-1", stderr_is_tty=True) + echo, icanon = handoff.final_modes() + self.assertTrue(echo, "echo never came back after the session detached") + self.assertTrue(icanon, "line mode never came back after the detach") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_attention_notify.py b/tests/test_attention_notify.py new file mode 100644 index 0000000..846114c --- /dev/null +++ b/tests/test_attention_notify.py @@ -0,0 +1,39 @@ +"""The example desktop notifier the watchdog can be pointed at. + +The attention queue this file was named for is gone with the event store, and +with it the watchdog's opt-in queue alerts. What survives is the notifier +contract itself: the watchdog hands an alert to whatever +SESSION_KIT_WATCHDOG_NOTIFY names, and the shipped example has to keep parsing +flags it does not recognize so the contract can grow without breaking the +machine's own notifier. The watchdog's own alerting is covered in +tests/test_watchdog.py. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import unittest + +REPO = Path(__file__).resolve().parent.parent + + +class NotifierExampleTests(unittest.TestCase): + def test_the_example_notifier_ignores_flags_it_does_not_know(self) -> None: + notifier = REPO / "extras" / "notify-desktop" + self.assertTrue(os.access(notifier, os.X_OK)) + # No notify-send in a container: the point is that an unknown flag is + # parsed rather than refused, so the contract can grow. + result = subprocess.run( + ["bash", "-n", str(notifier)], capture_output=True, text=True + ) + self.assertEqual(0, result.returncode, result.stderr) + source = notifier.read_text(encoding="utf-8") + self.assertIn("--title=*", source) + self.assertIn("--body=*", source) + self.assertIn("*) ;;", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_attention_truth.py b/tests/test_attention_truth.py new file mode 100644 index 0000000..1306646 --- /dev/null +++ b/tests/test_attention_truth.py @@ -0,0 +1,488 @@ +"""Who needs a person, taken from Claude's push signal and pinned to real strings. + +Two failures are covered here, and the second one is the reason for the first. + +A poll cannot see a question that appears between two polls. `claude agents +--json` is the most expensive part of a snapshot, so it cannot simply run more +often, and a session that asks something the moment after a collection is +invisible until the next one. Claude Code fires a Notification hook at the +instant attention is wanted, so the hook becomes the primary signal and the +poll becomes reconciliation. + +And the vocabulary in the documentation is not the vocabulary on the wire. A +live 2.1.229 reports `"status": "busy"` for a working session; the docs say +"working". Anything written against the documented word matches nothing and +says so to no one. Every vendor string this kit compares against is therefore +recorded here with the version and date it was read on, and changing one +without new live evidence fails this file. + +Live evidence behind the golden values (2026-08-13, Claude Code 2.1.229): + * `claude agents --json` returned exactly one interactive row, + `"status": "busy"` -- reproduced in GOLDEN_POLL_ROW; + * the Notification matcher enum in the 2.1.229 binary lists eight + notification_type values -- reproduced in NOTIFICATION_TYPES; + * the hook payload schema for Notification is base fields plus `message`, + optional `title`, and `notification_type`. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import unittest + +from tests.support import REPO + +sys.path.insert(0, str(REPO / "lib")) + +from sessionkit_inventory import attention # noqa: E402 +from sessionkit_inventory import providers_claude # noqa: E402 +from sessionkit_inventory import pulse # noqa: E402 + + +HOOK = REPO / "config" / "claude" / "attention_hook.sh" +SESSION = "22222222-3333-4444-8555-666666666666" + + +def run_hook(state_dir: Path, payload: dict) -> subprocess.CompletedProcess: + fixture_root = state_dir.parent + fixture_bin = fixture_root / "bin" + fixture_bin.mkdir(parents=True, exist_ok=True) + refusing_shpool = fixture_bin / "shpool" + refusing_shpool.write_text( + "#!/bin/sh\necho 'test fixture refuses shpool' >&2\nexit 97\n", + encoding="utf-8", + ) + refusing_shpool.chmod(0o755) + return subprocess.run( + ["bash", str(HOOK)], + input=json.dumps(payload).encode(), + capture_output=True, + env={ + "HOME": str(fixture_root), + "PATH": f"{fixture_bin}{os.pathsep}/usr/bin:/bin", + "XDG_STATE_HOME": str(fixture_root / "xdg-state"), + "XDG_DATA_HOME": str(fixture_root / "xdg-data"), + "SESSION_KIT_STATE_DIR": str(state_dir), + "SESSION_KIT_CONFIG": str(fixture_root / "config.toml"), + }, + timeout=30, + ) + + +class GoldenVendorVocabularyTests(unittest.TestCase): + """The strings, exactly as a live install says them.""" + + def test_a_working_session_reports_busy_not_working(self) -> None: + self.assertEqual("busy", attention.GOLDEN_POLL_ROW["status"]) + self.assertEqual("busy", attention.POLL_BUSY_STATUS) + rows = providers_claude._parse_claude_payload( + [dict(attention.GOLDEN_POLL_ROW)], palette=() + ) + self.assertEqual(1, len(rows)) + # The kit's own word for it is "working" -- that translation is the + # whole point, and it only happens because the comparison is against + # the string the vendor actually sends. + self.assertEqual("working", rows[0]["status"]) + self.assertFalse(rows[0]["needs_you"]) + + def test_the_live_statuses_are_recorded_with_their_kit_wording(self) -> None: + self.assertEqual(("busy", "idle"), attention.GOLDEN_POLL_STATUSES) + self.assertEqual(("working", False), attention.poll_attention("busy", None)) + self.assertEqual(("idle", False), attention.poll_attention("idle", None)) + + def test_the_notification_enum_is_the_one_the_binary_carries(self) -> None: + self.assertEqual( + ( + "permission_prompt", + "idle_prompt", + "auth_success", + "elicitation_dialog", + "elicitation_complete", + "elicitation_response", + "agent_needs_input", + "agent_completed", + ), + attention.NOTIFICATION_TYPES, + ) + # Every type is classified deliberately: raising attention, answering + # it, or -- for none of them today -- left to the poll. + for name in attention.NOTIFICATION_TYPES: + self.assertIn( + name, + attention.NEEDS_YOU_NOTIFICATIONS | attention.CLEARING_NOTIFICATIONS, + f"{name} is classified nowhere", + ) + + def test_a_waiting_row_is_still_read_from_the_poll_alone(self) -> None: + row = dict(attention.GOLDEN_POLL_ROW) + row["waitingFor"] = "a decision" + rows = providers_claude._parse_claude_payload([row], palette=()) + self.assertTrue(rows[0]["needs_you"]) + self.assertEqual("needs your reply", rows[0]["status"]) + self.assertEqual("poll", rows[0]["attention_source"]) + + +class HookRecordTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="attention-") + self.addCleanup(self.temporary.cleanup) + self.state = Path(self.temporary.name) / "state" + + def test_a_permission_prompt_is_recorded_as_needing_a_person(self) -> None: + result = run_hook( + self.state, + { + "session_id": SESSION, + "hook_event_name": "Notification", + "notification_type": "permission_prompt", + "message": "Claude needs your permission to use Bash", + }, + ) + self.assertEqual(0, result.returncode, result.stderr) + record = attention.read_record(self.state, SESSION) + self.assertIsNotNone(record) + self.assertTrue(record["needs_you"]) + self.assertEqual("permission_prompt", record["notification_type"]) + path = attention.record_path(self.state, SESSION) + self.assertEqual(0o600, path.stat().st_mode & 0o777) + + def test_a_submitted_prompt_says_the_person_answered(self) -> None: + run_hook( + self.state, + { + "session_id": SESSION, + "hook_event_name": "Notification", + "notification_type": "idle_prompt", + "message": "waiting", + }, + ) + run_hook( + self.state, + {"session_id": SESSION, "hook_event_name": "UserPromptSubmit"}, + ) + record = attention.read_record(self.state, SESSION) + self.assertFalse(record["needs_you"]) + self.assertEqual("UserPromptSubmit", record["hook_event"]) + + def test_a_record_more_than_five_seconds_in_the_future_is_not_evidence( + self, + ) -> None: + attention.write_record( + self.state, + session_id=SESSION, + hook_event="UserPromptSubmit", + needs_you=False, + recorded_at_ms=int((time.time() + 3600) * 1000), + ) + self.assertIsNone(attention.read_record(self.state, SESSION)) + + def test_small_clock_skew_is_tolerated(self) -> None: + attention.write_record( + self.state, + session_id=SESSION, + hook_event="UserPromptSubmit", + needs_you=False, + recorded_at_ms=int((time.time() + 2) * 1000), + ) + self.assertIsNotNone(attention.read_record(self.state, SESSION)) + + def test_a_type_this_kit_does_not_know_leaves_the_record_alone(self) -> None: + run_hook( + self.state, + { + "session_id": SESSION, + "hook_event_name": "Notification", + "notification_type": "agent_needs_input", + }, + ) + result = run_hook( + self.state, + { + "session_id": SESSION, + "hook_event_name": "Notification", + "notification_type": "some_future_vendor_event", + }, + ) + self.assertEqual(0, result.returncode) + # Still needing a person: an unrecognised event may not quietly clear + # a question that is still on the screen. + self.assertTrue(attention.read_record(self.state, SESSION)["needs_you"]) + + def test_a_finished_session_leaves_no_record_behind(self) -> None: + run_hook( + self.state, + { + "session_id": SESSION, + "hook_event_name": "Notification", + "notification_type": "permission_prompt", + }, + ) + run_hook(self.state, {"session_id": SESSION, "hook_event_name": "SessionEnd"}) + self.assertIsNone(attention.read_record(self.state, SESSION)) + + def test_rubbish_input_writes_nothing_and_still_exits_zero(self) -> None: + for payload in ( + {"hook_event_name": "Notification", "notification_type": "idle_prompt"}, + {"session_id": "not-a-uuid", "hook_event_name": "Notification"}, + {"session_id": SESSION, "hook_event_name": "PreToolUse"}, + ): + result = run_hook(self.state, payload) + self.assertEqual(0, result.returncode, payload) + self.assertEqual(b"", result.stdout) + self.assertIsNone(attention.read_record(self.state, SESSION)) + + +class PulseTests(unittest.TestCase): + """The watcher that lets the poll widen without anyone waiting longer. + + Every source of a needs-you change that is NOT a shpool event has to be in + here, or the picker would be widening its poll over a blind spot: the + Claude hook records, every Claude session record (ambient AND account + profiles -- on a multi-account machine nearly every live session is in a + profile), the Codex rollouts, and the fleet's stall file. + """ + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="pulse-") + self.addCleanup(self.temporary.cleanup) + self.home = Path(self.temporary.name) + self.state = self.home / ".local" / "state" / "session-kit" + self.environ = { + "SESSION_KIT_STATE_DIR": str(self.state), + "SESSION_KIT_ACCOUNT_ROOT": str(self.home / "accounts"), + "CODEX_HOME": str(self.home / ".codex"), + "FLEET_STATE_DIR": str(self.home / "fleet"), + } + + def digest(self) -> str: + return pulse.fingerprint(self.environ, self.home) + + def write(self, path: Path, text: str = "{}") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + def test_every_source_of_a_waiting_session_moves_the_fingerprint(self) -> None: + sources = { + "the Claude hook record": self.state + / "attention" + / "claude" + / f"{SESSION}.json", + "an ambient Claude session record": self.home + / ".claude" + / "sessions" + / "4242.json", + "a profiled Claude session record": self.home + / "accounts" + / "claude" + / "work" + / "sessions" + / "4243.json", + "a Codex rollout": self.home + / ".codex" + / "sessions" + / "2026" + / "08" + / "rollout-2026-08-13.jsonl", + "the fleet stall file": self.home / "fleet" / "stalls.json", + } + for label, path in sources.items(): + before = self.digest() + self.write(path, '{"probe": 1}') + self.assertNotEqual( + before, self.digest(), f"{label} did not move the pulse" + ) + # And a later append to the same file is seen too: a rollout grows, + # it is not recreated. + before = self.digest() + with path.open("a", encoding="utf-8") as handle: + handle.write("\n{}\n") + self.assertNotEqual( + before, self.digest(), f"{label} did not move the pulse on append" + ) + + def test_a_quiet_estate_produces_no_line(self) -> None: + self.write(self.state / "attention" / "claude" / f"{SESSION}.json") + self.assertEqual(self.digest(), self.digest()) + + def test_the_walk_is_bounded_on_a_big_tree(self) -> None: + rollouts = self.home / ".codex" / "sessions" / "deep" + rollouts.mkdir(parents=True) + for index in range(60): + (rollouts / f"rollout-{index}.jsonl").write_text("{}", encoding="utf-8") + paths = pulse.watched_paths(self.environ, self.home) + self.assertLessEqual(len(paths), pulse.MAX_NODES) + self.assertGreater(len(paths), 10) + + def test_a_missing_home_is_not_an_error(self) -> None: + empty = Path(self.temporary.name) / "nothing-here" + self.assertIsInstance(pulse.fingerprint({}, empty), str) + + +class MergeTests(unittest.TestCase): + """Which of the two sources decides, and what happens when one is absent.""" + + def hook_record(self, *, needs_you: bool, at: int) -> dict: + return { + "session_id": SESSION, + "hook_event": "Notification", + "notification_type": "idle_prompt" if needs_you else "agent_completed", + "needs_you": needs_you, + "message": "", + "recorded_at_ms": at, + } + + def test_a_question_asked_after_the_last_poll_still_reaches_the_picker( + self, + ) -> None: + decided = attention.merge( + poll_status="working", + poll_needs_you=False, + poll_stamp_ms=1_000, + record=self.hook_record(needs_you=True, at=2_000), + source="auto", + ) + self.assertTrue(decided["needs_you"]) + self.assertEqual("needs your reply", decided["agent_status"]) + self.assertEqual("hook", decided["attention_source"]) + + def test_a_newer_poll_outranks_an_older_hook_record(self) -> None: + decided = attention.merge( + poll_status="working", + poll_needs_you=False, + poll_stamp_ms=9_000, + record=self.hook_record(needs_you=True, at=1_000), + source="auto", + ) + self.assertFalse(decided["needs_you"]) + self.assertEqual("poll", decided["attention_source"]) + + def test_without_a_poll_stamp_only_a_raised_hand_outranks_the_poll(self) -> None: + raised = attention.merge( + poll_status="working", + poll_needs_you=False, + poll_stamp_ms=None, + record=self.hook_record(needs_you=True, at=1_000), + source="auto", + ) + self.assertTrue(raised["needs_you"]) + lowered = attention.merge( + poll_status="needs your reply", + poll_needs_you=True, + poll_stamp_ms=None, + record=self.hook_record(needs_you=False, at=1_000), + source="auto", + ) + self.assertTrue( + lowered["needs_you"], + "an undated comparison must never drop a waiting session", + ) + + def test_the_kill_switch_restores_the_poll_only_reading(self) -> None: + decided = attention.merge( + poll_status="working", + poll_needs_you=False, + poll_stamp_ms=1_000, + record=self.hook_record(needs_you=True, at=2_000), + source="poll", + ) + self.assertFalse(decided["needs_you"]) + self.assertEqual("poll", decided["attention_source"]) + + def test_no_hook_record_is_exactly_the_old_behaviour(self) -> None: + for needs_you, status in ((True, "needs your reply"), (False, "working")): + decided = attention.merge( + poll_status=status, + poll_needs_you=needs_you, + poll_stamp_ms=1_000, + record=None, + source="auto", + ) + self.assertEqual(needs_you, decided["needs_you"]) + self.assertEqual(status, decided["agent_status"]) + self.assertEqual("poll", decided["attention_source"]) + + def test_a_record_that_is_not_exactly_what_the_hook_writes_is_ignored( + self, + ) -> None: + """Corruption falls back to the poll, like unreadable JSON already did. + + Both coercions lie: `bool("false")` is True, so a needs_you that + arrived as a STRING would raise a hand nobody raised, and + `isinstance(True, int)` is True, so a recorded_at_ms of `true` reads as + the timestamp 1 and loses every comparison it is in. + """ + for damage in ( + {"needs_you": "false"}, + {"needs_you": 1}, + {"recorded_at_ms": True}, + {"recorded_at_ms": "9999"}, + ): + with tempfile.TemporaryDirectory(prefix="attention-") as temporary: + state = Path(temporary) + attention.write_record( + state, + session_id=SESSION, + hook_event="Notification", + notification_type="idle_prompt", + needs_you=True, + recorded_at_ms=5, + ) + path = attention.record_path(state, SESSION) + record = json.loads(path.read_text(encoding="utf-8")) + record.update(damage) + path.write_text(json.dumps(record), encoding="utf-8") + self.assertIsNone( + attention.read_record(state, SESSION), + f"{damage} was accepted as evidence", + ) + + def test_the_hook_implements_exactly_the_clearing_contract(self) -> None: + source = HOOK.read_text(encoding="utf-8") + for event in attention.CLEARING_EVENTS: + self.assertIn(event, source, f"the hook handles no {event}") + for name in attention.NEEDS_YOU_NOTIFICATIONS | attention.CLEARING_NOTIFICATIONS: + self.assertIn(name, source, f"the hook classifies no {name}") + + def test_a_record_from_another_session_is_not_read(self) -> None: + with tempfile.TemporaryDirectory(prefix="attention-") as temporary: + state = Path(temporary) + attention.write_record( + state, + session_id=SESSION, + hook_event="Notification", + notification_type="idle_prompt", + needs_you=True, + recorded_at_ms=5, + ) + path = attention.record_path(state, SESSION) + record = json.loads(path.read_text(encoding="utf-8")) + record["session_id"] = "11111111-2222-4333-8444-555555555555" + path.write_text(json.dumps(record), encoding="utf-8") + self.assertIsNone(attention.read_record(state, SESSION)) + + +class PollIsStillCollectedTests(unittest.TestCase): + def test_the_hook_never_replaces_the_poll_for_sessions_it_never_saw(self) -> None: + """A record without a live row is nobody's evidence.""" + with tempfile.TemporaryDirectory(prefix="attention-") as temporary: + state = Path(temporary) + attention.write_record( + state, + session_id=SESSION, + hook_event="Notification", + notification_type="idle_prompt", + needs_you=True, + recorded_at_ms=5, + ) + self.assertEqual({}, attention.read_all(state, [])) + self.assertEqual([SESSION], list(attention.read_all(state, [SESSION]))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_auto_name_reliability.py b/tests/test_auto_name_reliability.py new file mode 100644 index 0000000..dd0fdce --- /dev/null +++ b/tests/test_auto_name_reliability.py @@ -0,0 +1,840 @@ +"""A Codex session carries its real name, whatever else is in the store. + +The Codex titler used to look at the 200 most recent threads, ignore anything +older than seven days, stop after twenty names and twenty repairs per pass, and +return silently when its database could not be read. Claude's side is +event-driven and named everything. So a Codex thread past any one of those +edges stayed nameless on every surface -- picker, tab, and bounce -- with +nothing anywhere saying why. + +The bound is now time, not count or age: a pass ends when its budget runs out +and the next one continues, so a backlog converges instead of being abandoned. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path +import sqlite3 +import subprocess +import sys +import tempfile +import time +import unittest +from unittest import mock + +from tests.support import REPO + + +def names_push(): + library = os.fspath(REPO / "lib") + if library not in sys.path: + sys.path.insert(0, library) + return importlib.import_module("sessionkit_inventory.names_push") + + +def uuid_for(number: int) -> str: + return f"00000000-0000-4000-8000-{number:012d}" + + +class CodexTitlerTests(unittest.TestCase): + def store(self, root: Path, threads: list[tuple[str, str, str, float]]) -> Path: + """A Codex home whose thread store holds exactly these threads.""" + codex = root / ".codex" + codex.mkdir(parents=True, mode=0o700) + database = codex / "state.db" + connection = sqlite3.connect(database) + connection.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT," + " first_user_message TEXT, updated_at REAL, thread_source TEXT)" + ) + connection.executemany( + "INSERT INTO threads (id, title, first_user_message, updated_at," + " thread_source) VALUES (?, ?, ?, ?, 'user')", + threads, + ) + connection.commit() + connection.close() + return codex + + def run_pass(self, codex: Path, home: Path, **extra): + module = names_push() + titled = [] + + def derive_title(first: str) -> str: + return " ".join(str(first).split()[:7]).title() + + owners: dict[str, str] = {} + + def claim(_provider, uuid, **_keywords): + if owners.get(uuid): + return f"a {owners[uuid]} name already owns this session" + owners[uuid] = "automatic" + return "" + + def release(_provider, uuid, **_keywords): + if owners.get(uuid) != "automatic": + return False + owners.pop(uuid, None) + return True + + # `claim_automatic_name` returns "" when the claim LANDED and the + # reason it did not otherwise; the default here is a claim that lands. + extra.setdefault("claim_name", claim) + extra.setdefault("release_claim", release) + extra.setdefault("record_pushed", lambda *_a, **_k: None) + extra.setdefault( + "name_owner", lambda _provider, uuid, **_keywords: owners.get(uuid, "") + ) + extra.setdefault( + "human_named", + lambda _environ: frozenset( + f"codex:{uuid}" for uuid, owner in owners.items() if owner == "human" + ), + ) + extra.setdefault("adopt_native", lambda *_a, **_k: "") + environ = extra.pop( + "environ", + {"HOME": os.fspath(home), "SESSION_KIT_CODEX_LIVE_RENAME": "0"}, + ) + live_rename = extra.pop("push_live_rename", lambda *_a, **_k: ([], [])) + return module.codex_pending_auto_titles( + environ, + codex_paths=lambda: (codex, codex / "state.db"), + reconcile_pending_titles=lambda *_arguments, **_keywords: titled, + derive_title=derive_title, + load_config=extra.pop("load_config", dict), + max_session_index_bytes=extra.pop("max_session_index_bytes", 1_000_000), + push_live_rename=live_rename, + **extra, + ) + + def indexed(self, codex: Path) -> dict[str, str]: + index = codex / "session_index.jsonl" + if not index.is_file(): + return {} + found: dict[str, str] = {} + for line in index.read_text(encoding="utf-8").splitlines(): + entry = json.loads(line) + found[entry["id"]] = entry["thread_name"] + return found + + def test_a_thread_older_than_a_week_still_gets_its_name(self) -> None: + with tempfile.TemporaryDirectory(prefix=".titler-old-", dir=REPO) as raw: + home = Path(raw) + old = time.time() - 30 * 86400 + codex = self.store( + home, [(uuid_for(1), "", "fix the broken upload importer", old)] + ) + result = self.run_pass(codex, home) + self.assertEqual([uuid_for(1)], [item["uuid"] for item in result]) + self.assertIn(uuid_for(1), self.indexed(codex)) + + def test_the_two_hundred_and_first_thread_gets_its_name(self) -> None: + with tempfile.TemporaryDirectory(prefix=".titler-many-", dir=REPO) as raw: + home = Path(raw) + now = time.time() + threads = [ + (uuid_for(number), "", f"question number {number}", now - number) + for number in range(1, 261) + ] + codex = self.store(home, threads) + # A stopped clock, because this test is about the backlog and not + # about the two-second budget: with the real clock, a loaded CI + # runner spent the budget partway through and named 169 of 260. + # The budget has its own tests, which pin the clock the same way. + result = self.run_pass( + codex, home, budget_seconds=1.0, monotonic=lambda: 0.0 + ) + self.assertEqual(260, len(result)) + self.assertIn(uuid_for(260), self.indexed(codex)) + + def test_a_brand_new_thread_is_named_on_the_first_pass(self) -> None: + """The first-minute promise: one refresh, and the name is there.""" + with tempfile.TemporaryDirectory(prefix=".titler-new-", dir=REPO) as raw: + home = Path(raw) + now = time.time() + threads = [ + (uuid_for(number), "", f"older question {number}", now - 4000 - number) + for number in range(1, 240) + ] + threads.append( + (uuid_for(900), "", "name this session properly please", now - 5) + ) + codex = self.store(home, threads) + # A stopped clock: the promise under test is that the newest thread + # is reached first among two hundred and forty, not that one machine + # fits all of them inside two seconds. The budget has its own tests. + self.run_pass(codex, home, budget_seconds=1.0, monotonic=lambda: 0.0) + self.assertIn(uuid_for(900), self.indexed(codex)) + + def test_a_pass_stops_at_its_budget_and_the_next_one_continues(self) -> None: + with tempfile.TemporaryDirectory(prefix=".titler-budget-", dir=REPO) as raw: + home = Path(raw) + now = time.time() + threads = [ + (uuid_for(number), "", f"question number {number}", now - number) + for number in range(1, 30) + ] + codex = self.store(home, threads) + ticks = iter([0.0] + [0.0] * 30 + [99.0] * 200) + first = self.run_pass( + codex, home, budget_seconds=1.0, monotonic=lambda: next(ticks) + ) + self.assertGreater(len(first), 0) + self.assertLess(len(first), 29, "the budget has to end the pass") + named = set(self.indexed(codex)) + second = self.run_pass(codex, home) + # The second pass picks up exactly what the first one left. + self.assertTrue({item["uuid"] for item in second}) + self.assertTrue(named.isdisjoint({item["uuid"] for item in second})) + self.assertEqual(29, len(self.indexed(codex))) + + def test_the_deadline_is_passed_into_the_slowest_store_operation(self) -> None: + """One blocking write cannot consume more than the whole pass budget.""" + with tempfile.TemporaryDirectory(prefix=".titler-deadline-", dir=REPO) as raw: + home = Path(raw) + codex = self.store( + home, [(uuid_for(1), "", "fix the broken upload importer", time.time())] + ) + received: list[float | None] = [] + + # A write that ignores its deadline blocks for five seconds; one + # that honours it returns in ten milliseconds. The gap is that wide + # on purpose. It used to be 60ms against a 45ms ceiling, which asked + # the machine to be fast rather than asking the code to be right -- + # a busy runner spent 45ms on the fixture alone and failed a + # deadline that had been passed and honoured (2026-08-18, + # ubuntu-24.04/3.11). The sleep only ever runs long when the + # behaviour under test is actually broken. + def slow_store( + _root, _uuid, _title, *, timeout_seconds=None, **_keywords + ): + received.append(timeout_seconds) + time.sleep(5.0 if timeout_seconds is None else timeout_seconds) + return [], [] + + started = time.monotonic() + with mock.patch.object( + names_push(), "_push_codex_thread_title", side_effect=slow_store + ): + # And a stopped clock, because widening that gap left the other + # half of the same fault in place. The ten milliseconds are the + # budget for the WHOLE pass, and the connect, the schema probe, + # the first page and the index read all spend it before the row + # loop is reached; a pass already over budget there breaks + # before calling the store at all, and `received` is empty. + # That is the IndexError this test died on twice on busy + # runners (2026-08-18, ubuntu-24.04/3.11 and 22.04/3.12), and + # pinning one CPU against three spinners reproduces it in 2 of + # 10 runs. A pinned clock hands the store the same ten + # milliseconds every time, so the deadline is measured instead + # of the machine. The elapsed check below still runs on the + # real clock, which is what makes an ignored deadline visible. + self.run_pass( + codex, home, budget_seconds=0.01, monotonic=lambda: 0.0 + ) + elapsed = time.monotonic() - started + self.assertIsNotNone(received[0]) + self.assertLessEqual(received[0], 0.01) + self.assertLess(elapsed, 2.0, f"10ms pass took {elapsed:.3f}s") + + def test_live_rename_receives_only_the_remaining_pass_budget(self) -> None: + with tempfile.TemporaryDirectory(prefix=".titler-live-budget-", dir=REPO) as raw: + home = Path(raw) + codex = self.store( + home, [(uuid_for(1), "", "fix the broken upload importer", time.time())] + ) + received: list[float | None] = [] + + def slow_live( + _state, _uuid, _title, *, timeout_seconds=None, **_keywords + ): + received.append(timeout_seconds) + time.sleep(0.06 if timeout_seconds is None else timeout_seconds) + return [], [] + + self.run_pass( + codex, + home, + budget_seconds=0.01, + # The same pinned clock as the store-deadline test above, for + # the same reason: ten milliseconds is the budget for the whole + # pass, so a runner that spends it on the fixture never reaches + # the row, never calls this rename, and dies on an empty + # `received` rather than on the deadline. This one has not + # failed in CI yet; it is the identical shape and would. + monotonic=lambda: 0.0, + environ={"HOME": os.fspath(home)}, + push_live_rename=slow_live, + ) + self.assertIsNotNone(received[0]) + self.assertLessEqual(received[0], 0.01) + + def test_each_socket_operation_uses_the_shared_remaining_deadline(self) -> None: + class FakeSocket: + def __init__(self, incoming: bytes = b"") -> None: + self.incoming = incoming + self.timeouts: list[float] = [] + + def settimeout(self, value: float) -> None: + self.timeouts.append(value) + + def sendall(self, _payload: bytes) -> None: + return None + + def recv(self, count: int) -> bytes: + chunk, self.incoming = self.incoming[:count], self.incoming[count:] + return chunk + + deadline = time.monotonic() + 0.01 + sender = FakeSocket() + names_push()._ws_send_frame(sender, b"hello", deadline=deadline) + receiver = FakeSocket(b"\x81\x00") + self.assertEqual( + (1, b""), + names_push()._ws_recv_frame( + receiver, + max_frame=names_push().MAX_CODEX_LIVE_RENAME_FRAME, + deadline=deadline, + ), + ) + for timeout in sender.timeouts + receiver.timeouts: + self.assertGreater(timeout, 0) + self.assertLessEqual(timeout, 0.01) + + def test_a_human_rename_that_wins_the_race_is_left_alone(self) -> None: + """The claim is the decision, so a refusal writes nothing at all. + + `/rename` or `sp name` can land between the ownership read and the + write. The claim refuses then -- and the old order claimed AFTER + appending the index entry, so the person owned the name locally while + every provider surface carried the automatic one. + """ + with tempfile.TemporaryDirectory(prefix=".titler-race-", dir=REPO) as raw: + home = Path(raw) + codex = self.store( + home, [(uuid_for(1), "", "fix the broken upload importer", time.time())] + ) + result = self.run_pass( + codex, + home, + claim_name=lambda *_a, **_k: "a human name already owns this session", + ) + self.assertEqual([], result) + self.assertEqual({}, self.indexed(codex)) + connection = sqlite3.connect(codex / "state.db") + stored = connection.execute( + "SELECT title FROM threads WHERE id = ?", (uuid_for(1),) + ).fetchone()[0] + connection.close() + self.assertEqual("", stored, "the provider surface was overwritten") + + def test_the_titler_records_its_durable_index_push(self) -> None: + """The fast-path evidence is written when the index append lands.""" + with tempfile.TemporaryDirectory(prefix=".titler-pushed-", dir=REPO) as raw: + home = Path(raw) + exact = uuid_for(1) + codex = self.store( + home, [(exact, "", "fix the broken upload importer", time.time())] + ) + recorded: list[tuple[str, str, str]] = [] + + result = self.run_pass( + codex, + home, + record_pushed=lambda provider, uuid, title, **_keywords: recorded.append( + (provider, uuid, title) + ), + ) + + self.assertEqual([exact], [item["uuid"] for item in result]) + self.assertEqual( + [("codex", exact, "Fix The Broken Upload Importer")], recorded + ) + self.assertEqual(recorded[0][2], self.indexed(codex)[exact]) + + def test_a_human_rename_after_the_claim_wins_every_surface(self) -> None: + """The ownership decision is re-read at the provider-write boundary.""" + with tempfile.TemporaryDirectory(prefix=".titler-postclaim-", dir=REPO) as raw: + home = Path(raw) + exact = uuid_for(1) + human_title = "The operator's chosen title" + codex = self.store( + home, [(exact, "", "fix the broken upload importer", time.time())] + ) + owner = "" + + def claim(*_arguments, **_keywords): + nonlocal owner + owner = "automatic" + names_push()._append_codex_index_entry( + codex / "session_index.jsonl", exact, human_title + ) + connection = sqlite3.connect(codex / "state.db") + connection.execute( + "UPDATE threads SET title = ? WHERE id = ?", (human_title, exact) + ) + connection.commit() + connection.close() + owner = "human" + return "" + + result = self.run_pass( + codex, + home, + claim_name=claim, + name_owner=lambda *_a, **_k: owner, + ) + self.assertEqual([], result) + self.assertEqual(human_title, self.indexed(codex)[exact]) + connection = sqlite3.connect(codex / "state.db") + stored = connection.execute( + "SELECT title FROM threads WHERE id = ?", (exact,) + ).fetchone()[0] + connection.close() + self.assertEqual(human_title, stored) + + def test_a_human_rename_before_sqlite_commit_rolls_back_the_auto_title(self) -> None: + with tempfile.TemporaryDirectory(prefix=".titler-db-race-", dir=REPO) as raw: + home = Path(raw) + exact = uuid_for(1) + codex = self.store( + home, [(exact, "", "fix the broken upload importer", time.time())] + ) + database = codex / "state_5.sqlite" + (codex / "state.db").rename(database) + checks = 0 + + def human_landed() -> bool: + nonlocal checks + checks += 1 + return False + + names_push()._push_codex_thread_title( + codex, + exact, + "Automatic Title", + timeout_seconds=1.0, + still_automatic=human_landed, + ) + connection = sqlite3.connect(database) + stored = connection.execute( + "SELECT title FROM threads WHERE id = ?", (exact,) + ).fetchone()[0] + connection.close() + self.assertEqual(1, checks) + self.assertEqual("", stored) + + def test_an_append_failure_releases_the_claim_and_the_next_pass_retries(self) -> None: + with tempfile.TemporaryDirectory(prefix=".titler-retry-", dir=REPO) as raw: + home = Path(raw) + exact = uuid_for(1) + codex = self.store( + home, [(exact, "", "fix the broken upload importer", time.time())] + ) + owner = "" + claims = 0 + releases = 0 + + def claim(*_arguments, **_keywords): + nonlocal owner, claims + claims += 1 + if owner: + return "already owned" + owner = "automatic" + return "" + + def release(*_arguments, **_keywords): + nonlocal owner, releases + releases += 1 + if owner == "automatic": + owner = "" + return True + return False + + real_append = names_push()._append_codex_index_entry + attempts = 0 + + def fail_once(*arguments): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise OSError("injected append failure") + return real_append(*arguments) + + arguments = { + "claim_name": claim, + "release_claim": release, + "name_owner": lambda *_a, **_k: owner, + } + with mock.patch.object( + names_push(), "_append_codex_index_entry", side_effect=fail_once + ): + self.assertEqual([], self.run_pass(codex, home, **arguments)) + self.assertNotIn(exact, self.indexed(codex)) + second = self.run_pass(codex, home, **arguments) + self.assertEqual([exact], [item["uuid"] for item in second]) + self.assertEqual((2, 2, 1), (attempts, claims, releases)) + self.assertEqual("automatic", owner) + + def test_existing_kit_rows_backfill_once_without_starving_the_tail(self) -> None: + """Four hundred settled kit rows converge onto the fast path. + + Half predate pushed-title bookkeeping. Exact retained or derived title + agreement backfills that history once; a second pass skips the whole + settled head without adoption or per-row owner reads. + """ + with tempfile.TemporaryDirectory(prefix=".titler-settled-", dir=REPO) as raw: + home = Path(raw) + now = time.time() + threads = [ + ( + uuid_for(number), + f"Settled {number}", + ( + f"prompt {number}" + if number <= 300 + else f"settled {number}" + ), + now - number, + ) + for number in range(1, 401) + ] + threads.extend( + (uuid_for(number), "", f"name tail {number}", now - 500 - number) + for number in range(900, 940) + ) + codex = self.store(home, threads) + index = codex / "session_index.jsonl" + index.write_text( + "".join( + json.dumps({"id": uuid_for(number), "thread_name": f"Settled {number}"}) + + "\n" + for number in range(1, 401) + ), + encoding="utf-8", + ) + adoption_reads: list[str] = [] + owner_reads: list[str] = [] + pushed_writes: list[tuple[str, str, str]] = [] + ownership_reads = 0 + document = { + "automatic_titles": { + f"codex:{uuid_for(number)}": f"Settled {number}" + for number in range(201, 301) + }, + "pushed_titles": { + f"codex:{uuid_for(number)}": f"Settled {number}" + for number in range(1, 201) + }, + } + + def human_named(_environ): + nonlocal ownership_reads + ownership_reads += 1 + return frozenset() + + def name_owner(_provider, uuid, **_keywords): + owner_reads.append(uuid) + # The first tail read sees no owner; the real claim between + # reads then makes every write-boundary check automatic. + return "automatic" if owner_reads.count(uuid) > 1 else "" + + def record_pushed(provider, uuid, title, **_keywords): + pushed_writes.append((provider, uuid, title)) + document["pushed_titles"][f"{provider}:{uuid}"] = title + + # A stopped clock: the assertion below names all forty tail + # threads exactly, so the pass has to finish its scan. Against the + # real budget a slow runner truncates that list and the test reads + # as a starved tail when nothing starved it. The budget has its own + # tests, which pin the clock this way. + result = self.run_pass( + codex, + home, + budget_seconds=1.0, + monotonic=lambda: 0.0, + load_config=lambda: document, + human_named=human_named, + name_owner=name_owner, + record_pushed=record_pushed, + adopt_native=lambda _p, uuid, *_a, **_k: adoption_reads.append(uuid) + or "", + ) + self.assertEqual( + [uuid_for(number) for number in range(900, 940)], + [item["uuid"] for item in result], + ) + self.assertEqual(1, ownership_reads) + self.assertNotIn(uuid_for(1), adoption_reads) + self.assertNotIn(uuid_for(1), owner_reads) + self.assertNotIn(uuid_for(201), adoption_reads) + self.assertNotIn(uuid_for(201), owner_reads) + self.assertNotIn(uuid_for(900), adoption_reads) + self.assertIn(uuid_for(900), owner_reads) + historical_writes = [ + item for item in pushed_writes if 201 <= int(item[1][-12:]) <= 400 + ] + self.assertEqual( + [ + ("codex", uuid_for(number), f"Settled {number}") + for number in range(201, 401) + ], + historical_writes, + ) + + writes_after_first_pass = len(pushed_writes) + adoption_reads.clear() + owner_reads.clear() + second = self.run_pass( + codex, + home, + budget_seconds=1.0, + monotonic=lambda: 0.0, + load_config=lambda: document, + human_named=human_named, + name_owner=name_owner, + record_pushed=record_pushed, + adopt_native=lambda _p, uuid, *_a, **_k: adoption_reads.append(uuid) + or "", + ) + self.assertEqual([], second) + self.assertEqual(writes_after_first_pass, len(pushed_writes)) + self.assertFalse( + any(1 <= int(uuid[-12:]) <= 400 for uuid in adoption_reads) + ) + self.assertFalse(any(1 <= int(uuid[-12:]) <= 400 for uuid in owner_reads)) + + def test_a_pass_with_no_budget_left_never_touches_the_store(self) -> None: + """The query and its result set are inside the bound, not outside it. + + The old shape asked for every matching row and materialized the lot + before the first deadline check. Here the store is unreadable: a pass + that queried it would say so, and a pass that respects an expired + budget says nothing because it never asked. + """ + import contextlib + import io + + with tempfile.TemporaryDirectory(prefix=".titler-nobudget-", dir=REPO) as raw: + home = Path(raw) + codex = self.store(home, [(uuid_for(1), "", "prompt", time.time())]) + (codex / "state.db").write_text("not a database", encoding="utf-8") + ticks = iter([0.0] + [99.0] * 50) + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + result = self.run_pass( + codex, home, budget_seconds=1.0, monotonic=lambda: next(ticks) + ) + self.assertEqual([], result) + self.assertEqual("", stderr.getvalue()) + + def test_a_large_store_converges_over_a_few_passes(self) -> None: + """Two thousand unnamed threads, and every one ends up named.""" + with tempfile.TemporaryDirectory(prefix=".titler-large-", dir=REPO) as raw: + home = Path(raw) + now = time.time() + total = 2000 + codex = self.store( + home, + [ + (uuid_for(number), "", f"question number {number}", now - number) + for number in range(1, total + 1) + ], + ) + passes = 0 + while passes < 12: + passes += 1 + # A stopped clock, for the same reason the 260-thread test + # pins one: this is about two thousand threads all reaching a + # name, not about how many of them one machine fits inside a + # two-second budget. Against the real clock a loaded runner + # drained too little per pass and ran out of passes with the + # backlog still standing -- a failure about the runner + # (2026-08-18, ubuntu-24.04/3.11). The budget has its own + # tests, which pin the clock exactly this way. + named = self.run_pass( + codex, home, budget_seconds=1.0, monotonic=lambda: 0.0 + ) + if not named: + break + self.assertEqual(total, len(self.indexed(codex))) + self.assertLess(passes, 12, "the backlog never drained") + + def test_an_unreadable_thread_store_is_reported_not_swallowed(self) -> None: + with tempfile.TemporaryDirectory(prefix=".titler-broken-", dir=REPO) as raw: + home = Path(raw) + codex = home / ".codex" + codex.mkdir(parents=True, mode=0o700) + (codex / "state.db").write_text("this is not a database", encoding="utf-8") + import contextlib + import io + + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + result = self.run_pass(codex, home) + self.assertEqual([], result) + self.assertIn("Codex thread store unreadable", stderr.getvalue()) + + def test_a_failure_is_left_where_a_person_will_meet_it(self) -> None: + """stderr alone reached nobody: the caller sends both streams to /dev/null.""" + import contextlib + import io + + with tempfile.TemporaryDirectory(prefix=".titler-record-", dir=REPO) as raw: + home = Path(raw) + state = home / ".local" / "state" / "session-kit" + codex = self.store(home, [(uuid_for(1), "", "prompt", time.time())]) + (codex / "state.db").write_text("not a database", encoding="utf-8") + with contextlib.redirect_stderr(io.StringIO()): + self.run_pass(codex, home) + record = json.loads( + (state / "codex-autotitle-error.json").read_text(encoding="utf-8") + ) + self.assertIn("thread store unreadable", record["detail"]) + self.assertTrue(record["at"].endswith("Z")) + + def test_a_mid_scan_store_failure_keeps_its_doctor_record(self) -> None: + """A page-two failure is not a clean pass that can clear its own alert.""" + with tempfile.TemporaryDirectory(prefix=".titler-midscan-", dir=REPO) as raw: + home = Path(raw) + now = time.time() + codex = self.store( + home, + [ + (uuid_for(number), "", f"question {number}", now - number) + for number in range(1, 202) + ], + ) + real_connect = sqlite3.connect + calls = 0 + + def fail_page_two(*args, **kwargs): + nonlocal calls + calls += 1 + if calls == 3: # schema probe, page one, then page two + raise sqlite3.OperationalError("injected page-two failure") + return real_connect(*args, **kwargs) + + # A stopped clock, or page two is never reached: the injected + # failure fires on the third connect, and a runner that spends the + # two-second budget inside page one never gets there -- so nothing + # fails, no record is written, and the test dies reading a file that + # was never created (2026-08-18, ubuntu-24.04/3.10). What is under + # test is that a mid-scan failure keeps its record, not how much of + # a two-hundred-thread store one machine covers per second. + with mock.patch("sqlite3.connect", side_effect=fail_page_two): + self.run_pass(codex, home, budget_seconds=1.0, monotonic=lambda: 0.0) + record = json.loads( + ( + home / ".local" / "state" / "session-kit" + / "codex-autotitle-error.json" + ).read_text(encoding="utf-8") + ) + self.assertIn("injected page-two failure", record["detail"]) + + def test_a_healthy_pass_retires_the_last_complaint(self) -> None: + with tempfile.TemporaryDirectory(prefix=".titler-clear-", dir=REPO) as raw: + home = Path(raw) + state = home / ".local" / "state" / "session-kit" + state.mkdir(parents=True) + stale = state / "codex-autotitle-error.json" + stale.write_text(json.dumps({"detail": "old", "at": "x"}), encoding="utf-8") + codex = self.store(home, [(uuid_for(1), "", "prompt", time.time())]) + self.run_pass(codex, home) + self.assertFalse(stale.exists()) + + def test_a_machine_without_codex_carries_no_stale_complaint(self) -> None: + with tempfile.TemporaryDirectory(prefix=".titler-nocodex-", dir=REPO) as raw: + home = Path(raw) + state = home / ".local" / "state" / "session-kit" + state.mkdir(parents=True) + stale = state / "codex-autotitle-error.json" + stale.write_text(json.dumps({"detail": "old", "at": "x"}), encoding="utf-8") + codex = home / ".codex" + codex.mkdir(mode=0o700) + self.run_pass(codex, home) + self.assertFalse(stale.exists()) + + def test_an_oversized_index_is_reported_not_swallowed(self) -> None: + import contextlib + import io + + with tempfile.TemporaryDirectory(prefix=".titler-big-", dir=REPO) as raw: + home = Path(raw) + codex = self.store(home, [(uuid_for(1), "", "prompt", time.time())]) + (codex / "session_index.jsonl").write_text("x" * 5000, encoding="utf-8") + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + result = self.run_pass(codex, home, max_session_index_bytes=100) + self.assertEqual([], result) + self.assertIn("larger than the bounded size", stderr.getvalue()) + record = json.loads( + ( + home / ".local" / "state" / "session-kit" / "codex-autotitle-error.json" + ).read_text(encoding="utf-8") + ) + self.assertIn("larger than the bounded size", record["detail"]) + + def test_the_budget_can_be_set_and_a_nonsense_value_is_ignored(self) -> None: + module = names_push() + self.assertEqual(2.0, module._autotitle_budget_seconds({})) + self.assertEqual( + 5.5, + module._autotitle_budget_seconds( + {"SESSION_KIT_CODEX_AUTOTITLE_BUDGET_SECONDS": "5.5"} + ), + ) + for nonsense in ("0", "-3", "900", "soon"): + self.assertEqual( + 2.0, + module._autotitle_budget_seconds( + {"SESSION_KIT_CODEX_AUTOTITLE_BUDGET_SECONDS": nonsense} + ), + ) + + +class ReconcileCopyTests(unittest.TestCase): + """`sp color reconcile` names the providers it actually recolored.""" + + def render(self, payload: dict) -> str: + commands = (REPO / "lib" / "sh" / "sp_commands.sh").read_text(encoding="utf-8") + body = commands.split("color_reconcile() {", 1)[1].split("\n}\n", 1)[0] + program = body.split("python3 -c '", 1)[1].rsplit("'", 1)[0] + return subprocess.run( + [sys.executable, "-c", program], + input=json.dumps(payload), + capture_output=True, + text=True, + check=True, + ).stdout + + def test_a_codex_only_run_never_talks_about_claude(self) -> None: + output = self.render({"moved": {f"codex:{uuid_for(1)}": "lime"}}) + self.assertIn("Recolored 1 Codex session", output) + self.assertIn("Codex windows show the new color from their next start.", output) + self.assertNotIn("Claude", output) + + def test_a_mixed_run_names_both(self) -> None: + output = self.render( + { + "moved": { + f"codex:{uuid_for(1)}": "lime", + f"claude:{uuid_for(2)}": "cyan", + } + } + ) + self.assertIn( + "Claude and Codex windows show the new color from their next start.", + output, + ) + + def test_nothing_moved_says_nothing_changed(self) -> None: + self.assertEqual("Nothing changed.\n", self.render({"moved": {}})) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_change_model.py b/tests/test_change_model.py new file mode 100644 index 0000000..d86422c --- /dev/null +++ b/tests/test_change_model.py @@ -0,0 +1,324 @@ +"""Which model a session is on, and moving it to another one. + +Two promises. Every row says what model its provider is running, because a +person deciding where to send the next hour of work cannot see that anywhere +else. And a conversation can move to another model without being retyped: the +provider restarts on the model asked for and resumes the exact same +conversation. + +The safety is the account switch's, because the risk is the same one, a +restart in the middle of a turn loses that turn. A single stable row, a +conversation that is not mid-computation, no subagents, and a process tree with +nothing unrecognized in it, or the answer is one refusal line and nothing +changed. Nothing is ever asked (D7); automation names the exact session, +exactly as it does to close one. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +import sys +import unittest + +from tests.support import REPO, run +from tests.test_commands import CommandFixture + +SP = REPO / "bin" / "sp" +CORE = REPO / "lib" / "session_inventory.py" +SESSION = "s20260101-000000-1" +UUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + + +def facade() -> object: + sys.path.insert(0, os.fspath(REPO / "lib")) + spec = importlib.util.spec_from_file_location("session_inventory_model", CORE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class ModelColumnTests(unittest.TestCase): + """The model comes off the live provider, or the row says nothing.""" + + def setUp(self) -> None: + self.core = facade() + + def row(self, pid: int | None = 4242) -> dict: + return {"identity": {"pid": pid}} + + def test_the_command_line_model_is_the_answer(self) -> None: + self.assertEqual( + "claude-opus-5", + self.core._session_model( + self.row(), + {4242: {"cmdline": ["claude", "--model", "claude-opus-5", "--resume", UUID]}}, + ), + ) + + def test_the_launch_environment_answers_when_the_command_line_does_not(self) -> None: + self.assertEqual( + "gpt-5.4", + self.core._session_model( + self.row(), + {4242: {"cmdline": ["codex"], "requested_model": "gpt-5.4"}}, + ), + ) + + def test_a_session_on_the_providers_own_default_claims_no_model(self) -> None: + self.assertEqual( + "", self.core._session_model(self.row(), {4242: {"cmdline": ["claude"]}}) + ) + + def test_a_row_with_no_live_provider_claims_no_model(self) -> None: + self.assertEqual("", self.core._session_model(self.row(None), {})) + self.assertEqual("", self.core._session_model({}, {})) + + def test_control_bytes_never_reach_the_column(self) -> None: + self.assertEqual( + "opus 5", + self.core._session_model( + self.row(), + {4242: {"cmdline": ["claude", "--model", "opus\r\n5"]}}, + ), + ) + + +class ChangeModelTests(unittest.TestCase): + def setUp(self) -> None: + self.fixture = CommandFixture() + self.fixture.shpool_state.write_text( + json.dumps( + { + "sessions": [ + { + "name": SESSION, + "status": "Disconnected", + "started_at_unix_ms": 1_700_000_000_001, + } + ] + } + ), + encoding="utf-8", + ) + + def tearDown(self) -> None: + self.fixture.close() + + def env(self, **overrides: str) -> dict[str, str]: + table = { + "processes": [ + {"pid": 1001, "ppid": 1, "start_ticks": 10001, "cmdline": ["bash"]}, + { + "pid": 2001, + "ppid": 1001, + "start_ticks": 20001, + "cmdline": ["claude", "--model", "claude-old"], + }, + ] + } + env = { + **self.fixture.env(), + "STUB_DYNAMIC_PROVIDER": "claude", + "STUB_DYNAMIC_UUID": UUID, + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "STUB_DYNAMIC_AGENT_STATUS": "idle", + "STUB_DYNAMIC_MODEL": "claude-old", + "STUB_PROCESS_TABLE": json.dumps(table), + "SESSION_KIT_CONFIRM_ID": SESSION, + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + } + env.update(overrides) + return env + + def closed_records(self) -> list[list[str]]: + if not self.fixture.closed_log.exists(): + return [] + return [ + json.loads(line) + for line in self.fixture.closed_log.read_text(encoding="utf-8").splitlines() + if line + ] + + def test_an_idle_conversation_moves_to_the_model_asked_for(self) -> None: + moved = run([SP, "change-model", SESSION, "claude-new"], env=self.env()) + self.assertIn("Restored", moved.stdout) + self.assertIn("claude-new", moved.stdout) + log = self.fixture.shpool_log.read_text(encoding="utf-8").split() + self.assertEqual(["attach-exit", SESSION, "attach"], log[:3]) + # The same conversation, resumed, on the new model: the launch record + # the session shell reads names both. + launches = sorted(self.fixture.start.glob("*.launch")) + self.assertEqual(1, len(launches), launches) + fields = launches[0].read_text(encoding="utf-8").rstrip("\n").split("\t") + self.assertEqual("claude", fields[0]) + self.assertEqual("claude-new", fields[2]) + # The proven start clears its own records; what stays is the model + # request the session shell reads when it launches the provider. + self.assertEqual( + [path.name for path in launches], + sorted(path.name for path in self.fixture.start.iterdir()), + ) + # The old conversation was closed on purpose. Its findable evidence + # remains available to the shared live projection rather than being + # deleted after one point-in-time restore check. + intents = self.fixture.close_intent_log.read_text(encoding="utf-8") + self.assertIn(UUID, intents) + + def test_an_idle_codex_conversation_moves_to_the_model_asked_for(self) -> None: + table = { + "processes": [ + {"pid": 1001, "ppid": 1, "start_ticks": 10001, "cmdline": ["bash"]}, + { + "pid": 2001, + "ppid": 1001, + "start_ticks": 20001, + "cmdline": ["codex", "--model", "gpt-old"], + }, + ] + } + moved = run( + [SP, "change-model", SESSION, "gpt-new"], + env=self.env( + STUB_DYNAMIC_PROVIDER="codex", + STUB_DYNAMIC_MODEL="gpt-old", + STUB_PROCESS_TABLE=json.dumps(table), + ), + ) + self.assertIn("Restored", moved.stdout) + self.assertIn("gpt-new", moved.stdout) + log = self.fixture.shpool_log.read_text(encoding="utf-8").split() + self.assertEqual(["attach-exit", SESSION, "attach"], log[:3]) + launches = sorted(self.fixture.start.glob("*.launch")) + self.assertEqual(1, len(launches), launches) + fields = launches[0].read_text(encoding="utf-8").rstrip("\n").split("\t") + self.assertEqual("codex", fields[0]) + self.assertEqual("gpt-new", fields[2]) + records = self.closed_records() + self.assertNotIn(["closed-sessions", "forget", "codex", UUID], records) + intents = self.fixture.close_intent_log.read_text(encoding="utf-8") + self.assertIn(UUID, intents) + + def test_reopening_retains_the_closed_evidence_for_self_healing(self) -> None: + run([SP, "change-model", SESSION, "claude-new"], env=self.env()) + records = self.closed_records() + self.assertNotIn(["closed-sessions", "forget", "claude", UUID], records) + + def test_a_machine_session_stays_a_machine_session_after_the_restart(self) -> None: + run( + [SP, "change-model", SESSION, "claude-new"], + env=self.env(STUB_DYNAMIC_ORIGIN="machine"), + ) + stamps = [ + json.loads(line) + for line in self.fixture.origin_log.read_text(encoding="utf-8").splitlines() + ] + self.assertEqual("machine", stamps[-1][1]) + + def test_a_working_session_keeps_its_model(self) -> None: + refused = run( + [SP, "change-model", SESSION, "claude-new"], + env=self.env(STUB_DYNAMIC_AGENT_STATUS="working"), + check=False, + ) + self.assertEqual(1, refused.returncode) + self.assertEqual( + "session-kit: that session is working, so its model was not changed;" + " try again when it is idle\n", + refused.stderr, + ) + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_a_turn_that_starts_while_the_model_is_chosen_is_not_killed(self) -> None: + env = self.env() + env["STUB_DYNAMIC_AGENT_STATUS_AFTER_FIRST"] = "working" + + refused = run( + [SP, "change-model", SESSION, "claude-new"], + env=env, + check=False, + ) + self.assertEqual(1, refused.returncode) + self.assertIn("started working", refused.stderr) + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_an_unrecognized_child_process_keeps_its_model(self) -> None: + table = { + "processes": [ + {"pid": 1001, "ppid": 1, "start_ticks": 10001, "cmdline": ["bash"]}, + {"pid": 2001, "ppid": 1001, "start_ticks": 20001, "cmdline": ["claude"]}, + {"pid": 3001, "ppid": 1001, "start_ticks": 30001, "cmdline": ["rsync"]}, + ] + } + refused = run( + [SP, "change-model", SESSION, "claude-new"], + env=self.env(STUB_PROCESS_TABLE=json.dumps(table)), + check=False, + ) + self.assertEqual(1, refused.returncode) + self.assertIn("does not recognize", refused.stderr) + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_an_explicit_model_request_is_applied_despite_last_reply_evidence(self) -> None: + changed = run( + [SP, "change-model", SESSION, "claude-old"], env=self.env(), check=False + ) + self.assertEqual(0, changed.returncode) + self.assertIn("Restored", changed.stdout) + self.assertIn("claude-old", changed.stdout) + self.assertEqual( + ["attach-exit", SESSION, "attach"], + self.fixture.shpool_log.read_text(encoding="utf-8").split()[:3], + ) + launches = sorted(self.fixture.start.glob("*.launch")) + self.assertEqual(1, len(launches), launches) + self.assertEqual( + "claude-old", + launches[0].read_text(encoding="utf-8").rstrip("\n").split("\t")[2], + ) + + def test_an_unsafe_model_identifier_is_refused(self) -> None: + refused = run( + [SP, "change-model", SESSION, "claude new; rm -rf /"], + env=self.env(), + check=False, + ) + self.assertEqual(2, refused.returncode) + self.assertEqual( + "session-kit: unsupported or unsafe claude model identifier\n", + refused.stderr, + ) + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_a_shell_session_has_no_model_to_change(self) -> None: + refused = run( + [SP, "change-model", SESSION, "claude-new"], + env=self.env(STUB_DYNAMIC_PROVIDER="shell", STUB_DYNAMIC_UUID=""), + check=False, + ) + self.assertEqual(1, refused.returncode) + self.assertEqual( + "session-kit: only a Claude or Codex session runs on a model\n", + refused.stderr, + ) + + def test_automation_names_the_exact_session_or_nothing_changes(self) -> None: + env = self.env() + env.pop("SESSION_KIT_CONFIRM_ID") + refused = run( + [SP, "change-model", SESSION, "claude-new"], env=env, check=False + ) + self.assertEqual(1, refused.returncode) + self.assertEqual("Nothing changed.\n", refused.stdout) + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_no_screen_prints_a_session_id_or_a_conversation_uuid(self) -> None: + moved = run([SP, "change-model", SESSION, "claude-new"], env=self.env()) + self.assertNotIn(SESSION, moved.stdout) + self.assertNotIn(UUID, moved.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_claude_socket.py b/tests/test_claude_socket.py new file mode 100644 index 0000000..e541354 --- /dev/null +++ b/tests/test_claude_socket.py @@ -0,0 +1,1234 @@ +"""Delivery into a live Claude session, and the twelve ways it can fail. + +The bug this replaces is a single sentence. Claude delivery spawned a whole +`claude -p` sender and, whenever anything went wrong, reported "not registered +(possible trust prompt)" -- for a missing binary, for a session that had +exited, for a refused socket, and for an actual trust prompt. Twenty-one +undelivered events all carried it; seven were measured and not one was a trust +prompt. A cause that is always the same is not a cause. + +So every test here is one distinct ending, and each asserts that the outcome +NAMES that ending. The stub below is the vendor's socket contract as read off +the 2.1.229 binary: auth frame first or the connection is destroyed, JSON lines +after it, a session_id that must match the receiver's own, and receipts sent +back to the socket the sender named in `from`. + +What is NOT stubbed: the paths, the key-file naming and the permissions are the +real ones, because a client that looks in the wrong place fails identically +against a stub that agrees with it. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import shutil +import socket as socketmod +import stat +import sys +import tempfile +import threading +import time +import unittest +from unittest import mock + +from tests.support import REPO + +sys.path.insert(0, str(REPO / "lib")) + +from sessionkit_messages import claude_socket # noqa: E402 + + +SESSION = "22222222-3333-4444-8555-666666666666" +OTHER_SESSION = "11111111-2222-4333-8444-555555555555" + + +def runtime_env(root: Path, **extra: str) -> dict[str, str]: + """The environment a real session publishes its socket in. + + A live session's socket is ``$XDG_RUNTIME_DIR/cc-socks/<pid>.sock`` and the + client refuses one that is anywhere else, so a fixture that left the + variable unset would not be a fixture of a real session. StubSession puts + its socket in ``<root>/run/cc-socks``, which makes ``<root>/run`` the + runtime directory these tests run against. + """ + environment = {"XDG_RUNTIME_DIR": os.fspath(root / "run")} + environment.update(extra) + return environment + + +class StubSession: + """A Claude session as far as its messaging socket is concerned.""" + + def __init__( + self, + root: Path, + *, + session_id: str = SESSION, + pid: int = 424242, + receipt: str | None = "delivered", + require_auth: bool = True, + publish_key: bool = True, + token: str = "a" * 32, + close_after_accept: bool = False, + reject_after_seconds: float | None = None, + stop_reading_after_auth: bool = False, + ) -> None: + self.root = root + self.session_id = session_id + self.pid = pid + self.receipt = receipt + self.require_auth = require_auth + self.token = token + self.publish_key = publish_key + self.close_after_accept = close_after_accept + self.stop_reading_after_auth = stop_reading_after_auth + self.reject_after_seconds = reject_after_seconds + # Connections deliberately left open with their read side gone. + self.held: list[socketmod.socket] = [] + self.received: list[dict] = [] + self.controls: list[dict] = [] + self.socket_dir = root / "run" / "cc-socks" + self.socket_dir.mkdir(parents=True, exist_ok=True) + self.socket_path = self.socket_dir / f"{pid}.sock" + self.config_dir = root / ".claude" + (self.config_dir / "sessions").mkdir(parents=True, exist_ok=True) + self.server: socketmod.socket | None = None + self.thread: threading.Thread | None = None + self.stopping = threading.Event() + + # -- the two files a real session publishes ----------------------------- + def write_record(self, *, socket_path: str | None = "") -> None: + record = { + "pid": self.pid, + "sessionId": self.session_id, + "kind": "interactive", + "version": "2.1.229", + "status": "busy", + "cwd": str(self.root), + } + if socket_path != "": + if socket_path is not None: + record["messagingSocketPath"] = socket_path + else: + record["messagingSocketPath"] = os.fspath(self.socket_path) + path = self.config_dir / "sessions" / f"{self.pid}.json" + path.write_text(json.dumps(record), encoding="utf-8") + + def write_key(self) -> None: + if not self.publish_key: + return + digest = hashlib.sha256( + os.fspath(Path(os.path.realpath(self.socket_path))).encode("utf-8") + ).hexdigest() + path = self.config_dir / "sessions" / f"{self.pid}.{digest}.key" + path.write_text(json.dumps({"peerToken": self.token}), encoding="utf-8") + os.chmod(path, 0o600) + + # -- the socket ---------------------------------------------------------- + def start(self) -> None: + self.stopping = threading.Event() + self.server = socketmod.socket(socketmod.AF_UNIX, socketmod.SOCK_STREAM) + self.server.bind(os.fspath(self.socket_path)) + os.chmod(self.socket_path, 0o600) + self.server.listen(4) + # Closing a socket does not wake a thread already blocked in accept(), + # so the loop wakes on its own and looks at the flag instead. + self.server.settimeout(0.1) + self.thread = threading.Thread(target=self._serve, daemon=True) + self.thread.start() + + def stop(self) -> None: + for connection in getattr(self, "held", []): + try: + connection.close() + except OSError: + pass + stopping = getattr(self, "stopping", None) + if stopping is not None: + stopping.set() + if self.thread is not None: + self.thread.join(timeout=5) + self.thread = None + if self.server is not None: + try: + self.server.close() + except OSError: + pass + self.server = None + + def _serve(self) -> None: + while not self.stopping.is_set(): + try: + connection, _ = self.server.accept() # type: ignore[union-attr] + except (socketmod.timeout, TimeoutError): + continue + except OSError: + return + threading.Thread( + target=self._handle, args=(connection,), daemon=True + ).start() + + def _handle(self, connection: socketmod.socket) -> None: + authenticated = not self.require_auth + first = True + buffer = b"" + keep_open = False + connection.settimeout(10) + try: + while True: + try: + block = connection.recv(65536) + except OSError: + return + if not block: + return + buffer += block + while b"\n" in buffer: + line, _, buffer = buffer.partition(b"\n") + if not line.strip(): + continue + record = json.loads(line.decode("utf-8")) + if first: + first = False + if record.get("type") == "auth": + if record.get("token") == self.token: + if self.reject_after_seconds is not None: + # A busy session that gets round to + # refusing the token only later. + time.sleep(self.reject_after_seconds) + connection.close() + return + if self.stop_reading_after_auth: + # Took the token, then went away before the + # message. The connection is NOT closed, so + # the client's auth probe cannot see it -- + # the body write is what finds out, and it + # finds out with EPIPE. + connection.shutdown(socketmod.SHUT_RD) + self.held.append(connection) + keep_open = True + return + authenticated = True + continue + if self.require_auth and not authenticated: + # Exactly what the daemon does: no reply, socket + # destroyed. + connection.close() + return + if not authenticated: + connection.close() + return + if record.get("type") == "control": + self.controls.append(record) + continue + if record.get("type") != "user": + continue + if record.get("session_id") not in (None, self.session_id): + continue + self.received.append(record) + self._receipt(record) + if self.close_after_accept: + # Accepted, queued, and done with the connection -- the + # shape that used to be reported as an auth refusal. + connection.close() + return + finally: + if not keep_open: + try: + connection.close() + except OSError: + pass + + def _receipt(self, record: dict) -> None: + if self.receipt is None: + return + address = str(record.get("from") or "") + if not address.startswith("uds:"): + return + target = address[4:] + reply = socketmod.socket(socketmod.AF_UNIX, socketmod.SOCK_STREAM) + try: + reply.settimeout(5) + reply.connect(target) + reply.sendall( + json.dumps( + { + "type": "control", + "action": "peer_message_status", + "orig_msg_id": record.get("msg_id"), + "status": self.receipt, + } + ).encode("utf-8") + + b"\n" + ) + except OSError: + pass + finally: + reply.close() + + +class SocketDeliveryTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="claude-socket-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + + def session(self, **kwargs) -> StubSession: + stub = StubSession(self.root, **kwargs) + self.addCleanup(stub.stop) + return stub + + def send(self, stub: StubSession, text: str = "hello", **kwargs): + return claude_socket.send( + kwargs.pop("session_id", stub.session_id), + text, + environ=kwargs.pop("environ", runtime_env(self.root)), + home=self.root, + config_dir=stub.config_dir, + timeout=kwargs.pop("timeout", 3), + **kwargs, + ) + + def test_a_receipt_is_what_makes_it_delivered(self) -> None: + stub = self.session() + stub.write_record() + stub.write_key() + stub.start() + outcome = self.send(stub, "the report is ready") + self.assertTrue(outcome.delivered, outcome.detail) + self.assertEqual("delivered", outcome.how) + self.assertTrue(outcome.authenticated) + self.assertEqual(1, len(stub.received)) + self.assertEqual( + "the report is ready", stub.received[0]["message"]["content"] + ) + # The message pins the conversation, so a recycled pid cannot be + # handed someone else's answer. + self.assertEqual(stub.session_id, stub.received[0]["session_id"]) + + def test_a_message_written_but_never_confirmed_is_not_delivered(self) -> None: + """The live case, not a corner: an ACCEPTED message sends no receipt. + + Measured against a real 2.1.229 session on 2026-08-13 -- it rendered + the injected prompt and answered it, and wrote nothing back. So the + sender must not call this delivered on its own say-so; it hands back + the message uuid instead, which is the id the message carries in the + target's own transcript. + """ + stub = self.session(receipt=None) + stub.write_record() + stub.write_key() + stub.start() + outcome = self.send(stub, timeout=2) + self.assertFalse(outcome.delivered) + self.assertEqual("sent-no-receipt", outcome.how) + self.assertTrue(outcome.authenticated) + self.assertEqual(1, len(stub.received)) + self.assertEqual( + stub.received[0]["uuid"], outcome.as_dict()["message_uuid"] + ) + + def test_a_held_message_is_reported_as_held_not_as_a_failure_to_reach( + self, + ) -> None: + stub = self.session(receipt="held") + stub.write_record() + stub.write_key() + stub.start() + outcome = self.send(stub) + self.assertFalse(outcome.delivered) + self.assertEqual("held", outcome.how) + self.assertIn("approve", outcome.detail) + + def test_a_declined_message_says_the_operator_declined_it(self) -> None: + stub = self.session(receipt="denied") + stub.write_record() + stub.write_key() + stub.start() + outcome = self.send(stub) + self.assertEqual("denied", outcome.how) + self.assertIn("declined", outcome.detail) + + def test_a_session_that_exited_is_a_refused_socket_not_a_trust_prompt( + self, + ) -> None: + stub = self.session() + stub.write_record() + stub.write_key() + # The socket file outlives the process that bound it. + stub.socket_path.parent.mkdir(parents=True, exist_ok=True) + listener = socketmod.socket(socketmod.AF_UNIX, socketmod.SOCK_STREAM) + listener.bind(os.fspath(stub.socket_path)) + listener.close() + outcome = self.send(stub) + self.assertEqual("socket-refused", outcome.how) + self.assertEqual( + claude_socket.FAILURE_DETAIL[claude_socket.SOCKET_REFUSED], + outcome.detail, + ) + self.assertFalse(outcome.as_dict().get("sent", False)) + + def test_a_session_claude_never_recorded_says_exactly_that(self) -> None: + stub = self.session() + outcome = self.send(stub, session_id=OTHER_SESSION) + self.assertEqual("no-session-record", outcome.how) + + def test_a_bare_session_says_it_has_no_socket(self) -> None: + """--bare (and pre-2.1.224) sessions publish no socket at all.""" + stub = self.session() + stub.write_record(socket_path=None) + outcome = self.send(stub) + self.assertEqual("no-socket-path", outcome.how) + self.assertIn("--bare", outcome.detail) + + def test_a_socket_with_no_file_is_not_confused_with_a_refusal(self) -> None: + stub = self.session() + stub.write_record() + stub.write_key() + outcome = self.send(stub) + self.assertEqual("socket-missing", outcome.how) + + def test_a_session_that_published_no_key_is_never_sent_to_blind(self) -> None: + stub = self.session(publish_key=False) + stub.write_record() + stub.start() + outcome = self.send(stub) + self.assertEqual("no-auth-key", outcome.how) + self.assertEqual([], stub.received) + + def test_a_refused_token_is_reported_as_a_refused_token(self) -> None: + stub = self.session(token="b" * 32) + stub.write_record() + stub.write_key() + stub.token = "c" * 32 # the session rotated it after publishing + stub.start() + outcome = self.send(stub) + self.assertEqual("auth-rejected", outcome.how) + self.assertEqual([], stub.received) + + def test_the_kill_switch_makes_the_channel_report_itself_off(self) -> None: + stub = self.session() + stub.write_record() + stub.write_key() + stub.start() + outcome = self.send(stub, environ={"SESSION_KIT_CLAUDE_SOCKET": "0"}) + self.assertEqual("channel-off", outcome.how) + self.assertEqual([], stub.received) + + def test_an_empty_message_is_refused_before_any_socket_is_touched(self) -> None: + stub = self.session() + stub.write_record() + stub.write_key() + outcome = self.send(stub, " ") + self.assertEqual("bad-request", outcome.how) + + +class AuthWindowTests(unittest.TestCase): + """The two windows around the auth line, and why they are not one. + + "The peer closed the connection" is the only refusal signal the vendor + gives. It is also what a session does after ACCEPTING a message. Reported + as one thing, a delivered message reads as a refusal, the caller falls back + to the spawned sender, and the same text arrives twice -- the + never-double-deliver invariant (B13/C13). These tests are the differential: + same close, two sides of the body write, two different answers. + """ + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="claude-socket-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + + def session(self, **kwargs) -> StubSession: + stub = StubSession(self.root, **kwargs) + self.addCleanup(stub.stop) + stub.write_record() + stub.write_key() + return stub + + def send(self, stub: StubSession, **kwargs): + return claude_socket.send( + stub.session_id, + "the report is ready", + environ=runtime_env(self.root), + home=self.root, + config_dir=stub.config_dir, + timeout=kwargs.pop("timeout", 3), + **kwargs, + ) + + def test_a_close_after_the_message_is_never_called_an_auth_refusal( + self, + ) -> None: + stub = self.session(close_after_accept=True, receipt=None) + stub.start() + outcome = self.send(stub) + self.assertEqual( + ["the report is ready"], + [item["message"]["content"] for item in stub.received], + "the stub accepted the message, so the client must not call it refused", + ) + self.assertNotEqual("auth-rejected", outcome.how) + self.assertEqual("closed-after-send", outcome.how) + self.assertTrue(outcome.authenticated) + # The caller is told, in the outcome itself, that a resend would be a + # second delivery. + self.assertIn("twice", outcome.detail) + self.assertIn("message_uuid", outcome.as_dict()) + + def test_a_refused_token_closes_before_anything_is_sent(self) -> None: + stub = self.session(token="b" * 32) + stub.token = "c" * 32 + stub.start() + outcome = self.send(stub) + self.assertEqual("auth-rejected", outcome.how) + self.assertFalse(outcome.authenticated) + self.assertFalse(outcome.as_dict()["sent"]) + self.assertEqual([], stub.received) + + def test_one_sentence_covers_both_shapes_that_report_a_refused_socket( + self, + ) -> None: + """N5: one cause id, two ways to arrive at it. + + A connect that is refused outright -- the session is gone and its + socket file was left behind -- and a peer that took the token and then + went away before the body both come back as `socket-refused`, and both + are classified correctly: nothing was written either way. The sentence + a person READS was written for the first shape only, in a round whose + whole thesis is that every cause says its own true thing. + """ + sentence = claude_socket.FAILURE_DETAIL[claude_socket.SOCKET_REFUSED] + + # Shape 1: the socket file outlives the process that bound it. + elsewhere = tempfile.TemporaryDirectory(prefix="claude-socket-") + self.addCleanup(elsewhere.cleanup) + other_root = Path(elsewhere.name) + exited = StubSession(other_root) + self.addCleanup(exited.stop) + exited.write_record() + exited.write_key() + listener = socketmod.socket(socketmod.AF_UNIX, socketmod.SOCK_STREAM) + listener.bind(os.fspath(exited.socket_path)) + listener.close() + left_behind = claude_socket.send( + exited.session_id, + "the report is ready", + environ=runtime_env(other_root), + home=other_root, + config_dir=exited.config_dir, + timeout=3, + ) + self.assertEqual("socket-refused", left_behind.how) + self.assertFalse(left_behind.delivered) + self.assertFalse(left_behind.as_dict().get("sent", False)) + self.assertEqual(sentence, left_behind.detail) + + # Shape 2: the token was accepted, and then the peer stopped reading. + vanished = self.session(stop_reading_after_auth=True) + vanished.start() + outcome = self.send(vanished) + self.assertEqual("socket-refused", outcome.how) + self.assertTrue( + outcome.authenticated, "the token was accepted before the peer went" + ) + self.assertFalse(outcome.as_dict()["sent"]) + self.assertEqual([], vanished.received) + self.assertEqual(sentence, outcome.detail) + + # So the sentence may not describe only the first shape: this peer + # left no abandoned socket file, and it was answering when the send + # began. + self.assertNotIn("left behind", sentence) + self.assertNotIn("the session is gone", sentence) + self.assertIn("nothing was written", sentence) + + def test_a_late_refusal_is_not_dressed_up_as_an_authenticated_send( + self, + ) -> None: + """A session that only gets round to refusing after the probe window. + + It must not come back as sent-no-receipt with authenticated=True: the + message never reached a queue, and the caller has to be free to try the + other channel. + """ + stub = self.session( + reject_after_seconds=claude_socket.AUTH_PROBE_SECONDS + 0.6 + ) + stub.start() + outcome = self.send(stub, timeout=3) + self.assertEqual([], stub.received) + self.assertFalse(outcome.delivered) + # It is reported as the ambiguous thing it is, not as a confirmed send: + # a caller reading `auth_confirmed` sees that nothing was proven, and + # the detail tells it a resend risks a double delivery. + self.assertEqual("closed-after-send", outcome.how) + self.assertFalse(outcome.as_dict()["auth_confirmed"]) + self.assertIn("twice", outcome.detail) + + +class RenameTests(unittest.TestCase): + """Renaming through the vendor's channel instead of its transcript file. + + The kit's name push appends an `agent-name` record to a file both vendors + call internal (docs/pinned-internal-formats.md). The control frame says the + same thing on a supported interface, and it reaches the session already on + screen instead of the next one that starts. + """ + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="claude-socket-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.stub = StubSession(self.root) + self.addCleanup(self.stub.stop) + + def rename(self, name: str = " Overhaul Bench "): + return claude_socket.rename( + self.stub.session_id, + name, + environ=runtime_env(self.root), + home=self.root, + config_dir=self.stub.config_dir, + ) + + def test_a_rename_reaches_the_session_over_the_control_channel(self) -> None: + self.stub.write_record() + self.stub.write_key() + self.stub.start() + outcome = self.rename() + # The session applies the name and answers nothing, so this can never + # be "done" -- only "written to a session that took our token". + self.assertFalse(outcome.delivered) + self.assertEqual("rename-sent", outcome.how) + self.assertEqual("Overhaul Bench", outcome.as_dict()["name"]) + deadline = time.monotonic() + 5 + while not self.stub.controls and time.monotonic() < deadline: + time.sleep(0.05) + self.assertEqual(1, len(self.stub.controls), "no control frame arrived") + self.assertEqual("rename", self.stub.controls[0]["action"]) + self.assertEqual("Overhaul Bench", self.stub.controls[0]["name"]) + self.assertEqual( + self.stub.session_id, self.stub.controls[0]["session_id"] + ) + + def test_a_session_that_refuses_the_token_is_never_reported_renamed( + self, + ) -> None: + """The finding this test exists for: rename used to always succeed.""" + self.stub.write_record() + self.stub.write_key() + self.stub.token = "c" * 32 # rotated after publishing + self.stub.start() + outcome = self.rename() + self.assertFalse(outcome.delivered) + self.assertEqual("auth-rejected", outcome.how) + self.assertFalse(outcome.as_dict()["sent"]) + time.sleep(0.3) + self.assertEqual([], self.stub.controls) + + def test_an_unreachable_session_is_named_the_same_way_a_send_names_it( + self, + ) -> None: + self.stub.write_record() + outcome = claude_socket.rename( + self.stub.session_id, + "Anything", + environ=runtime_env(self.root), + home=self.root, + config_dir=self.stub.config_dir, + ) + self.assertFalse(outcome.delivered) + self.assertEqual("socket-missing", outcome.how) + + +class StaleRecordTests(unittest.TestCase): + """A crash leaves its session record behind; the live one has to win. + + Claude writes one record per PROCESS. Resume the same conversation after a + crash and two records name the same conversation -- the dead one first, by + pid order. Answering from it reports `socket-refused` for a session that is + running and reachable, which is the same class of wrong answer as the + catch-all sentence this module replaces. + """ + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="claude-socket-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + + def test_the_record_whose_socket_answers_is_the_one_used(self) -> None: + dead = StubSession(self.root, pid=100) + self.addCleanup(dead.stop) + dead.write_record() + dead.write_key() + # A socket file with nobody behind it: exactly what a crash leaves. + listener = socketmod.socket(socketmod.AF_UNIX, socketmod.SOCK_STREAM) + listener.bind(os.fspath(dead.socket_path)) + listener.close() + + live = StubSession(self.root, pid=999) + self.addCleanup(live.stop) + live.write_record() + live.write_key() + live.start() + + outcome = claude_socket.send( + SESSION, + "resumed", + environ=runtime_env(self.root), + home=self.root, + config_dir=live.config_dir, + timeout=3, + ) + self.assertEqual(999, outcome.pid, outcome.detail) + self.assertEqual(1, len(live.received)) + self.assertEqual([], dead.received) + + def test_a_record_without_a_socket_never_hides_a_live_one(self) -> None: + bare = StubSession(self.root, pid=101) + self.addCleanup(bare.stop) + bare.write_record(socket_path=None) + + live = StubSession(self.root, pid=555) + self.addCleanup(live.stop) + live.write_record() + live.write_key() + live.start() + + report = claude_socket.capability( + SESSION, environ=runtime_env(self.root), home=self.root, config_dir=live.config_dir + ) + self.assertTrue(report["available"], report) + self.assertEqual(555, report["pid"]) + + +class OutcomeCoverageTests(unittest.TestCase): + """The endings that had no test, each proven to name itself.""" + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="claude-socket-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.stub = StubSession(self.root) + self.addCleanup(self.stub.stop) + + def send(self, text: str = "hello", **kwargs): + return claude_socket.send( + self.stub.session_id, + text, + environ=runtime_env(self.root), + home=self.root, + config_dir=self.stub.config_dir, + timeout=kwargs.pop("timeout", 3), + **kwargs, + ) + + def test_a_path_that_is_not_a_socket_says_so(self) -> None: + self.stub.write_record() + self.stub.write_key() + self.stub.socket_path.write_text("not a socket", encoding="utf-8") + self.assertEqual("not-a-socket", self.send().how) + + def test_an_oversize_message_is_refused_before_the_socket(self) -> None: + self.stub.write_record() + self.stub.write_key() + self.stub.start() + outcome = self.send("x" * (claude_socket.MAX_TEXT_CHARS + 1)) + self.assertEqual("bad-request", outcome.how) + self.assertEqual([], self.stub.received) + + def test_an_expired_receipt_is_reported_as_expired(self) -> None: + stub = StubSession(self.root, pid=606, receipt="expired") + self.addCleanup(stub.stop) + stub.write_record() + stub.write_key() + stub.start() + outcome = claude_socket.send( + stub.session_id, + "hello", + environ=runtime_env(self.root), + home=self.root, + config_dir=stub.config_dir, + timeout=3, + ) + self.assertEqual("expired", outcome.how) + self.assertIn("shut down", outcome.detail) + + def test_an_infinite_timeout_is_refused_rather_than_waited_out(self) -> None: + import argparse + + with self.assertRaises(argparse.ArgumentTypeError): + claude_socket._timeout_argument("inf") + with self.assertRaises(argparse.ArgumentTypeError): + claude_socket._timeout_argument("nan") + # A library caller is bounded too, not just the command line. + self.assertEqual( + claude_socket.DEFAULT_TIMEOUT_SECONDS, + claude_socket._bounded_seconds(float("inf")), + ) + + def test_every_outcome_name_carries_its_own_sentence(self) -> None: + """One list, one count -- the report used to give two.""" + self.assertEqual(len(set(claude_socket.OUTCOMES)), len(claude_socket.OUTCOMES)) + failures = set(claude_socket.OUTCOMES) - {claude_socket.DELIVERED} + self.assertEqual( + failures, + set(claude_socket.FAILURE_DETAIL), + "an outcome exists with no sentence, or a sentence with no outcome", + ) + for name, sentence in claude_socket.FAILURE_DETAIL.items(): + self.assertGreater(len(sentence), 20, name) + + +class RecordingListener: + """A socket that is NOT the session's, and a ledger of what reached it. + + The point of every test below is a number: how many bytes this listener + was told. Anything above zero means the auth token and the message body + left this process for a destination nobody proved was ours. + """ + + def __init__(self, path: Path) -> None: + self.path = Path(path) + self.connections = 0 + self.payload = bytearray() + self.socket = socketmod.socket(socketmod.AF_UNIX, socketmod.SOCK_STREAM) + self.socket.bind(os.fspath(self.path)) + os.chmod(self.path, 0o600) + self.socket.listen(4) + self.socket.settimeout(0.1) + self.stopping = threading.Event() + self.thread = threading.Thread(target=self._serve, daemon=True) + self.thread.start() + + def _serve(self) -> None: + while not self.stopping.is_set(): + try: + connection, _ = self.socket.accept() + except (socketmod.timeout, TimeoutError): + continue + except OSError: + return + self.connections += 1 + connection.settimeout(0.2) + try: + # Held open deliberately. A stand-in that hangs up after the + # first line makes the client report auth-rejected and stop, + # which UNDER-counts the disclosure: with the connection kept, + # the body follows the token, which is what the security + # report measured (372 bytes, token and message both). + while not self.stopping.is_set(): + try: + block = connection.recv(65536) + except (socketmod.timeout, TimeoutError): + continue + if not block: + break + self.payload.extend(block) + except OSError: + pass + finally: + connection.close() + + def stop(self) -> None: + self.stopping.set() + self.thread.join(timeout=5) + try: + self.socket.close() + except OSError: + pass + + +class ForeignSocketTests(unittest.TestCase): + """S1: the destination is proved ours BEFORE the token leaves this process. + + The socket path is read out of a FILE. Until this gate existed the client + dialled whatever that file said, sent the auth token as its first line and + the whole message after it -- proven against a stand-in foreign listener, + 361 bytes including the token. Nothing checked the owner, the mode, the + directory or the runtime root. + + So each test here plants one thing a real session would never publish and + asserts the same two facts: the outcome is `foreign-socket`, and the + listener standing in for the foreign end was told ZERO bytes. The last + test is the control -- the legitimate own-session path still delivers, + because a gate that refuses everything is not a fix. + """ + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="claude-socket-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.runtime = self.root / "run" + (self.runtime / "cc-socks").mkdir(parents=True, exist_ok=True) + + def listener(self, path: Path) -> RecordingListener: + listening = RecordingListener(path) + self.addCleanup(listening.stop) + return listening + + def planted(self, socket_path: Path) -> StubSession: + """A complete, believable session record naming this socket. + + Both files, exactly as a live session publishes them -- the record AND + the key whose name is the digest of that path. A fixture missing the + key would refuse for the wrong reason (`no-auth-key`) and prove + nothing about the gate. + """ + stub = StubSession(self.root) + self.addCleanup(stub.stop) + stub.socket_path = Path(socket_path) + stub.write_record() + stub.write_key() + return stub + + def send(self, stub: StubSession, **kwargs): + return claude_socket.send( + stub.session_id, + "the report is ready", + environ=kwargs.pop("environ", runtime_env(self.root)), + home=self.root, + config_dir=stub.config_dir, + timeout=3, + ) + + def assert_refused(self, outcome, listening: RecordingListener) -> None: + self.assertEqual(claude_socket.FOREIGN_SOCKET, outcome.how, outcome.detail) + self.assertFalse(outcome.delivered) + self.assertFalse(outcome.authenticated, "a token was offered to it") + self.assertFalse(outcome.as_dict().get("sent", False)) + self.assertEqual(0, listening.connections, "the client dialled it") + self.assertEqual( + 0, len(listening.payload), f"bytes sent: {bytes(listening.payload)!r}" + ) + # Nothing of ours may be left in a directory we refused to trust, + # either -- the receipt listener binds beside the target's socket. + residue = sorted( + path.name + for path in listening.path.parent.iterdir() + if path.name.startswith("session-kit-receipt.") + ) + self.assertEqual([], residue, "a receipt socket was bound beside it") + + def test_a_socket_outside_the_runtime_directory_is_never_dialled(self) -> None: + """The proven attack: a record pointing at a shared directory.""" + shared = Path(tempfile.mkdtemp(prefix="claude-socket-foreign-")) + self.addCleanup(lambda: shutil.rmtree(shared, True)) + listening = self.listener(shared / "foreign.sock") + stub = self.planted(listening.path) + # The receipt listener binds in the TARGET's directory, so a refusal + # that still built one would have put a socket of ours in a directory + # we just refused to trust. It is never even constructed. + built: list[Path] = [] + real = claude_socket._ReceiptListener + with mock.patch.object( + claude_socket, + "_ReceiptListener", + lambda directory: built.append(directory) or real(directory), + ): + outcome = self.send(stub) + self.assertEqual([], built, "a receipt socket was built for a foreign directory") + self.assert_refused(outcome, listening) + + def test_a_socket_another_user_owns_is_never_dialled(self) -> None: + """Ownership, with this process standing in for the other user. + + A test cannot create a file owned by somebody else without being root, + so the euid moves instead: the socket below belongs to a uid that is + not the one asking, which is the only thing the check reads. + """ + listening = self.listener(self.runtime / "cc-socks" / "424242.sock") + stub = self.planted(listening.path) + with mock.patch.object(os, "geteuid", return_value=os.geteuid() + 1): + outcome = self.send(stub) + self.assert_refused(outcome, listening) + + def test_a_group_writable_socket_is_never_dialled(self) -> None: + listening = self.listener(self.runtime / "cc-socks" / "424242.sock") + os.chmod(listening.path, 0o660) + stub = self.planted(listening.path) + self.assert_refused(self.send(stub), listening) + + def test_a_world_writable_socket_is_never_dialled(self) -> None: + listening = self.listener(self.runtime / "cc-socks" / "424242.sock") + os.chmod(listening.path, 0o666) + stub = self.planted(listening.path) + self.assert_refused(self.send(stub), listening) + + def test_a_socket_in_a_world_writable_directory_is_never_dialled(self) -> None: + """Owned by us, 0600, in the runtime dir -- and still not safe. + + Anyone who can write the directory can unlink the socket and bind + their own in its place between the check and the connect, so the + directory is part of the gate, not context for it. + """ + shared = self.runtime / "shared" + shared.mkdir() + os.chmod(shared, 0o777) + listening = self.listener(shared / "424242.sock") + stub = self.planted(listening.path) + self.assert_refused(self.send(stub), listening) + + def test_a_symlinked_socket_path_is_never_dialled(self) -> None: + listening = self.listener(self.runtime / "cc-socks" / "424242.sock") + link = self.runtime / "cc-socks" / "link.sock" + link.symlink_to(listening.path) + stub = self.planted(link) + self.assert_refused(self.send(stub), listening) + + def test_a_symlinked_parent_directory_is_never_dialled(self) -> None: + """The link is one component up, and the socket itself is honest.""" + outside = Path(tempfile.mkdtemp(prefix="claude-socket-outside-")) + self.addCleanup(lambda: shutil.rmtree(outside, True)) + listening = self.listener(outside / "424242.sock") + link = self.runtime / "linked" + link.symlink_to(outside) + stub = self.planted(link / "424242.sock") + self.assert_refused(self.send(stub), listening) + + def test_the_capability_probe_refuses_the_same_target_the_same_way(self) -> None: + """One gate, both paths: the probe cannot bless what the send refuses.""" + shared = Path(tempfile.mkdtemp(prefix="claude-socket-foreign-")) + self.addCleanup(lambda: shutil.rmtree(shared, True)) + listening = self.listener(shared / "foreign.sock") + stub = self.planted(listening.path) + report = claude_socket.capability( + stub.session_id, + environ=runtime_env(self.root), + home=self.root, + config_dir=stub.config_dir, + ) + self.assertFalse(report["available"], report) + self.assertEqual(claude_socket.FOREIGN_SOCKET, report["reason"]) + self.assertFalse(report["authenticated"]) + self.assertEqual(0, listening.connections) + self.assertEqual(0, len(listening.payload)) + + def test_the_rename_channel_refuses_it_too(self) -> None: + """The third route to the wire, held to the same check.""" + shared = Path(tempfile.mkdtemp(prefix="claude-socket-foreign-")) + self.addCleanup(lambda: shutil.rmtree(shared, True)) + listening = self.listener(shared / "foreign.sock") + stub = self.planted(listening.path) + outcome = claude_socket.rename( + stub.session_id, + "Anything", + environ=runtime_env(self.root), + home=self.root, + config_dir=stub.config_dir, + ) + self.assertEqual(claude_socket.FOREIGN_SOCKET, outcome.how) + self.assertEqual(0, listening.connections) + self.assertEqual(0, len(listening.payload)) + + def test_a_planted_record_never_wins_the_stale_record_probe(self) -> None: + """Two records, one conversation: the foreign one gets no connect. + + find_target dials candidates to find out which is live. That probe is + a connect like any other, so it passes the same gate -- a record + planted with a higher pid must not be able to make the client touch a + socket it does not own. + """ + shared = Path(tempfile.mkdtemp(prefix="claude-socket-foreign-")) + self.addCleanup(lambda: shutil.rmtree(shared, True)) + listening = self.listener(shared / "foreign.sock") + planted = self.planted(listening.path) + planted.pid = 999999 + planted.write_record() + planted.write_key() + + live = StubSession(self.root, pid=424242) + self.addCleanup(live.stop) + live.write_record() + live.write_key() + live.start() + + outcome = claude_socket.send( + SESSION, + "the report is ready", + environ=runtime_env(self.root), + home=self.root, + config_dir=live.config_dir, + timeout=3, + ) + self.assertEqual(0, listening.connections, "the planted record got a connect") + self.assertEqual(0, len(listening.payload)) + self.assertEqual(424242, outcome.pid, outcome.detail) + self.assertEqual(1, len(live.received)) + + def test_the_legitimate_own_session_path_still_delivers(self) -> None: + """The control. A gate that refuses everything is not a fix. + + Owned by this euid, 0600, in `$XDG_RUNTIME_DIR/cc-socks`, reached + through no symlink -- which is what a real 2.1.229 session publishes. + """ + stub = StubSession(self.root) + self.addCleanup(stub.stop) + stub.write_record() + stub.write_key() + stub.start() + outcome = self.send(stub) + self.assertTrue(outcome.delivered, outcome.detail) + self.assertEqual( + ["the report is ready"], + [item["message"]["content"] for item in stub.received], + ) + self.assertEqual("", claude_socket._socket_problem( + stub.socket_path, environ=runtime_env(self.root) + )) + + def test_the_receipt_socket_is_private_before_the_chmod_lands(self) -> None: + """S5: bind() takes the umask, and the chmod is a syscall later. + + The chmod is disabled here on purpose, so what is asserted is the mode + the BIND produced. Under a 0000 umask the old code published a + world-writable socket for the length of that window, in whatever + directory the target named. + """ + directory = self.runtime / "cc-socks" + previous = os.umask(0o000) + self.addCleanup(os.umask, previous) + with mock.patch.object(os, "chmod"): + with claude_socket._ReceiptListener(directory) as listener: + self.assertIsNotNone(listener.path, "the listener never bound") + mode = stat.S_IMODE(os.stat(listener.path).st_mode) + self.assertEqual( + 0, mode & (stat.S_IRWXG | stat.S_IRWXO), f"bound as {mode:04o}" + ) + + +class CapabilityTests(unittest.TestCase): + """Choosing a channel before there is a message to lose.""" + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="claude-socket-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + + def capability(self, stub: StubSession, **kwargs): + return claude_socket.capability( + kwargs.pop("session_id", stub.session_id), + environ=kwargs.pop("environ", runtime_env(self.root)), + home=self.root, + config_dir=stub.config_dir, + ) + + def test_a_reachable_session_is_reported_available_and_authenticated( + self, + ) -> None: + stub = StubSession(self.root) + self.addCleanup(stub.stop) + stub.write_record() + stub.write_key() + stub.start() + report = self.capability(stub) + self.assertTrue(report["available"]) + self.assertTrue(report["authenticated"]) + self.assertEqual("", report["reason"]) + + def test_capability_touches_no_socket(self) -> None: + stub = StubSession(self.root) + self.addCleanup(stub.stop) + stub.write_record() + stub.write_key() + stub.start() + self.capability(stub) + self.assertEqual([], stub.received) + + def test_an_unreachable_session_reports_the_same_cause_a_send_would( + self, + ) -> None: + stub = StubSession(self.root) + self.addCleanup(stub.stop) + stub.write_record() + report = self.capability(stub) + self.assertFalse(report["available"]) + self.assertEqual("socket-missing", report["reason"]) + + def test_the_machine_answer_ignores_a_record_naming_a_foreign_socket( + self, + ) -> None: + """"Has this box the channel at all" is asked with no session named. + + A record pointing at a socket the gate would refuse is not a channel + this box has: answering yes sends a caller down a path every later + step refuses, which is the "available but unusable" shape the module + already avoids for a missing auth key. + """ + shared = Path(tempfile.mkdtemp(prefix="claude-socket-foreign-")) + self.addCleanup(lambda: shutil.rmtree(shared, True)) + foreign = shared / "foreign.sock" + holder = socketmod.socket(socketmod.AF_UNIX, socketmod.SOCK_STREAM) + holder.bind(os.fspath(foreign)) + holder.close() + planted = StubSession(self.root, pid=999999) + self.addCleanup(planted.stop) + planted.socket_path = foreign + planted.write_record() + + report = claude_socket.capability( + "", + environ=runtime_env(self.root), + home=self.root, + config_dir=planted.config_dir, + ) + self.assertFalse(report["available"], report) + self.assertEqual("no-socket-path", report["reason"]) + + # The same box, once a record names a socket the gate accepts. + live = StubSession(self.root, pid=424242) + self.addCleanup(live.stop) + live.write_record() + live.start() + report = claude_socket.capability( + "", + environ=runtime_env(self.root), + home=self.root, + config_dir=live.config_dir, + ) + self.assertTrue(report["available"], report) + + def test_the_kill_switch_is_visible_before_a_send_is_attempted(self) -> None: + stub = StubSession(self.root) + self.addCleanup(stub.stop) + stub.write_record() + stub.write_key() + report = self.capability(stub, environ={"SESSION_KIT_CLAUDE_SOCKET": "0"}) + self.assertFalse(report["enabled"]) + self.assertEqual("channel-off", report["reason"]) + + +class KeyNamingTests(unittest.TestCase): + def test_the_key_file_name_is_the_digest_of_the_resolved_socket_path( + self, + ) -> None: + """Pinned against the 2.1.229 key-file naming rule. + + The name is ``<pid>.<sha256 of the resolved socket path>.key``, shaped + exactly like a live record; the digest below is that same + construction, recomputed here so a change to the rule fails rather + than silently looking in an empty directory. + """ + target = claude_socket.Target( + session_id=SESSION, + pid=987654, + socket_path=Path("/run/user/1000/cc-socks/987654.sock"), + record_path=Path("/dev/null"), + ) + expected = hashlib.sha256( + b"/run/user/1000/cc-socks/987654.sock" + ).hexdigest() + name = claude_socket.auth_key_path(target, config_dir=Path("/tmp")).name + self.assertEqual(f"987654.{expected}.key", name) + self.assertEqual( + "d76bebf7ede4eb05d82e19f2b3e1d58ee5275f71a15d81eb2cff8f311c62b338", + expected, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_help.py b/tests/test_cli_help.py new file mode 100644 index 0000000..1603428 --- /dev/null +++ b/tests/test_cli_help.py @@ -0,0 +1,676 @@ +"""The command help, its topics, the shipped completion, and their drift guards. + +Help is the one surface that has to work when nothing else does, so these tests +run every entry point's `--help` on a machine with no shpool on PATH and no +Session Kit state to read. The rest are drift guards: a verb the dispatcher +accepts but the help never mentions, a topic advertised but not implemented, a +machine verb that leaked into the human list, an exit status no document +explains, or a completion that offers a verb `sp help` does not. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + +from tests.support import REPO, without_shpool + + +SP = REPO / "bin" / "sp" +SESSION_KIT = REPO / "bin" / "session-kit" +STATUS = REPO / "bin" / "shpool_status" +REAPER = REPO / "bin" / "shpool_reaper" +WATCHDOG = REPO / "bin" / "session_kit_watchdog" +RESUME = REPO / "bin" / "codex_resume_here" +LAUNCHER = REPO / "deploy" / "session-kit-launcher" +COMPLETION = REPO / "lib" / "sh" / "sp_completion.bash" +INSTALL_MODULE = REPO / "lib" / "sh" / "session_kit_install.sh" +UNINSTALL_MODULE = REPO / "lib" / "sh" / "session_kit_uninstall.sh" + +# Every entry point a person can type, with the arguments that must be a usage +# error. `sp help a b` is sp's: it needs no shpool, so the check stays honest on +# a machine that has none. +ENTRY_POINTS = ( + (SP, ("help", "a", "b")), + (SESSION_KIT, ("no-such-verb",)), + (STATUS, ("--bogus",)), + (REAPER, ("--bogus",)), + (WATCHDOG, ("--bogus",)), + (RESUME, ("--bogus",)), +) + +TOPIC_LINE = re.compile(r"^ sp help (?P<topic>[a-z-]+) ") +# ` go|resume|attach)` and friends: one dispatcher arm, all of its spellings. +CASE_ARM = re.compile(r"^ ([a-z][a-z0-9|-]*)\)$", re.MULTILINE) +EXIT_STATUS = re.compile(r"\bexit ([0-9]+)\b") + + +class HelpFixture: + """An isolated HOME and a PATH with no shpool anywhere on it.""" + + def __init__(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".cli-help-", dir=REPO) + self.base = Path(self.temp.name) + self.home = self.base / "home" + self.home.mkdir() + self.bin = self.base / "bin" + self.bin.mkdir() + + def close(self) -> None: + self.temp.cleanup() + + def env(self, **extra: str) -> dict[str, str]: + # `without_shpool` is load-bearing, not decoration. The suite-wide + # sandbox guard (tests/sandbox_guard.py) normally puts a refusing + # `shpool` on every child's PATH, and that would make + # test_help_is_reachable_without_shpool pass for the wrong reason: + # shpool would be present. This asks the guard for a machine with no + # session manager at all, which it delivers by removing its own stub + # AND any real binary, so the absence these tests need is real. + return without_shpool( + { + "HOME": os.fspath(self.home), + "PATH": f"{self.bin}:/usr/bin:/bin", + "XDG_STATE_HOME": os.fspath(self.base / "state"), + "XDG_CONFIG_HOME": os.fspath(self.base / "config"), + "XDG_DATA_HOME": os.fspath(self.base / "data"), + "PYTHONDONTWRITEBYTECODE": "1", + "SESSION_KIT_NONINTERACTIVE": "1", + **extra, + } + ) + + def run(self, *argv: object, **extra: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [os.fspath(part) for part in argv], + env=self.env(**extra), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + +class CommandHelpTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.fixture = HelpFixture() + cls.overview = cls.fixture.run(SP, "help") + cls.dispatcher = SP.read_text(encoding="utf-8") + + @classmethod + def tearDownClass(cls) -> None: + cls.fixture.close() + + def topics(self) -> list[str]: + return [ + match.group("topic") + for line in self.overview.stdout.splitlines() + if (match := TOPIC_LINE.match(line)) + ] + + def dispatcher_arms(self) -> list[tuple[str, ...]]: + """Every verb the second case block accepts, as spelling groups.""" + body = self.dispatcher.split("sk_require_shpool || exit 1", 1)[1] + return [ + tuple(match.split("|")) + for match in CASE_ARM.findall(body) + ] + + def test_shpool_is_absent_from_the_fixture_path(self) -> None: + """Otherwise the reachability test below proves nothing.""" + found = subprocess.run( + ["bash", "-c", "command -v shpool || true"], + env=self.fixture.env(), + text=True, + stdout=subprocess.PIPE, + check=False, + ) + self.assertEqual("", found.stdout.strip()) + + def test_help_is_reachable_without_shpool(self) -> None: + for flag in ("help", "-h", "--help"): + with self.subTest(flag=flag): + completed = self.fixture.run(SP, flag) + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertEqual("", completed.stderr) + self.assertNotIn("shpool executable unavailable", completed.stdout) + self.assertIn("Sessions", completed.stdout) + self.assertEqual(self.overview.stdout, completed.stdout) + + def test_the_overview_groups_the_commands_and_describes_each_one(self) -> None: + text = self.overview.stdout + for group in ( + "Sessions", + "Names and colors", + "Delegated work", + "Accounts", + "History and recovery", + "Topics", + ): + self.assertIn(f"\n{group}\n", text, f"{group} is not a help group") + described = [ + line + for line in text.splitlines() + if re.match(r"^ sp \S.* {2,}\S", line) + ] + self.assertGreaterEqual(len(described), 20, text) + + def test_every_advertised_topic_answers(self) -> None: + topics = self.topics() + self.assertGreaterEqual(len(topics), 9, topics) + for topic in topics: + with self.subTest(topic=topic): + completed = self.fixture.run(SP, "help", topic) + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertEqual("", completed.stderr) + self.assertGreater(len(completed.stdout.splitlines()), 3) + + def test_the_unavailable_topic_names_automatic_safe_cleanup(self) -> None: + """Quarantine documents the automatic path and its proof boundary.""" + completed = self.fixture.run(SP, "help", "unavailable") + self.assertEqual(0, completed.returncode, completed.stderr) + text = completed.stdout + self.assertIn("no session number", text) + self.assertIn("next cleanup pass", text) + self.assertIn("attach-and-exit", text) + self.assertIn("hard timeout", text) + self.assertIn("any surviving session process", text) + + def test_an_unknown_topic_names_the_topics_it_has(self) -> None: + completed = self.fixture.run(SP, "help", "no-such-topic") + self.assertEqual(2, completed.returncode) + self.assertEqual("", completed.stdout) + for topic in self.topics(): + self.assertIn(topic, completed.stderr) + + def test_every_human_verb_the_dispatcher_accepts_is_documented(self) -> None: + """The coverage gap this help replaced: verbs sp took and never named.""" + missing = [] + for spellings in self.dispatcher_arms(): + if spellings[0].startswith("picker-") or spellings[0] == "restore-exact": + continue + if not any( + f" sp {spelling}" in self.overview.stdout for spelling in spellings + ): + missing.append("|".join(spellings)) + self.assertEqual( + [], + missing, + "sp accepts these verbs and `sp help` never mentions them: " + f"{missing}. Add them to the overview in bin/sp.", + ) + + def test_machine_verbs_are_hidden_from_humans_and_written_down_once(self) -> None: + machine = [ + spellings[0] + for spellings in self.dispatcher_arms() + if spellings[0].startswith("picker-") or spellings[0] == "restore-exact" + ] + self.assertGreater(len(machine), 5, machine) + topic = self.fixture.run(SP, "help", "machine") + self.assertEqual(0, topic.returncode, topic.stderr) + for verb in machine: + with self.subTest(verb=verb): + self.assertNotIn(f"sp {verb}", self.overview.stdout) + self.assertIn(f"sp {verb}", topic.stdout) + + def test_sp_list_rejects_arguments_instead_of_silently_drawing_text(self) -> None: + completed = self.fixture.run(SP, "list", "--json") + self.assertEqual(2, completed.returncode) + self.assertEqual("", completed.stdout) + self.assertTrue( + completed.stderr.startswith("session-kit: sp list takes nothing else\n"), + completed.stderr, + ) + + def test_bare_sp_is_the_same_listing_as_sp_list(self) -> None: + fake_shpool = self.fixture.bin / "shpool" + fake_shpool.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") + fake_shpool.chmod(0o755) + fake_core = self.fixture.base / "inventory-core" + fake_core.write_text( + "#!/usr/bin/env python3\n" + "import sys\n" + "assert sys.argv[1:] == ['render']\n" + "print('the unchanged session list')\n", + encoding="utf-8", + ) + fake_core.chmod(0o755) + env = { + "SESSION_KIT_SHPOOL_CMD": os.fspath(fake_shpool), + "SESSION_KIT_INVENTORY_CORE": os.fspath(fake_core), + } + try: + bare = self.fixture.run(SP, **env) + explicit = self.fixture.run(SP, "list", **env) + self.assertEqual(0, bare.returncode, bare.stderr) + self.assertEqual(explicit.stdout, bare.stdout) + self.assertEqual("the unchanged session list\n", bare.stdout) + finally: + fake_shpool.unlink(missing_ok=True) + fake_core.unlink(missing_ok=True) + + def test_usage_lists_every_new_picker_key_and_search_field(self) -> None: + usage = (REPO / "docs" / "usage.md").read_text(encoding="utf-8") + self.assertIn( + "model number move an idle Claude or Codex conversation", + usage, + ) + self.assertIn( + "x show or hide machine sessions", + usage, + ) + self.assertIn( + "/text filter names, providers, accounts, models, and projects", + usage, + ) + + +class UniformHelpTests(unittest.TestCase): + """One contract for every entry point: help on stdout, mistakes on stderr.""" + + @classmethod + def setUpClass(cls) -> None: + cls.fixture = HelpFixture() + + @classmethod + def tearDownClass(cls) -> None: + cls.fixture.close() + + def test_h_and_long_help_print_to_stdout_and_succeed(self) -> None: + for command, _ in ENTRY_POINTS: + for flag in ("-h", "--help"): + with self.subTest(command=command.name, flag=flag): + completed = self.fixture.run(command, flag) + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertEqual("", completed.stderr) + self.assertIn("usage", completed.stdout.lower()) + + def test_an_argument_mistake_prints_usage_to_stderr_and_exits_two(self) -> None: + for command, bad in ENTRY_POINTS: + with self.subTest(command=command.name): + completed = self.fixture.run(command, *bad) + self.assertEqual(2, completed.returncode, completed.stdout) + self.assertEqual("", completed.stdout) + self.assertNotEqual("", completed.stderr.strip()) + + def test_doctor_does_not_advertise_an_unimplemented_authority_mode(self) -> None: + dead_authority_mode = "doctor --" + "authority" + help_result = self.fixture.run(SESSION_KIT, "--help") + self.assertEqual(0, help_result.returncode, help_result.stderr) + self.assertNotIn(dead_authority_mode, help_result.stdout) + + report = self.fixture.run(SESSION_KIT, "doctor", "--json") + self.assertNotEqual(0, report.returncode) + acceptance = next( + row + for row in json.loads(report.stdout)["checks"] + if row["name"] == "acceptance" + ) + self.assertEqual("warn", acceptance["status"]) + self.assertIn("release-acceptance.json", acceptance["detail"]) + self.assertNotIn(dead_authority_mode, acceptance["detail"]) + + +class StatusModeDocumentationTests(unittest.TestCase): + """shpool_status modes that look like reads and are not, said out loud.""" + + @classmethod + def setUpClass(cls) -> None: + cls.fixture = HelpFixture() + cls.usage = cls.fixture.run(STATUS, "--help").stdout + cls.source = STATUS.read_text(encoding="utf-8") + + @classmethod + def tearDownClass(cls) -> None: + cls.fixture.close() + + def section(self, heading: str) -> str: + start = self.usage.index(heading) + rest = self.usage[start + len(heading) :] + end = rest.find("\n\n") + return rest if end < 0 else rest[:end] + + def modes(self, heading: str) -> list[str]: + """The mode each entry under a heading is about, not every word in it.""" + return [ + match.group(1) + for line in self.section(heading).splitlines() + if (match := re.match(r"^ (--[a-z-]+|\(no mode\))", line)) + ] + + def test_the_read_only_modes_are_the_ones_that_never_build_state(self) -> None: + self.assertEqual( + [ + "(no mode)", + "--rows", + "--strict-json", + "--guard-json", + "--render-file", + "--lookup-file", + "--recovery-pending-list", + ], + self.modes("Read-only modes"), + "the dashboards, replay paths, and no-write probes belong under read-only", + ) + self.assertEqual( + ["--waiting-count", "--detail", "--json", "--lookup"], + self.modes("refresh the cached inventory as they run:"), + ) + + def test_naming_runs_on_refreshes_but_never_on_a_listing(self) -> None: + core = self.fixture.base / "status-core" + log = self.fixture.base / "status-core.log" + core.write_text( + "#!/usr/bin/env python3\n" + "import json,os,pathlib,sys\n" + "with pathlib.Path(os.environ['STATUS_CORE_LOG']).open('a') as out:\n" + " out.write(json.dumps(sys.argv[1:])+'\\n')\n" + "print('{}')\n", + encoding="utf-8", + ) + core.chmod(0o755) + env = { + "SESSION_KIT_INVENTORY_CORE": os.fspath(core), + "STATUS_CORE_LOG": os.fspath(log), + } + + expected = { + (): [["render"]], + ("--rows",): [["render", "--rows"]], + ("--json",): [ + ["automatic-title", "claude-pending"], + ["automatic-title", "codex-pending"], + ["snapshot"], + ], + ("--lookup", "7"): [ + ["automatic-title", "claude-pending"], + ["automatic-title", "codex-pending"], + ["lookup", "7"], + ], + } + for argv, calls in expected.items(): + with self.subTest(argv=argv): + log.unlink(missing_ok=True) + completed = self.fixture.run(STATUS, *argv, **env) + self.assertEqual(0, completed.returncode, completed.stderr) + observed = [ + json.loads(line) + for line in log.read_text(encoding="utf-8").splitlines() + ] + self.assertEqual(calls, observed) + + def test_the_mutating_recovery_modes_are_named_as_such(self) -> None: + mutating = self.section("change recovery state") + self.assertIn("--recovery-pending-ack", mutating) + # Writing down what a screen printed is a write, and a person reading + # this page is entitled to find it where the writes are listed. + self.assertIn("--recovery-remember-printed", mutating) + + +class ExitCodeDocumentationTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.fixture = HelpFixture() + cls.topic = cls.fixture.run(SP, "help", "exit-codes").stdout + + @classmethod + def tearDownClass(cls) -> None: + cls.fixture.close() + + def documented(self) -> set[str]: + return { + match.group(1) + for line in self.topic.splitlines() + if (match := re.match(r"^ ([0-9]+) {2,}\S", line)) + } + + def test_every_status_a_command_returns_is_explained(self) -> None: + sources = [*sorted((REPO / "bin").iterdir()), LAUNCHER] + sources += sorted((REPO / "lib" / "sh").glob("*.sh")) + used: set[str] = set() + for path in sources: + if not path.is_file(): + continue + used.update(EXIT_STATUS.findall(path.read_text(encoding="utf-8"))) + # The picker's two statuses are constants, not literals in an exit line. + used.update({"74", "75"}) + used.discard("0") + undocumented = sorted(used - self.documented(), key=int) + self.assertEqual( + [], + undocumented, + f"these statuses are returned and never explained: {undocumented}. " + "Document them in the exit-codes topic in bin/sp.", + ) + + def test_the_picker_statuses_are_the_ones_the_code_uses(self) -> None: + source = SP.read_text(encoding="utf-8") + self.assertIn("PICKER_REFUSED_STATUS=74", source) + self.assertIn("PICKER_ATTACH_FAILED_STATUS=75", source) + + +class CompletionTests(unittest.TestCase): + """The shipped completion: what it offers, and what it must never do.""" + + @classmethod + def setUpClass(cls) -> None: + cls.fixture = HelpFixture() + cls.overview = cls.fixture.run(SP, "help").stdout + + @classmethod + def tearDownClass(cls) -> None: + cls.fixture.close() + + def complete(self, function: str, *words: str, **extra: str) -> list[str]: + script = ( + f'source "{COMPLETION}"\n' + "COMP_WORDS=(" + " ".join(f'"{word}"' for word in words) + ")\n" + "COMP_CWORD=$(( ${#COMP_WORDS[@]} - 1 ))\n" + "COMPREPLY=()\n" + f"{function}\n" + 'printf "%s\\n" "${COMPREPLY[@]:-}"\n' + ) + completed = subprocess.run( + ["bash", "-c", script], + env=self.fixture.env(**extra), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertEqual("", completed.stderr) + return [line for line in completed.stdout.split("\n") if line] + + def test_it_registers_every_command_it_ships_for(self) -> None: + listed = subprocess.run( + ["bash", "-c", f'source "{COMPLETION}"; complete -p'], + env=self.fixture.env(), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(0, listed.returncode, listed.stderr) + for command in ( + "sp", + "session-kit", + "shpool_status", + "shpool_reaper", + "codex_resume_here", + ): + self.assertRegex(listed.stdout, rf"complete .*\b{re.escape(command)}$|" + rf"complete .*\b{re.escape(command)}\n") + + def test_the_first_word_offers_human_verbs_and_no_machine_verb(self) -> None: + offered = self.complete("_sp", "sp", "") + self.assertIn("list", offered) + self.assertIn("help", offered) + for verb in offered: + self.assertFalse(verb.startswith("picker-"), verb) + self.assertNotEqual("restore-exact", verb) + for verb in offered: + self.assertIn( + f" sp {verb}", + self.overview, + f"completion offers `sp {verb}` and `sp help` never names it", + ) + + def test_the_topics_it_offers_are_the_topics_help_has(self) -> None: + advertised = [ + match.group("topic") + for line in self.overview.splitlines() + if (match := TOPIC_LINE.match(line)) + ] + self.assertEqual(sorted(advertised), sorted(self.complete("_sp", "sp", "help", ""))) + + def test_it_completes_providers_colors_and_project_aliases(self) -> None: + projects = self.fixture.base / "projects.tsv" + projects.write_text( + "# alias\tprovider\tcwd\nnotes\tclaude\t/srv/notes\n", + encoding="utf-8", + ) + offered = self.complete( + "_sp", "sp", "new", "", SESSION_KIT_PROJECTS_FILE=os.fspath(projects) + ) + self.assertEqual(["claude", "codex", "shell", "notes"], offered) + self.assertIn("magenta", self.complete("_sp", "sp", "color", "2", "")) + self.assertEqual( + ["enable", "disable", "status"], + self.complete("_session_kit", "session-kit", "services", ""), + ) + + def test_a_tab_press_never_runs_a_session_command(self) -> None: + """A completion that shelled out would build state from inside a prompt.""" + marker = self.fixture.base / "ran" + for name in ("sp", "shpool_status", "session-kit", "shpool_reaper"): + trap = self.fixture.bin / name + trap.write_text( + f'#!/bin/sh\necho "$0" >> "{marker}"\nexit 1\n', encoding="utf-8" + ) + trap.chmod(0o755) + for function, words in ( + ("_sp", ("sp", "")), + ("_sp", ("sp", "go", "")), + ("_sp", ("sp", "close", "")), + ("_sp", ("sp", "new", "")), + ("_session_kit", ("session-kit", "")), + ("_shpool_status", ("shpool_status", "--")), + ): + self.complete(function, *words) + self.assertFalse( + marker.exists(), + f"completion executed: {marker.read_text() if marker.exists() else ''}", + ) + + def test_it_parses_under_the_bash_that_ships_with_macos(self) -> None: + """A completion file that fails to parse breaks the prompt that sourced it.""" + code = "\n".join( + line + for line in COMPLETION.read_text(encoding="utf-8").splitlines() + if not line.lstrip().startswith("#") + ) + for bash_four_only in ("mapfile", "readarray", "declare -A", ",,}"): + self.assertNotIn(bash_four_only, code, bash_four_only) + + +class CompletionInstallTests(unittest.TestCase): + """The copies the installer writes, and the ones the uninstaller may remove.""" + + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".cli-help-install-", dir=REPO) + self.base = Path(self.temp.name) + self.release = self.base / "root" / "releases" / ("a" * 40) + (self.release / "lib" / "sh").mkdir(parents=True) + (self.release / "lib" / "sh" / "sp_completion.bash").write_text( + COMPLETION.read_text(encoding="utf-8"), encoding="utf-8" + ) + self.completions = self.base / "completions" + + def tearDown(self) -> None: + self.temp.cleanup() + + def shell(self, call: str) -> subprocess.CompletedProcess[str]: + script = ( + "set -u\n" + f'install_root="{self.base / "root"}"\n' + f'completion_dir="{self.completions}"\n' + "completion_names=(sp session-kit shpool_status)\n" + "completion_marker='# session-kit managed bash completion v1'\n" + f'source "{INSTALL_MODULE}"\n' + f'source "{UNINSTALL_MODULE}"\n' + f"{call}\n" + ) + completed = subprocess.run( + ["bash", "-c", script], + cwd=REPO, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(0, completed.returncode, completed.stderr) + return completed + + def test_install_writes_one_copy_per_command_name(self) -> None: + self.shell(f'install_completions "{"a" * 40}"') + for name in ("sp", "session-kit", "shpool_status"): + copy = self.completions / name + self.assertTrue(copy.is_file(), name) + self.assertIn("session-kit managed bash completion", copy.read_text()) + self.assertEqual(0o644, copy.stat().st_mode & 0o777) + + def test_a_file_that_is_not_ours_is_never_replaced_or_removed(self) -> None: + self.completions.mkdir() + foreign = self.completions / "sp" + foreign.write_text("# somebody else's completion\n", encoding="utf-8") + self.shell(f'install_completions "{"a" * 40}"') + self.assertEqual("# somebody else's completion\n", foreign.read_text()) + self.shell("remove_completions") + self.assertTrue(foreign.is_file()) + self.assertFalse((self.completions / "session-kit").exists()) + + def test_uninstall_removes_the_copies_it_wrote(self) -> None: + self.shell(f'install_completions "{"a" * 40}"') + self.shell("remove_completions") + for name in ("sp", "session-kit", "shpool_status"): + self.assertFalse((self.completions / name).exists(), name) + + def test_none_turns_the_whole_thing_off(self) -> None: + script = ( + "set -u\n" + f'install_root="{self.base / "root"}"\n' + 'completion_dir=none\n' + "completion_names=(sp)\n" + "completion_marker='# session-kit managed bash completion v1'\n" + f'source "{INSTALL_MODULE}"\n' + f'install_completions "{"a" * 40}"\n' + ) + completed = subprocess.run( + ["bash", "-c", script], + cwd=REPO, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertFalse((self.base / "none").exists()) + + def test_the_source_check_knows_the_completion_file_has_to_be_there(self) -> None: + manager = SESSION_KIT.read_text(encoding="utf-8") + block = manager.split("shell_modules=(", 1)[1].split(")", 1)[0] + self.assertIn("lib/sh/sp_completion.bash", block) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_close_intent.py b/tests/test_close_intent.py new file mode 100644 index 0000000..8cf4b1c --- /dev/null +++ b/tests/test_close_intent.py @@ -0,0 +1,817 @@ +"""The crash queue offers what was lost, never what somebody closed. + +`recovery-pending.json` is the queue of conversations whose sessions vanished +before anyone claimed them. It could not tell a crash from a decision: a +session closed on purpose came back as recovery work, so the offer that means +"something broke" also meant "you finished something", and neither meaning +survives being both. + +Intent is recorded as a tombstone keyed `provider:uuid` by every deliberate +close, a clean provider exit, the recovery menu's `c`, `bye`, and `k` from +the picker. The queue honours it at the one place every consumer passes +through, so an entry queued before the close disappears from the offers, the +header count, and the ack together. Tombstones expire on the same clock as the +terminal number the close freed: one policy, not two. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import stat +import sys +import tempfile +import unittest +from unittest import mock +import contextlib +import io + +from tests.support import REPO, run +from tests.test_inventory import inventory_core, uuid_for +from tests.test_recovery_pending import pending_document + +sys.path.insert(0, os.fspath(REPO / "lib")) + +from sessionkit_inventory import closed_sessions, lifecycle # noqa: E402 + + +DAY_MS = 86_400_000 +CORE = REPO / "lib" / "session_inventory.py" + + +class CloseIntentTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".intent-", dir=REPO) + self.state = Path(self.temp.name) / "state" + self.state.mkdir() + self.state.chmod(0o700) + self.data = Path(self.temp.name) / "data" + self.data.mkdir(mode=0o700) + self.environ = mock.patch.dict( + os.environ, {"SESSION_KIT_DATA_DIR": str(self.data)} + ) + self.environ.start() + self.pending_path = self.state / "recovery-pending.json" + self.pending_path.write_text( + json.dumps(pending_document(), sort_keys=True), encoding="utf-8" + ) + self.config = { + "schema_version": 1, + "state_dir": self.state, + "aliases": {}, + "max_proc_nodes": 8192, + "max_proc_depth": 32, + } + + def tearDown(self) -> None: + self.environ.stop() + self.temp.cleanup() + + def closed_row(self, provider: str, uuid: str) -> None: + closed_sessions.record_close( + provider=provider, + uuid=uuid, + title="Closed on purpose", + environ=os.environ, + now_unix_ms=1, + ) + + def offered(self) -> set[str]: + return { + entry["uuid"] + for entry in inventory_core.list_pending(self.config)["entries"] + } + + def test_two_sessions_closing_at_once_keep_both_tombstones(self) -> None: + """One document, every session on the machine, and no lock. + + `record_close_intent` is a read/modify/replace of the single file all + closes tombstone into, so two sessions ending in the same second is + ordinary rather than exotic -- and it got MORE ordinary the moment a + crashed provider began closing its own session automatically. + Unlocked, both writers read the same predecessor and each replaces it: + the last one wins, the other's intent is gone, and a conversation + somebody deliberately closed comes back offered as unclaimed lost + work. Atomic replacement stops a torn file; it never stopped a lost + update (found in review, 2026-08-15). + + Real processes, the real verb, no mocked writer. Every child waits on + one barrier file so their read/modify/replace windows genuinely + overlap. + """ + import subprocess + import time + + barrier = Path(self.temp.name) / "go" + uuids = [uuid_for(index) for index in range(1, 9)] + program = f""" +import sys, time +sys.path.insert(0, {os.fspath(REPO / "lib")!r}) +from pathlib import Path +from sessionkit_inventory import lifecycle +state = Path({os.fspath(self.state)!r}) +barrier = Path({os.fspath(barrier)!r}) +while not barrier.exists(): + time.sleep(0.005) +lifecycle.record_close_intent(state, provider="codex", uuid=sys.argv[1]) +""" + workers = [ + subprocess.Popen( + [sys.executable, "-c", program, uuid], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + for uuid in uuids + ] + time.sleep(0.4) + barrier.write_text("go", encoding="utf-8") + for worker in workers: + _, errors = worker.communicate(timeout=60) + self.assertEqual(0, worker.returncode, errors) + + recorded = lifecycle.load_close_intents(self.state)["closed"] + missing = [uuid for uuid in uuids if f"codex:{uuid}" not in recorded] + self.assertEqual( + [], missing, f"lost {len(missing)} of {len(uuids)} simultaneous closes" + ) + + def test_a_crash_is_offered_when_nobody_closed_it(self) -> None: + # The filter must not swallow the queue it protects: with no + # tombstones at all, every queued conversation is still work. + self.assertEqual( + {uuid_for(1), uuid_for(2), uuid_for(3)}, self.offered() + ) + + def test_a_conversation_closed_on_purpose_is_never_offered(self) -> None: + self.closed_row("claude", uuid_for(1)) + lifecycle.record_close_intent( + self.state, provider="claude", uuid=uuid_for(1) + ) + self.assertEqual({uuid_for(2), uuid_for(3)}, self.offered()) + + def test_a_tombstone_drops_an_entry_that_was_already_queued(self) -> None: + # The entry existed first; the close came second. Order does not + # decide whether it was a crash. + self.assertIn(uuid_for(2), self.offered()) + self.closed_row("codex", uuid_for(2)) + lifecycle.record_close_intent( + self.state, provider="codex", uuid=uuid_for(2) + ) + self.assertNotIn(uuid_for(2), self.offered()) + + def test_a_tombstone_reaches_a_queued_generation_too(self) -> None: + # Not just the primary generation: the older queued ones as well. + self.closed_row("codex", uuid_for(3)) + lifecycle.record_close_intent( + self.state, provider="codex", uuid=uuid_for(3) + ) + self.assertEqual({uuid_for(1), uuid_for(2)}, self.offered()) + + def test_a_tombstone_without_its_closed_row_does_not_hide_recovery(self) -> None: + lifecycle.record_close_intent( + self.state, provider="claude", uuid=uuid_for(1) + ) + + payload = inventory_core.list_pending(self.config) + + self.assertIn(uuid_for(1), {row["uuid"] for row in payload["entries"]}) + diagnostic = " ".join(payload.get("diagnostics", ())) + self.assertIn(f"claude:{uuid_for(1)}", diagnostic) + self.assertIn("row is missing", diagnostic) + self.assertIn("recovery will keep offering", diagnostic) + + def test_an_invalid_ledger_cannot_make_a_tombstone_authoritative(self) -> None: + closed_sessions.ledger_path(os.environ).mkdir() + lifecycle.record_close_intent( + self.state, provider="claude", uuid=uuid_for(1) + ) + + payload = inventory_core.list_pending(self.config) + + self.assertIn(uuid_for(1), {row["uuid"] for row in payload["entries"]}) + diagnostic = " ".join(payload.get("diagnostics", ())) + self.assertIn(f"claude:{uuid_for(1)}", diagnostic) + self.assertIn("could not be completely validated", diagnostic) + + def test_a_tombstone_expires_with_the_number_quarantine(self) -> None: + # One clock. A conversation closed longer ago than the quarantine on + # the number it freed is not remembered as closed either. + now = 10 * lifecycle.CLOSE_INTENT_RETENTION_SECONDS * 1000 + stale = now - int(lifecycle.CLOSE_INTENT_RETENTION_SECONDS * 1000) - 1 + fresh = now - int(lifecycle.CLOSE_INTENT_RETENTION_SECONDS * 1000) + DAY_MS + lifecycle.record_close_intent( + self.state, provider="claude", uuid=uuid_for(1), now_unix_ms=stale + ) + lifecycle.record_close_intent( + self.state, provider="codex", uuid=uuid_for(2), now_unix_ms=fresh + ) + live = lifecycle.load_close_intents(self.state, now_unix_ms=now) + self.assertEqual([f"codex:{uuid_for(2)}"], sorted(live["closed"])) + self.assertFalse( + lifecycle.closed_on_purpose( + self.state, + provider="claude", + uuid=uuid_for(1), + now_unix_ms=now, + ) + ) + self.assertTrue( + lifecycle.closed_on_purpose( + self.state, + provider="codex", + uuid=uuid_for(2), + now_unix_ms=now, + ) + ) + + def test_recording_an_intent_expires_the_stale_ones_it_finds(self) -> None: + now = 10 * lifecycle.CLOSE_INTENT_RETENTION_SECONDS * 1000 + stale = now - int(lifecycle.CLOSE_INTENT_RETENTION_SECONDS * 1000) - 1 + lifecycle.record_close_intent( + self.state, provider="claude", uuid=uuid_for(1), now_unix_ms=stale + ) + document = lifecycle.record_close_intent( + self.state, provider="codex", uuid=uuid_for(2), now_unix_ms=now + ) + self.assertEqual([f"codex:{uuid_for(2)}"], sorted(document["closed"])) + + def test_the_store_is_private_and_refuses_a_key_it_cannot_trust(self) -> None: + lifecycle.record_close_intent( + self.state, provider="claude", uuid=uuid_for(1) + ) + path = lifecycle.close_intent_path(self.state) + self.assertEqual(0o600, stat.S_IMODE(path.stat().st_mode)) + for provider, uuid in ( + ("shell", uuid_for(1)), + ("claude", "not-a-uuid"), + ("claude", ""), + ): + with self.subTest(provider=provider, uuid=uuid): + with self.assertRaises(Exception): + lifecycle.record_close_intent( + self.state, provider=provider, uuid=uuid + ) + self.assertFalse( + lifecycle.closed_on_purpose( + self.state, provider=provider, uuid=uuid + ) + ) + + def test_an_unreadable_store_hides_no_recovery_work(self) -> None: + # Failing open here shows MORE offers, never fewer: a tombstone store + # that cannot be read must not be able to swallow a real crash. + lifecycle.close_intent_path(self.state).write_text( + "{not json", encoding="utf-8" + ) + lifecycle.close_intent_path(self.state).chmod(0o600) + self.assertEqual( + {uuid_for(1), uuid_for(2), uuid_for(3)}, self.offered() + ) + + +class SharedCloseVerbTests(unittest.TestCase): + """`close-intent record`: the verb BOTH live close paths run after a kill. + + `sp close` (lib/sh/sp_commands.sh) and the picker's `k` (lib/sh/sp_picker.sh) + call this one command once the session is already dead. It writes the two + records that decide where the conversation can be found afterwards: the + closed-sessions ledger row, which is what a person restores from, and the + tombstone, which tells the crash queue not to offer it as lost work. + + Written tombstone-first with the ledger result discarded, a ledger that + could not be written produced the one state with no way back -- absent from + Closed sessions, suppressed in recovery -- and printed `recorded: true` at a + caller that had already killed the session (found in review, 2026-08-15). + + Real subprocesses running the shipped file, with HOME, state, data and + config pinned into the fixture and a fake `shpool` first on PATH, so nothing + here can read or touch a real session manager. + """ + + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".shared-close-", dir=REPO) + self.base = Path(self.temp.name) + self.home = self.base / "home" + self.state = self.base / "state" + self.data = self.home / ".local" / "share" / "session-kit" + self.bin = self.home / ".local" / "bin" + for path in (self.home, self.state): + path.mkdir(mode=0o700, parents=True) + self.data.mkdir(mode=0o700, parents=True) + self.bin.mkdir(mode=0o700, parents=True) + self.config = self.base / "inventory.json" + self.config.write_text( + json.dumps( + {"schema_version": 1, "state_dir": str(self.state), "aliases": {}} + ) + + "\n", + encoding="utf-8", + ) + self.config.chmod(0o600) + self.boot = self.base / "boot-id" + self.boot.write_text("boot-current\n", encoding="utf-8") + # A fake session manager. The collector behind these verbs runs + # `shpool list --json`; without this stub on PATH it reaches the real + # daemon on its default socket. + stub = self.bin / "shpool" + stub.write_text( + "#!/usr/bin/env bash\n" + "if [[ $1 == list ]]; then printf '{\"sessions\": []}\\n'; exit 0; fi\n" + "exit 1\n", + encoding="utf-8", + ) + stub.chmod(0o755) + self.uuid = uuid_for(2) + self.provider_transcript(self.uuid) + self.queue_as_lost(self.uuid) + + def tearDown(self) -> None: + self.temp.cleanup() + + def provider_transcript(self, uuid: str) -> Path: + """The conversation's own file: what a restore reads, and what the + Closed-sessions reader checks before it will show the row at all.""" + day = self.home / ".codex" / "sessions" / "2026" / "08" / "15" + day.mkdir(parents=True, exist_ok=True) + path = day / f"rollout-2026-08-15T03-00-00-{uuid}.jsonl" + path.write_text( + json.dumps({"type": "session_meta", "payload": {"id": uuid}}) + "\n", + encoding="utf-8", + ) + return path + + def queue_as_lost(self, uuid: str) -> None: + """The crash queue already holds this conversation. + + That is the state a close acts on, and the only remaining way back to + the conversation when the ledger cannot be written. + """ + document = { + "schema_version": 1, + "generated_at": "2026-08-15T00:00:00Z", + "source_boot_id": "boot-primary", + "source_daemon_generation": {"pid": 10, "process_start_ticks": 100}, + "detected_boot_id": "boot-current", + "detected_daemon_generation": {"pid": 20, "process_start_ticks": 200}, + "sessions": { + "old-codex": { + "provider": "codex", + "uuid": uuid, + "title": "Codex pending", + "started_at_unix_ms": 1_700_000_000_000, + "cwd": "/srv/project", + "argv": ["codex", "resume", uuid], + "command": f"codex resume {uuid}", + } + }, + "queued_generations": [], + } + (self.state / "recovery-pending.json").write_text( + json.dumps(document, sort_keys=True), encoding="utf-8" + ) + + def ledger_cannot_be_written(self) -> Path: + """A directory where the ledger file belongs: every append raises + EISDIR, the shape of a full disk or a lost mount without needing + either.""" + path = self.data / "closed-sessions.jsonl" + path.mkdir() + return path + + def environment(self) -> dict[str, str]: + return { + "HOME": str(self.home), + "PATH": f"{self.bin}:{os.environ['PATH']}", + "SESSION_KIT_CONFIG": str(self.config), + "SESSION_KIT_STATE_DIR": str(self.state), + "SESSION_KIT_BOOT_ID_FILE": str(self.boot), + "XDG_STATE_HOME": str(self.home / ".local" / "state"), + "XDG_DATA_HOME": str(self.home / ".local" / "share"), + "SESSION_KIT_DATA_DIR": str(self.data), + "SHPOOL_JOURNAL": "disabled", + "PYTHONDONTWRITEBYTECODE": "1", + } + + def record_close(self, uuid: str | None = None): + """The shipped command, exactly as both close paths spell it.""" + return run( + [CORE, "close-intent", "record", "codex", uuid or self.uuid], + env=self.environment(), + check=False, + ) + + def offered(self) -> set[str]: + """What the crash queue would offer, excluding closed-ledger rows.""" + completed = run( + [CORE, "recovery-pending", "list", "--without-closed"], + env=self.environment(), + check=False, + ) + self.assertEqual(0, completed.returncode, completed.stderr) + return { + entry["uuid"] + for entry in json.loads(completed.stdout)["entries"] + } + + def closed_rows(self) -> list: + """What Closed sessions shows a person, via the real reader.""" + completed = run( + [CORE, "closed-sessions", "list"], + env=self.environment(), + check=False, + ) + self.assertEqual(0, completed.returncode, completed.stderr) + return json.loads(completed.stdout)["closed"] + + def tombstones(self) -> set[str]: + return set(lifecycle.load_close_intents(self.state)["closed"]) + + def test_a_close_that_lands_is_findable_and_no_longer_offered(self) -> None: + """The control: nothing about the ordinary close changes.""" + completed = self.record_close() + self.assertEqual(0, completed.returncode, completed.stderr) + payload = json.loads(completed.stdout) + self.assertTrue(payload["recorded"]) + self.assertTrue(payload["tombstoned"]) + self.assertEqual( + [self.uuid], [row["uuid"] for row in self.closed_rows()] + ) + self.assertEqual({f"codex:{self.uuid}"}, self.tombstones()) + self.assertEqual(set(), self.offered()) + + def test_replacement_after_final_agreement_is_reoffered_and_diagnosed( + self, + ) -> None: + """The success claim heals if its ledger row later becomes absent.""" + ledger = closed_sessions.ledger_path(self.environment()) + ledger.touch(mode=0o600) + before = ledger.read_bytes() + real_prove = closed_sessions._prove_descriptor_authoritative + replaced = False + + def prove_then_replace(descriptor: int, path: Path, *, action: str) -> None: + nonlocal replaced + real_prove(descriptor, path, action=action) + if action == "appended" and not replaced: + replacement = path.with_name("after-agreement-replacement.jsonl") + replacement.write_bytes(before) + replacement.chmod(0o600) + os.replace(replacement, path) + replaced = True + + output = io.StringIO() + with mock.patch.dict(os.environ, self.environment(), clear=True), mock.patch.object( + closed_sessions, + "_prove_descriptor_authoritative", + prove_then_replace, + ), contextlib.redirect_stdout(output): + code = inventory_core.main( + ["close-intent", "record", "codex", self.uuid] + ) + payload = json.loads(output.getvalue()) + self.assertEqual(0, code) + self.assertTrue(payload["recorded"]) + self.assertTrue(payload["tombstoned"]) + self.assertTrue(replaced) + self.assertNotIn(self.uuid.encode(), ledger.read_bytes()) + + completed = run( + [CORE, "recovery-pending", "list", "--without-closed"], + env=self.environment(), + check=False, + ) + self.assertEqual(0, completed.returncode, completed.stderr) + recovery = json.loads(completed.stdout) + self.assertIn(self.uuid, {row["uuid"] for row in recovery["entries"]}) + diagnostic = " ".join(recovery.get("diagnostics", ())) + self.assertIn(f"codex:{self.uuid}", diagnostic) + self.assertIn("row is missing", diagnostic) + + def test_a_ledger_that_cannot_be_written_closes_nothing_and_says_so( + self, + ) -> None: + """The defect. A deterministic ledger failure must not end with the + conversation on NEITHER surface, and must not report success. + + Both callers branch on the exit status, so a zero here is what let a + killed session's conversation disappear silently. + """ + self.ledger_cannot_be_written() + before = self.offered() + self.assertEqual({self.uuid}, before) + + completed = self.record_close() + + self.assertNotEqual( + 0, + completed.returncode, + "a failed record must not report success to a caller that has" + f" already killed the session: {completed.stdout}", + ) + payload = json.loads(completed.stdout) + self.assertFalse(payload["recorded"]) + self.assertIn("ledger", payload["reason"]) + # No row anywhere means no tombstone: the internal crash candidate is + # retained. The fused `sp recover` command still refuses its whole + # list while this deliberately broken ledger cannot be validated. + self.assertEqual(set(), self.tombstones()) + listed = run( + [CORE, "closed-sessions", "list"], + env=self.environment(), + check=False, + ) + self.assertNotEqual(0, listed.returncode) + self.assertIn("regular file", listed.stderr) + self.assertEqual({self.uuid}, self.offered()) + shipped = run( + [REPO / "bin" / "sp", "recover"], + env=self.environment(), + check=False, + ) + self.assertNotEqual(0, shipped.returncode) + self.assertEqual("", shipped.stdout) + self.assertIn("closed-conversations list could not be read", shipped.stderr) + + def test_a_shell_row_that_cannot_be_written_is_not_reported_as_filed( + self, + ) -> None: + """The sibling verb, on the same close path, for a plain managed shell. + + `sp close` and picker `k` both spell it `>/dev/null 2>&1 && return 0`, + so its exit status is the whole answer they get. It printed + `recorded: false` and exited 0, which made every warning either shell + could raise unreachable and printed `Closed ...` over a session that + reached no list at all. + """ + self.ledger_cannot_be_written() + completed = run( + [CORE, "closed-sessions", "record", "shell", "--session", "s1"], + env=self.environment(), + check=False, + ) + self.assertNotEqual( + 0, + completed.returncode, + f"a lost shell row reported as filed: {completed.stdout}", + ) + self.assertFalse(json.loads(completed.stdout)["recorded"]) + listed = run( + [CORE, "closed-sessions", "list"], + env=self.environment(), + check=False, + ) + self.assertNotEqual(0, listed.returncode) + self.assertIn("regular file", listed.stderr) + + def test_a_shell_row_that_lands_reports_success(self) -> None: + """The control for the status above: an ordinary shell close is zero.""" + completed = run( + [CORE, "closed-sessions", "record", "shell", "--session", "s1"], + env=self.environment(), + check=False, + ) + self.assertEqual(0, completed.returncode, completed.stderr) + # A filed row answers with the row itself; only a failure carries + # `recorded`, which is what the exit status is derived from. + self.assertNotIn("recorded", json.loads(completed.stdout)) + self.assertEqual(["s1"], [row["shpool_id"] for row in self.closed_rows()]) + + def test_a_uuid_the_tombstone_would_reject_records_nothing_at_all( + self, + ) -> None: + """The ledger now goes first, so an argument only the tombstone store + validates must be refused BEFORE either record is written -- otherwise + the reorder trades one bad state for a closed-list row naming a + conversation that can never be tombstoned or found.""" + completed = self.record_close("not-a-uuid") + self.assertNotEqual(0, completed.returncode, completed.stdout) + self.assertEqual([], self.closed_rows()) + self.assertEqual(set(), self.tombstones()) + self.assertEqual({self.uuid}, self.offered()) + + +class LostConversationQueueTests(unittest.TestCase): + """One session dying on its own is a loss the queue can now see. + + Before this, only a daemon restart could queue anything: a window killed + mid-conversation was invisible until the next generation change, by which + time the row it came from had aged off every screen. + """ + + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".lost-", dir=REPO) + self.state = Path(self.temp.name) / "state" + self.state.mkdir() + self.state.chmod(0o700) + self.data = Path(self.temp.name) / "data" + self.data.mkdir(mode=0o700) + self.environ = mock.patch.dict( + os.environ, {"SESSION_KIT_DATA_DIR": str(self.data)} + ) + self.environ.start() + self.config = { + "schema_version": 1, + "state_dir": self.state, + "aliases": {}, + "max_proc_nodes": 8192, + "max_proc_depth": 32, + } + self.paths = {"pending": self.state / "recovery-pending.json"} + + def tearDown(self) -> None: + self.environ.stop() + self.temp.cleanup() + + def closed_row(self) -> None: + closed_sessions.record_close( + provider="claude", + uuid=uuid_for(1), + title="Closed on purpose", + environ=os.environ, + now_unix_ms=1, + ) + + def previous(self, **overrides) -> dict: + row = { + "shpool_id_raw": "main7", + "provider": "claude", + "title": "Parser work", + "started_at_unix_ms": 1_700_000_000_000, + "terminal_number": 7, + "account_alias": "work", + "identity": {"uuid": uuid_for(1), "confidence": "exact"}, + "recovery": { + "available": True, + "provider": "claude", + "uuid": uuid_for(1), + "cwd": "/srv/project", + "argv": ["claude", "--resume", uuid_for(1)], + "command": f"claude --resume {uuid_for(1)}", + }, + } + row.update(overrides) + return {"sessions": [row], "daemon_generation": {"pid": 10, "process_start_ticks": 100}} + + def current(self, *sessions: dict) -> dict: + return { + "generated_at": "2026-08-11T00:00:00Z", + "daemon_generation": {"pid": 10, "process_start_ticks": 100}, + "sessions": list(sessions), + } + + def enqueue(self, current: dict, previous: dict | None) -> list[str]: + return inventory_core.enqueue_lost_conversations( + self.paths, + current, + previous, + boot_id="boot-current", + config=self.config, + ) + + def offered(self) -> set[str]: + return { + entry["uuid"] + for entry in inventory_core.list_pending(self.config)["entries"] + } + + def test_a_session_that_vanished_with_its_provider_is_queued(self) -> None: + queued = self.enqueue(self.current(), self.previous()) + self.assertEqual([f"claude:{uuid_for(1)}"], queued) + self.assertEqual({uuid_for(1)}, self.offered()) + entry = next( + item + for item in inventory_core.list_pending(self.config)["entries"] + ) + self.assertTrue(entry["actionable"]) + self.assertEqual("/srv/project", entry["cwd"]) + stored = json.loads(self.paths["pending"].read_text(encoding="utf-8")) + record = stored["sessions"]["main7"] + self.assertEqual(7, record["terminal_number"]) + self.assertEqual("work", record["account_alias"]) + self.assertIsInstance(record["crashed_at_unix_ms"], int) + + def test_a_closed_conversation_is_retained_but_not_offered(self) -> None: + self.closed_row() + lifecycle.record_close_intent( + self.state, provider="claude", uuid=uuid_for(1) + ) + self.assertEqual( + [f"claude:{uuid_for(1)}"], + self.enqueue(self.current(), self.previous()), + ) + self.assertTrue(self.paths["pending"].exists()) + self.assertNotIn(uuid_for(1), self.offered()) + + def test_a_tombstone_without_its_row_is_queued_and_diagnosed(self) -> None: + lifecycle.record_close_intent( + self.state, provider="claude", uuid=uuid_for(1) + ) + errors = io.StringIO() + + with contextlib.redirect_stderr(errors): + queued = self.enqueue(self.current(), self.previous()) + + self.assertEqual([f"claude:{uuid_for(1)}"], queued) + self.assertIn(uuid_for(1), self.offered()) + self.assertIn(f"claude:{uuid_for(1)}", errors.getvalue()) + self.assertIn("row is missing", errors.getvalue()) + + def test_a_session_that_is_still_there_is_not_a_loss(self) -> None: + previous = self.previous() + self.assertEqual([], self.enqueue(self.current(*previous["sessions"]), previous)) + self.assertFalse(self.paths["pending"].exists()) + + def test_the_same_conversation_living_elsewhere_is_not_a_loss(self) -> None: + # Moved, restored, or reopened under a new session ID: the + # conversation is on screen, so it is not recovery work. + moved = dict(self.previous()["sessions"][0], shpool_id_raw="main9") + self.assertEqual([], self.enqueue(self.current(moved), self.previous())) + + def crashed_shell(self, **overrides) -> dict: + """What a crash leaves behind: an idle shell holding the conversation. + + This is the row the reaper's auto-close eventually takes. + """ + return self.previous( + provider="shell", + exited_provider="claude", + identity=None, + exited_identity={ + "uuid": uuid_for(1), + "provenance": "last exact live inventory for this shell generation", + "confidence": "historical-exact", + }, + **overrides, + ) + + def test_the_auto_close_of_a_crashed_shell_queues_its_conversation(self) -> None: + # An automatic reap is not intent (operator ruling, 2026-08-11): the + # terminal was safe to close, but nobody said the conversation was + # finished, so it becomes recovery work rather than disappearing. + queued = self.enqueue(self.current(), self.crashed_shell()) + self.assertEqual([f"claude:{uuid_for(1)}"], queued) + self.assertEqual({uuid_for(1)}, self.offered()) + + def test_the_auto_close_of_a_closed_conversation_retains_hidden_evidence( + self, + ) -> None: + # A person's k, c, bye, or clean /exit left a tombstone; the reap that + # follows is bookkeeping. Its raw evidence remains self-healing while + # the agreed closed row keeps it off the public recovery list. + self.closed_row() + lifecycle.record_close_intent( + self.state, provider="claude", uuid=uuid_for(1) + ) + self.assertEqual( + [f"claude:{uuid_for(1)}"], + self.enqueue(self.current(), self.crashed_shell()), + ) + self.assertTrue(self.paths["pending"].exists()) + self.assertNotIn(uuid_for(1), self.offered()) + + def test_an_idle_shell_with_a_guessed_identity_is_never_queued(self) -> None: + previous = self.crashed_shell() + previous["sessions"][0]["exited_identity"]["confidence"] = "heuristic" + self.assertEqual([], self.enqueue(self.current(), previous)) + + def test_a_guess_at_identity_is_never_queued(self) -> None: + previous = self.previous( + identity={"uuid": uuid_for(1), "confidence": "heuristic"} + ) + self.assertEqual([], self.enqueue(self.current(), previous)) + + def test_a_second_pass_keeps_the_first_record_of_the_loss(self) -> None: + first = self.enqueue(self.current(), self.previous()) + self.assertEqual(1, len(first)) + stamped = json.loads(self.paths["pending"].read_text(encoding="utf-8")) + crashed_at = stamped["sessions"]["main7"]["crashed_at_unix_ms"] + self.enqueue(self.current(), self.previous()) + again = json.loads(self.paths["pending"].read_text(encoding="utf-8")) + self.assertEqual(crashed_at, again["sessions"]["main7"]["crashed_at_unix_ms"]) + self.assertEqual({uuid_for(1)}, self.offered()) + + def test_an_existing_queue_from_another_generation_is_preserved(self) -> None: + self.paths["pending"].write_text( + json.dumps(pending_document(), sort_keys=True), encoding="utf-8" + ) + self.enqueue(self.current(), self.previous()) + self.assertEqual( + {uuid_for(1), uuid_for(2), uuid_for(3)}, self.offered() + ) + + def test_nothing_is_queued_without_an_exact_daemon_generation(self) -> None: + # An entry nobody could acknowledge later is worse than no entry. + current = self.current() + current.pop("daemon_generation") + self.assertEqual([], self.enqueue(current, self.previous())) + self.assertFalse(self.paths["pending"].exists()) + + def test_a_first_snapshot_with_no_previous_state_queues_nothing(self) -> None: + self.assertEqual([], self.enqueue(self.current(), None)) + self.assertFalse(self.paths["pending"].exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_closed_sessions.py b/tests/test_closed_sessions.py new file mode 100644 index 0000000..1c9336a --- /dev/null +++ b/tests/test_closed_sessions.py @@ -0,0 +1,1401 @@ +"""Closing a session ends the terminal, never the conversation. + +Before this ledger, a deliberate close left a tombstone that expired in seven +days and appeared on no screen at all: the recovery feed listed crashes only. +So the one thing a person is told they can do, close a session and pick the +conversation up later, was the one thing the kit could not do. + +Every deliberate close now appends one durable record, and `sp recover` lists +those next to the crashes, newest first, until the conversation is restored or +its transcript is gone. A plain shell has no conversation to reopen and says so +rather than offering a restore that cannot work. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import types +import unittest +from unittest import mock + +from tests.support import REPO, run +from tests.test_commands import CommandFixture + +sys.path.insert(0, os.fspath(REPO / "lib")) + +from sessionkit_inventory import closed_sessions, lifecycle # noqa: E402 + +SP = REPO / "bin" / "sp" +CORE = REPO / "lib" / "session_inventory.py" +ONE = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" +TWO = "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff" + + +def isolated_environment(base: Path, data: Path, state: Path) -> dict[str, str]: + """Every ledger fixture is estate-proof, even when it grows a subprocess.""" + home = base / "home" + binaries = base / "bin" + for path in (home, binaries, state, data): + path.mkdir(mode=0o700, parents=True, exist_ok=True) + shpool = binaries / "shpool" + shpool.write_text( + "#!/usr/bin/env bash\n" + "printf 'closed-sessions fixture refuses shpool: %s\\n' \"$*\" >&2\n" + "exit 70\n", + encoding="utf-8", + ) + shpool.chmod(0o700) + return { + **os.environ, + "PATH": f"{binaries}:/usr/local/bin:/usr/bin:/bin", + "HOME": str(home), + "SESSION_KIT_STATE_DIR": str(state), + "SESSION_KIT_DATA_DIR": str(data), + "XDG_STATE_HOME": str(base / "xdg-state"), + "XDG_DATA_HOME": str(base / "xdg-data"), + "SESSION_KIT_CONFIG": str(base / "session-kit.toml"), + "SESSION_KIT_SHPOOL_CMD": str(shpool), + "SESSION_KIT_NONINTERACTIVE": "1", + } + + +class LedgerTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".closed-", dir=REPO) + self.base = Path(self.temp.name) + self.data = self.base / "data" + self.state = self.base / "state" + self.environ = isolated_environment(self.base, self.data, self.state) + + def tearDown(self) -> None: + self.temp.cleanup() + + def record(self, **fields: object) -> dict: + return closed_sessions.record_close(environ=self.environ, **fields) # type: ignore[arg-type] + + def listed(self, **kwargs: object) -> list[dict]: + return closed_sessions.load_closed(environ=self.environ, **kwargs) # type: ignore[arg-type] + + def test_a_close_is_kept_and_read_back(self) -> None: + self.record( + provider="claude", + uuid=ONE, + title="Orphaned Record", + cwd="/srv/project", + now_unix_ms=1_000, + ) + rows = self.listed() + self.assertEqual(1, len(rows)) + self.assertEqual("Orphaned Record", rows[0]["title"]) + self.assertEqual("/srv/project", rows[0]["cwd"]) + self.assertTrue(rows[0]["restorable"]) + self.assertEqual( + 0o600, + os.stat(closed_sessions.ledger_path(self.environ)).st_mode & 0o777, + ) + + def test_the_newest_close_of_a_conversation_is_the_one_listed(self) -> None: + self.record(provider="claude", uuid=ONE, title="First", now_unix_ms=1_000) + self.record(provider="claude", uuid=ONE, title="Second", now_unix_ms=2_000) + self.record(provider="codex", uuid=TWO, title="Other", now_unix_ms=1_500) + rows = self.listed() + self.assertEqual(["Second", "Other"], [row["title"] for row in rows]) + + def test_a_conversation_the_machine_cannot_read_is_history_only(self) -> None: + """It is not offered as a restore, and it is not hidden either. + + Dropping the row left the reader with no account of a session they + remembers closing, and the sentence written for this case could never + print, because nothing could reach it. + """ + self.record(provider="claude", uuid=ONE, title="Gone", now_unix_ms=1_000) + self.record(provider="claude", uuid=TWO, title="Here", now_unix_ms=2_000) + rows = self.listed(still_readable=lambda provider, uuid: uuid == TWO) + self.assertEqual(["Here", "Gone"], [row["title"] for row in rows]) + self.assertEqual([True, False], [row["restorable"] for row in rows]) + + def test_a_shell_close_is_listed_as_history_only(self) -> None: + self.record( + provider="shell", shpool_id="s1", title="A shell", now_unix_ms=1_000 + ) + rows = self.listed(still_readable=lambda provider, uuid: False) + self.assertEqual(["A shell"], [row["title"] for row in rows]) + self.assertFalse(rows[0]["restorable"]) + + def test_a_restored_conversation_leaves_the_list(self) -> None: + self.record(provider="claude", uuid=ONE, now_unix_ms=1_000) + self.record(provider="codex", uuid=TWO, now_unix_ms=2_000) + self.assertEqual(1, closed_sessions.forget("claude", ONE, environ=self.environ)) + self.assertEqual([TWO], [row["uuid"] for row in self.listed()]) + self.assertEqual(0, closed_sessions.forget("claude", ONE, environ=self.environ)) + + def test_a_rewrite_past_the_old_bound_preserves_unrelated_rows(self) -> None: + self.record(provider="codex", uuid=TWO, title="Oldest", now_unix_ms=1) + path = closed_sessions.ledger_path(self.environ) + with path.open("ab") as handle: + row = json.dumps( + { + "provider": "shell", + "uuid": "", + "title": "f" * 120, + "cwd": "", + "closed_at_unix_ms": 2, + "origin": "human", + "shpool_id": "filler", + "account_alias": "", + }, + sort_keys=True, + ).encode("utf-8") + b"\n" + while handle.tell() <= closed_sessions.MAX_LEDGER_BYTES: + handle.write(row) + handle.write( + (json.dumps({ + "provider": "claude", + "uuid": ONE, + "title": "Target", + "cwd": "", + "closed_at_unix_ms": 3, + "origin": "human", + "shpool_id": "", + "account_alias": "", + }, sort_keys=True) + "\n").encode("utf-8") + ) + self.assertEqual( + 1, closed_sessions.forget("claude", ONE, environ=self.environ) + ) + self.assertIn(TWO.encode(), path.read_bytes()) + self.assertNotIn(ONE.encode(), path.read_bytes()) + + def test_listing_reads_the_valid_prefix_beyond_the_rewrite_bound(self) -> None: + self.record(provider="codex", uuid=TWO, title="Oldest", now_unix_ms=1) + path = closed_sessions.ledger_path(self.environ) + filler = ( + json.dumps( + { + "provider": "shell", + "uuid": "", + "title": "Later shell history", + "cwd": "", + "closed_at_unix_ms": 2, + "origin": "human", + "shpool_id": "filler", + "account_alias": "", + }, + sort_keys=True, + ).encode("utf-8") + + b"\n" + ) + with path.open("ab") as handle: + while handle.tell() <= closed_sessions.MAX_LEDGER_BYTES + 2048: + handle.write(filler) + + rows = self.listed() + self.assertIn(TWO, {row["uuid"] for row in rows}) + + def test_default_listing_has_no_row_count_that_hides_older_history(self) -> None: + path = closed_sessions.ledger_path(self.environ) + with path.open("wb") as handle: + for number in range(501): + handle.write( + ( + json.dumps( + { + "provider": "shell", + "uuid": "", + "title": f"Shell {number}", + "cwd": "", + "closed_at_unix_ms": number + 1, + "origin": "human", + "shpool_id": f"shell-{number}", + "account_alias": "", + }, + sort_keys=True, + ) + + "\n" + ).encode("utf-8") + ) + path.chmod(0o600) + + self.assertEqual(501, len(self.listed())) + + def test_a_pathological_listing_size_fails_loudly_without_a_partial_list( + self, + ) -> None: + path = closed_sessions.ledger_path(self.environ) + path.touch(mode=0o600) + with path.open("r+b") as handle: + handle.truncate(closed_sessions.MAX_LIST_LEDGER_BYTES + 1) + + with self.assertRaisesRegex( + Exception, "complete-list safety limit.*no partial list was returned" + ): + self.listed() + + def test_a_close_cannot_append_past_the_listing_ceiling(self) -> None: + self.record(provider="claude", uuid=ONE, now_unix_ms=1) + path = closed_sessions.ledger_path(self.environ) + before = path.read_bytes() + # The existing ledger is accepted, but any normal production row is + # larger than the one remaining byte. The success decision must use + # the post-append size while the append lock is still held. + with mock.patch.object( + closed_sessions, "MAX_LIST_LEDGER_BYTES", len(before) + 1 + ): + with self.assertRaisesRegex( + Exception, + r"would grow.*listing ceiling.*sp recover --allow-large-ledger", + ): + self.record(provider="codex", uuid=TWO, now_unix_ms=2) + self.assertEqual(before, path.read_bytes()) + + def test_success_requires_post_append_validation_and_rolls_back_damage( + self, + ) -> None: + self.record(provider="claude", uuid=ONE, now_unix_ms=1) + path = closed_sessions.ledger_path(self.environ) + before = path.read_bytes() + real_write = closed_sessions.os.write + injected = False + + def corrupting_write(descriptor: int, payload: object) -> int: + nonlocal injected + raw = bytes(payload) + written = real_write(descriptor, raw) + if not injected and TWO.encode() in raw: + injected = True + real_write(descriptor, b"{not-json}\n") + return written + + with mock.patch.object(closed_sessions.os, "write", corrupting_write): + with self.assertRaisesRegex(Exception, "malformed row"): + self.record(provider="codex", uuid=TWO, now_unix_ms=2) + self.assertTrue(injected) + self.assertEqual(before, path.read_bytes()) + + def test_success_refuses_when_the_appended_inode_is_replaced_at_its_path( + self, + ) -> None: + self.record(provider="claude", uuid=ONE, now_unix_ms=1) + path = closed_sessions.ledger_path(self.environ) + before = path.read_bytes() + original_inode = path.stat().st_ino + real_write_all = closed_sessions._write_all + replaced = False + + def replace_after_append( + descriptor: int, payload: bytes, action: str + ) -> None: + nonlocal replaced + real_write_all(descriptor, payload, action) + if not replaced and TWO.encode() in payload: + replacement = path.with_name("valid-replacement.jsonl") + replacement.write_bytes(before) + replacement.chmod(0o600) + os.replace(replacement, path) + replaced = True + + with mock.patch.object(closed_sessions, "_write_all", replace_after_append): + with self.assertRaisesRegex( + Exception, "no longer names the file that was appended" + ): + self.record(provider="codex", uuid=TWO, now_unix_ms=2) + self.assertTrue(replaced) + self.assertNotEqual(original_inode, path.stat().st_ino) + self.assertEqual(before, path.read_bytes()) + self.assertNotIn(TWO, {row["uuid"] for row in self.listed()}) + + def test_post_append_validation_reads_the_appended_inode_before_authority( + self, + ) -> None: + self.record(provider="claude", uuid=ONE, now_unix_ms=1) + path = closed_sessions.ledger_path(self.environ) + before = path.read_bytes() + real_write_all = closed_sessions._write_all + injected = False + + def corrupt_then_replace( + descriptor: int, payload: bytes, action: str + ) -> None: + nonlocal injected + real_write_all(descriptor, payload, action) + if not injected and TWO.encode() in payload: + real_write_all(descriptor, b"{not-json}\n", "injecting corruption") + replacement = path.with_name("valid-replacement.jsonl") + replacement.write_bytes(before) + replacement.chmod(0o600) + os.replace(replacement, path) + injected = True + + with mock.patch.object(closed_sessions, "_write_all", corrupt_then_replace): + with self.assertRaisesRegex(Exception, "malformed row"): + self.record(provider="codex", uuid=TWO, now_unix_ms=2) + self.assertTrue(injected) + self.assertEqual(before, path.read_bytes()) + + def test_the_reader_never_requests_file_sized_buffers(self) -> None: + path = closed_sessions.ledger_path(self.environ) + row = ( + json.dumps( + { + "provider": "shell", + "uuid": "", + "title": "bounded", + "cwd": "", + "closed_at_unix_ms": 1, + "origin": "human", + "shpool_id": "one", + "account_alias": "", + }, + sort_keys=True, + ).encode("utf-8") + + b"\n" + ) + with path.open("wb") as handle: + while handle.tell() < 256 * 1024: + handle.write(row) + path.chmod(0o600) + requests: list[int] = [] + real_read = closed_sessions.os.read + + def measured_read(descriptor: int, count: int) -> bytes: + requests.append(count) + return real_read(descriptor, count) + + with mock.patch.object(closed_sessions.os, "read", measured_read): + self.listed() + self.assertTrue(requests) + self.assertLessEqual(max(requests), 64 * 1024) + + def test_a_giant_single_row_marks_the_whole_ledger_damaged(self) -> None: + path = closed_sessions.ledger_path(self.environ) + path.write_text( + json.dumps( + { + "provider": "shell", + "uuid": "", + "title": "x" * (128 * 1024), + "cwd": "", + "closed_at_unix_ms": 1, + "origin": "human", + "shpool_id": "giant", + "account_alias": "", + } + ) + + "\n", + encoding="utf-8", + ) + path.chmod(0o600) + with self.assertRaisesRegex(Exception, "row 1 exceeds"): + self.listed() + + def test_a_malformed_ledger_is_not_an_empty_read_or_a_rewrite_source(self) -> None: + self.record(provider="claude", uuid=ONE, now_unix_ms=1) + path = closed_sessions.ledger_path(self.environ) + with path.open("ab") as handle: + handle.write(b"{not-json}\n") + before = path.read_bytes() + with self.assertRaisesRegex(Exception, "malformed row"): + self.listed() + with self.assertRaisesRegex(Exception, "malformed row"): + closed_sessions.forget("claude", ONE, environ=self.environ) + self.assertEqual(before, path.read_bytes()) + + def test_restore_rewrite_streams_past_the_old_four_mib_limit(self) -> None: + self.record(provider="codex", uuid=TWO, title="preserved", now_unix_ms=1) + path = closed_sessions.ledger_path(self.environ) + preserved = path.read_bytes() + filler = ( + json.dumps( + { + "provider": "shell", + "uuid": "", + "title": "filler", + "cwd": "", + "closed_at_unix_ms": 2, + "origin": "human", + "shpool_id": "filler", + "account_alias": "", + }, + sort_keys=True, + ).encode("utf-8") + + b"\n" + ) + with path.open("ab") as handle: + while handle.tell() <= closed_sessions.MAX_LEDGER_BYTES + 2048: + handle.write(filler) + handle.write( + ( + json.dumps( + { + "provider": "claude", + "uuid": ONE, + "title": "target", + "cwd": "", + "closed_at_unix_ms": 3, + "origin": "human", + "shpool_id": "", + "account_alias": "", + }, + sort_keys=True, + ) + + "\n" + ).encode("utf-8") + ) + self.assertEqual( + 1, closed_sessions.forget("claude", ONE, environ=self.environ) + ) + self.assertTrue(path.read_bytes().startswith(preserved)) + self.assertIn(TWO.encode(), path.read_bytes()) + self.assertNotIn(ONE.encode(), path.read_bytes()) + + def test_restore_refuses_when_its_published_inode_is_replaced(self) -> None: + self.record(provider="claude", uuid=ONE, now_unix_ms=1) + self.record(provider="codex", uuid=TWO, now_unix_ms=2) + path = closed_sessions.ledger_path(self.environ) + before = path.read_bytes() + real_replace = closed_sessions.os.replace + interposed = False + + def replace_then_interpose(source: object, destination: object) -> None: + nonlocal interposed + real_replace(source, destination) + if Path(destination) == path and not interposed: + replacement = path.with_name("restore-interposed.jsonl") + replacement.write_bytes(before) + replacement.chmod(0o600) + real_replace(replacement, path) + interposed = True + + with mock.patch.object( + closed_sessions.os, "replace", replace_then_interpose + ): + with self.assertRaisesRegex( + Exception, "no longer names the file that was published" + ): + closed_sessions.forget("claude", ONE, environ=self.environ) + self.assertTrue(interposed) + self.assertEqual(before, path.read_bytes()) + + def test_restore_validates_the_rewrite_through_its_written_descriptor( + self, + ) -> None: + self.record(provider="claude", uuid=ONE, now_unix_ms=1) + self.record(provider="codex", uuid=TWO, now_unix_ms=2) + path = closed_sessions.ledger_path(self.environ) + before = path.read_bytes() + real_write_all = closed_sessions._write_all + injected = False + + def corrupt_rewrite(descriptor: int, payload: bytes, action: str) -> None: + nonlocal injected + real_write_all(descriptor, payload, action) + if action == "rewriting closed sessions" and not injected: + real_write_all(descriptor, b"{not-json}\n", "injecting corruption") + injected = True + + with mock.patch.object(closed_sessions, "_write_all", corrupt_rewrite): + with self.assertRaisesRegex(Exception, "malformed row"): + closed_sessions.forget("claude", ONE, environ=self.environ) + self.assertTrue(injected) + self.assertEqual(before, path.read_bytes()) + + def test_a_restore_rewrite_serializes_with_a_new_close(self) -> None: + self.record(provider="claude", uuid=ONE, now_unix_ms=1) + reached_rewrite = threading.Event() + allow_rewrite = threading.Event() + real_tempfile = closed_sessions.tempfile + real_mkstemp = real_tempfile.mkstemp + + def held_mkstemp(*args, **kwargs): + reached_rewrite.set() + self.assertTrue(allow_rewrite.wait(timeout=5)) + return real_mkstemp(*args, **kwargs) + + closed_sessions.tempfile = types.SimpleNamespace(mkstemp=held_mkstemp) + outcomes: dict[str, object] = {} + + def restore() -> None: + outcomes["forgotten"] = closed_sessions.forget( + "claude", ONE, environ=self.environ + ) + + def close() -> None: + outcomes["closed"] = self.record( + provider="codex", uuid=TWO, now_unix_ms=2 + ) + + restore_thread = threading.Thread(target=restore) + close_thread = threading.Thread(target=close) + try: + restore_thread.start() + self.assertTrue(reached_rewrite.wait(timeout=5)) + close_thread.start() + close_thread.join(timeout=0.1) + self.assertTrue(close_thread.is_alive(), "close bypassed the ledger transaction") + allow_rewrite.set() + restore_thread.join(timeout=5) + close_thread.join(timeout=5) + finally: + allow_rewrite.set() + closed_sessions.tempfile = real_tempfile + self.assertFalse(restore_thread.is_alive()) + self.assertFalse(close_thread.is_alive()) + self.assertEqual(1, outcomes["forgotten"]) + self.assertEqual([TWO], [row["uuid"] for row in self.listed()]) + + def test_two_legal_short_write_appenders_cannot_interleave(self) -> None: + real_write = closed_sessions.os.write + condition = threading.Condition() + started: set[str] = set() + turn = "writer-a" + serialized = False + failures: list[BaseException] = [] + + def short_write(descriptor: int, payload: object) -> int: + nonlocal turn, serialized + name = threading.current_thread().name + other = "writer-b" if name == "writer-a" else "writer-a" + with condition: + started.add(name) + condition.notify_all() + if len(started) < 2 and not condition.wait_for( + lambda: len(started) == 2, timeout=0.1 + ): + serialized = True + condition.notify_all() + if not serialized: + self.assertTrue( + condition.wait_for(lambda: turn == name, timeout=5) + ) + written = real_write(descriptor, bytes(payload)[:7]) + if not serialized: + turn = other + condition.notify_all() + return written + + def writer(provider: str, uuid: str) -> None: + try: + self.record(provider=provider, uuid=uuid, now_unix_ms=1) + except BaseException as exc: + failures.append(exc) + + closed_sessions.os.write = short_write + workers = [ + threading.Thread(target=writer, name="writer-a", args=("claude", ONE)), + threading.Thread(target=writer, name="writer-b", args=("codex", TWO)), + ] + try: + for worker in workers: + worker.start() + for worker in workers: + worker.join(timeout=10) + finally: + closed_sessions.os.write = real_write + self.assertEqual([], failures) + self.assertTrue(all(not worker.is_alive() for worker in workers)) + self.assertEqual({ONE, TWO}, {row["uuid"] for row in self.listed()}) + + def test_a_record_with_no_conversation_and_no_shell_is_refused(self) -> None: + with self.assertRaises(Exception): + self.record(provider="claude", uuid="", title="Nameless") + + def test_a_close_learns_what_it_did_not_know_from_the_last_list(self) -> None: + inventory = { + "sessions": [ + { + "provider": "claude", + "identity": {"uuid": ONE}, + "title": "Cache Sweep", + "cwd": "/srv/kit", + "origin": "machine", + "origin_recorded": "machine", + "account_alias": "work", + } + ] + } + found = closed_sessions.entry_from_inventory( + inventory, provider="claude", uuid=ONE + ) + self.assertEqual("Cache Sweep", found["title"]) + self.assertEqual("/srv/kit", found["cwd"]) + self.assertEqual("machine", found["origin"]) + + def test_a_close_records_the_stamp_and_never_a_reading_of_the_moment( + self, + ) -> None: + """This row outlives the session and is read on every future restore. + + For an unstamped session `origin` is whatever collection inferred + about who held a socket at that instant, and a close landing while a + provider restarts infers "a program". Written here, that instant + becomes a permanent machine record for a conversation nobody ever + stamped: restored later it comes back a machine's, and no refresh can + take it back. No stamp means no claim, and no claim reads as the + person's. + """ + inventory = { + "sessions": [ + { + "provider": "claude", + "identity": {"uuid": ONE}, + "title": "Mid Bounce", + "cwd": "/srv/kit", + "origin": "machine", + } + ] + } + + found = closed_sessions.entry_from_inventory( + inventory, provider="claude", uuid=ONE + ) + + self.assertEqual("Mid Bounce", found["title"]) + # "unknown", not "human": the ledger records what was known, and + # nothing was. Every reader treats it as the person's, which is what + # an unproven session is -- but it is not written down as a claim. + self.assertEqual("unknown", found["origin"]) + + def test_a_ledger_that_opens_but_does_not_parse_is_not_a_ledger_with_no_rows( + self, + ) -> None: + """The difference a decision turns on, which opening the file misses. + + The ledger is the only thing that says a conversation was the + person's, so "no rows" is answered from somewhere else. A truncated + write and an empty file both yield no rows; only one of them is a + fact, and a reader that cannot tell them apart lets a crash mid-write + decide whose session this is. + """ + environ = self.environ + path = closed_sessions.ledger_path(environ) + path.parent.mkdir(parents=True, exist_ok=True) + cases = { + "no ledger yet": (None, True), + "an empty ledger": ("", True), + "one whole row": ( + '{"provider": "codex", "uuid": "%s", "closed_at_unix_ms": 1, ' + '"origin": "human"}\n' % ONE, + True, + ), + "a row cut off mid-write": ( + '{"provider": "codex", "uuid": "%s", "closed_at_unix' % ONE, + False, + ), + "a row this reader would skip": ( + '{"provider": "codex", "uuid": "not-a-uuid", "closed_at_unix_ms": 1}\n', + False, + ), + } + for label, (payload, expected) in cases.items(): + with self.subTest(label=label): + if payload is None: + path.unlink(missing_ok=True) + else: + path.write_text(payload, encoding="utf-8") + path.chmod(0o600) + self.assertEqual( + expected, closed_sessions.ledger_is_readable(environ) + ) + + def test_an_unknown_session_teaches_the_close_nothing(self) -> None: + found = closed_sessions.entry_from_inventory( + {"sessions": []}, provider="claude", uuid=ONE + ) + self.assertEqual( + { + "title": "", + "title_source": "", + "cwd": "", + "origin": "unknown", + "account_alias": "", + }, + found, + ) + + +class LedgerThroughTheCoreTests(unittest.TestCase): + """Every deliberate close writes the ledger, whoever performed it.""" + + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".closed-core-", dir=REPO) + self.base = Path(self.temp.name) + self.data = self.base / "data" + self.state = self.base / "state" + self.environ = isolated_environment(self.base, self.data, self.state) + (self.state / "inventory.json").write_text( + json.dumps( + { + "schema_version": 1, + "sessions": [ + { + "provider": "claude", + "shpool_id_raw": "s1", + "identity": {"uuid": ONE}, + "title": "Named By The List", + "cwd": "/srv/project", + "origin": "machine", + "origin_recorded": "machine", + } + ], + } + ), + encoding="utf-8", + ) + (self.state / "inventory.json").chmod(0o600) + + def tearDown(self) -> None: + self.temp.cleanup() + + def core(self, *argv: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(CORE), *argv], + env=self.environ, + capture_output=True, + text=True, + ) + + def sp(self, *argv: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(SP), *argv], + env=self.environ, + capture_output=True, + text=True, + ) + + def test_recording_a_close_intent_also_lists_the_conversation(self) -> None: + done = self.core("close-intent", "record", "claude", ONE) + self.assertEqual(0, done.returncode, done.stderr) + entries = [ + json.loads(line) + for line in (self.data / closed_sessions.LEDGER_NAME) + .read_text(encoding="utf-8") + .splitlines() + ] + self.assertEqual(1, len(entries)) + # The picker knows the row it closed; `sp close` knows it too. Neither + # has to carry the fields, because the last list already had them. + self.assertEqual("Named By The List", entries[0]["title"]) + self.assertEqual("/srv/project", entries[0]["cwd"]) + self.assertEqual("machine", entries[0]["origin"]) + + def test_the_list_verb_answers_with_what_was_recorded(self) -> None: + self.core( + "closed-sessions", + "record", + "shell", + "--session", + "s1", + "--title", + "A shell", + ) + listed = self.core("closed-sessions", "list") + self.assertEqual(0, listed.returncode, listed.stderr) + rows = json.loads(listed.stdout)["closed"] + self.assertEqual(["A shell"], [row["title"] for row in rows]) + self.assertFalse(rows[0]["restorable"]) + + def test_an_unreadable_existing_ledger_makes_list_fail(self) -> None: + self.core("closed-sessions", "record", "shell", "--session", "s1") + ledger = self.data / closed_sessions.LEDGER_NAME + ledger.chmod(0) + try: + listed = self.core("closed-sessions", "list") + finally: + ledger.chmod(0o600) + self.assertNotEqual(0, listed.returncode) + self.assertIn("cannot open closed-sessions ledger", listed.stderr) + + def test_recover_warns_when_the_real_ledger_reader_fails(self) -> None: + self.core("closed-sessions", "record", "shell", "--session", "s1") + ledger = self.data / closed_sessions.LEDGER_NAME + ledger.chmod(0) + try: + shown = self.sp("recover") + finally: + ledger.chmod(0o600) + self.assertNotEqual(0, shown.returncode, shown.stderr) + self.assertIn("closed-conversations list could not be read", shown.stderr) + self.assertNotIn("Closed conversations: none.", shown.stdout) + + def test_a_large_noop_forget_validates_without_rewriting(self) -> None: + self.core("closed-sessions", "record", "shell", "--session", "s1") + ledger = self.data / closed_sessions.LEDGER_NAME + with ledger.open("ab") as handle: + handle.write(b" \n" * (closed_sessions.MAX_LEDGER_BYTES // 2 + 1)) + before = ledger.read_bytes() + forgotten = self.core("closed-sessions", "forget", "claude", ONE) + self.assertEqual(0, forgotten.returncode, forgotten.stderr) + self.assertEqual(0, json.loads(forgotten.stdout)["forgotten"]) + self.assertEqual(before, ledger.read_bytes()) + + def test_core_and_recover_both_reach_a_tombstoned_row_past_four_mib( + self, + ) -> None: + transcript = ( + self.base + / "home/.codex/sessions/2026/08/15" + / f"rollout-2026-08-15T03-00-00-{ONE}.jsonl" + ) + transcript.parent.mkdir(parents=True) + transcript.write_text( + json.dumps({"type": "session_meta", "payload": {"id": ONE}}) + "\n", + encoding="utf-8", + ) + recorded = self.core( + "closed-sessions", + "record", + "codex", + "--uuid", + ONE, + "--title", + "Old Closed Conversation", + ) + self.assertEqual(0, recorded.returncode, recorded.stderr) + lifecycle.record_close_intent( + self.state, provider="codex", uuid=ONE + ) + ledger = self.data / closed_sessions.LEDGER_NAME + with ledger.open("ab") as handle: + number = 0 + while handle.tell() <= closed_sessions.MAX_LEDGER_BYTES + 2048: + number += 1 + handle.write( + ( + json.dumps( + { + "provider": "shell", + "uuid": "", + "title": "Later shell history", + "cwd": "", + "closed_at_unix_ms": number + 2, + "origin": "human", + "shpool_id": f"filler-{number}", + "account_alias": "", + }, + sort_keys=True, + ) + + "\n" + ).encode("utf-8") + ) + + listed = self.core("closed-sessions", "list") + shown = self.sp("recover") + self.assertEqual(0, listed.returncode, listed.stderr) + self.assertIn(ONE, listed.stdout) + self.assertEqual(0, shown.returncode, shown.stderr) + self.assertIn("Old Closed Conversation", shown.stdout) + self.assertNotIn("Argument list too long", shown.stderr) + + def test_core_and_recover_warn_instead_of_listing_a_pathological_tail( + self, + ) -> None: + ledger = self.data / closed_sessions.LEDGER_NAME + ledger.touch(mode=0o600) + with ledger.open("r+b") as handle: + handle.truncate(closed_sessions.MAX_LIST_LEDGER_BYTES + 1) + size_before = ledger.stat().st_size + + listed = self.core("closed-sessions", "list") + shown = self.sp("recover") + self.assertNotEqual(0, listed.returncode) + self.assertIn("complete-list safety limit", listed.stderr) + self.assertIn("no partial list was returned", listed.stderr) + self.assertNotEqual(0, shown.returncode) + self.assertIn("complete-list safety limit", shown.stderr) + self.assertIn("no partial list was returned", shown.stderr) + self.assertIn("sp recover --allow-large-ledger", shown.stderr) + self.assertIn("closed-conversations list could not be read", shown.stderr) + self.assertNotIn("Closed conversations: none.", shown.stdout) + self.assertEqual(size_before, ledger.stat().st_size) + + def test_large_ledger_recovery_override_is_a_shipped_way_back(self) -> None: + transcript = ( + self.base + / "home/.codex/sessions/2026/08/15" + / f"rollout-2026-08-15T03-00-00-{ONE}.jsonl" + ) + transcript.parent.mkdir(parents=True) + transcript.write_text( + json.dumps({"type": "session_meta", "payload": {"id": ONE}}) + "\n", + encoding="utf-8", + ) + ledger = self.data / closed_sessions.LEDGER_NAME + ledger.write_text( + json.dumps( + { + "provider": "codex", + "uuid": ONE, + "title": "Large ledger way back", + "cwd": str(self.base), + "closed_at_unix_ms": 1, + "origin": "human", + "shpool_id": "", + "account_alias": "", + }, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + ledger.chmod(0o600) + wrapper = self.base / "small-ceiling-core" + wrapper.write_text( + "#!/usr/bin/env python3\n" + "import sys\n" + f"sys.path.insert(0, {str(CORE.parent)!r})\n" + "from sessionkit_inventory import closed_sessions\n" + "closed_sessions.MAX_LIST_LEDGER_BYTES = 1\n" + "import session_inventory\n" + "raise SystemExit(session_inventory.main())\n", + encoding="utf-8", + ) + wrapper.chmod(0o700) + env = {**self.environ, "SESSION_KIT_INVENTORY_CORE": str(wrapper)} + + refused = subprocess.run( + [str(SP), "recover"], env=env, capture_output=True, text=True + ) + self.assertNotEqual(0, refused.returncode) + self.assertIn("sp recover --allow-large-ledger", refused.stderr) + self.assertNotIn("Closed conversations: none.", refused.stdout) + + recovered = subprocess.run( + [str(SP), "recover", "--allow-large-ledger"], + env=env, + capture_output=True, + text=True, + ) + self.assertEqual(0, recovered.returncode, recovered.stderr) + self.assertIn("Large ledger way back", recovered.stdout) + self.assertIn( + "sp restore --allow-large-ledger number", recovered.stdout + ) + + def test_complete_boundary_matrix_uses_one_listability_rule(self) -> None: + ceiling = 16 * 1024 + ledger = self.data / closed_sessions.LEDGER_NAME + for uuid in (ONE, TWO): + transcript = ( + self.base + / "home/.codex/sessions/2026/08/15" + / f"rollout-2026-08-15T03-00-00-{uuid}.jsonl" + ) + transcript.parent.mkdir(parents=True, exist_ok=True) + transcript.write_text( + json.dumps({"type": "session_meta", "payload": {"id": uuid}}) + + "\n", + encoding="utf-8", + ) + wrapper = self.base / "matrix-core" + wrapper.write_text( + "#!/usr/bin/env python3\n" + "import sys\n" + f"sys.path.insert(0, {str(CORE.parent)!r})\n" + "from sessionkit_inventory import closed_sessions\n" + f"closed_sessions.MAX_LIST_LEDGER_BYTES = {ceiling}\n" + "import session_inventory\n" + "raise SystemExit(session_inventory.main())\n", + encoding="utf-8", + ) + wrapper.chmod(0o700) + env = {**self.environ, "SESSION_KIT_INVENTORY_CORE": str(wrapper)} + + def install(size: int) -> bytes: + row = { + "provider": "codex", + "uuid": ONE, + "title": "Boundary target", + "cwd": str(self.base), + "closed_at_unix_ms": 1, + "origin": "human", + "shpool_id": "", + "account_alias": "", + "padding": "", + } + raw = (json.dumps(row, sort_keys=True) + "\n").encode("utf-8") + row["padding"] = "x" * (size - len(raw)) + raw = (json.dumps(row, sort_keys=True) + "\n").encode("utf-8") + self.assertEqual(size, len(raw)) + ledger.write_bytes(raw) + ledger.chmod(0o600) + close_intents = self.state / "closed-conversations.json" + close_intents.unlink(missing_ok=True) + return raw + + states = { + "ceiling_minus_one": ceiling - 1, + "exactly_ceiling": ceiling, + "one_over": ceiling + 1, + "far_over": ceiling + 4096, + } + + # Normal list and recovery accept the complete states through the + # ceiling. Above it they refuse the whole file, and the shipped + # deliberate override is the exact way back to every preserved row. + for name, size in states.items(): + with self.subTest(operation="list-recover", state=name): + install(size) + listed = subprocess.run( + [str(wrapper), "closed-sessions", "list"], + env=env, + capture_output=True, + text=True, + ) + recovered = subprocess.run( + [str(SP), "recover"], env=env, capture_output=True, text=True + ) + if size <= ceiling: + self.assertEqual(0, listed.returncode, listed.stderr) + self.assertEqual(0, recovered.returncode, recovered.stderr) + self.assertIn("Boundary target", recovered.stdout) + else: + self.assertNotEqual(0, listed.returncode) + self.assertNotEqual(0, recovered.returncode) + self.assertNotIn("Closed conversations: none.", recovered.stdout) + self.assertIn( + "sp recover --allow-large-ledger", recovered.stderr + ) + override = subprocess.run( + [str(SP), "recover", "--allow-large-ledger"], + env=env, + capture_output=True, + text=True, + ) + self.assertEqual(0, override.returncode, override.stderr) + self.assertIn("Boundary target", override.stdout) + + # Every close whose row would cross (including from one byte below), + # or which begins above, is byte-identical on refusal and writes no + # tombstone. A comfortably-below control succeeds and stays listable. + close_states = { + **states, + "append_crossing": ceiling - 100, + } + for name, size in close_states.items(): + with self.subTest(operation="close", state=name): + before = install(size) + closed = subprocess.run( + [str(wrapper), "close-intent", "record", "codex", TWO], + env=env, + capture_output=True, + text=True, + ) + self.assertNotEqual(0, closed.returncode, closed.stdout) + self.assertFalse(json.loads(closed.stdout)["recorded"]) + self.assertIn(str(ceiling), closed.stdout) + self.assertIn("sp recover --allow-large-ledger", closed.stdout) + self.assertEqual(before, ledger.read_bytes()) + self.assertFalse( + lifecycle.closed_on_purpose( + self.state, provider="codex", uuid=TWO + ) + ) + install(ceiling - 512) + accepted = subprocess.run( + [str(wrapper), "close-intent", "record", "codex", TWO], + env=env, + capture_output=True, + text=True, + ) + self.assertEqual(0, accepted.returncode, accepted.stdout) + self.assertTrue(json.loads(accepted.stdout)["recorded"]) + self.assertLessEqual(ledger.stat().st_size, ceiling) + self.assertEqual( + 0, + subprocess.run( + [str(wrapper), "closed-sessions", "list"], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode, + ) + + # Restore cleanup uses the same complete validator and original-byte + # transaction but no obsolete file-size refusal, so all four states + # can remove the target after its transcript has been recovered. + for name, size in states.items(): + with self.subTest(operation="restore", state=name): + install(size) + forgotten = subprocess.run( + [str(wrapper), "closed-sessions", "forget", "codex", ONE], + env=env, + capture_output=True, + text=True, + ) + self.assertEqual(0, forgotten.returncode, forgotten.stderr) + self.assertEqual(1, json.loads(forgotten.stdout)["forgotten"]) + self.assertEqual(b"", ledger.read_bytes()) + + +class RecoverFeedTests(unittest.TestCase): + def setUp(self) -> None: + self.fixture = CommandFixture() + + def tearDown(self) -> None: + self.fixture.close() + + def manifest(self, rows: dict) -> None: + path = self.fixture.state / "recovery-manifest.json" + path.write_text(json.dumps({"sessions": rows}), encoding="utf-8") + path.chmod(0o600) + + def closed_ledger(self, *rows: dict) -> None: + """Write the real ledger the one projection reads. + + These used to be handed to the shell through STUB_CLOSED_LIST, back + when the CLI merged the stores itself. There is one reader now, so + the fixture writes the store rather than the answer. + """ + data = self.fixture.home / ".local" / "share" / "session-kit" + data.mkdir(parents=True, exist_ok=True) + (data / "closed-sessions.jsonl").write_text( + "".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8" + ) + for row in rows: + if row.get("provider") == "shell" or not row.get("uuid"): + continue + if row.get("provider") == "codex": + transcript = ( + self.fixture.home + / ".codex" + / "sessions" + / "2026" + / "08" + / "15" + / f"rollout-2026-08-15T03-00-00-{row['uuid']}.jsonl" + ) + else: + transcript = ( + self.fixture.home + / ".claude" + / "projects" + / "-srv-project" + / f"{row['uuid']}.jsonl" + ) + transcript.parent.mkdir(parents=True, exist_ok=True) + transcript.write_text('{"type":"user"}\n', encoding="utf-8") + + def numbers(self, **bindings: int) -> None: + """The numbers these conversations already have.""" + path = self.fixture.state / "terminal-numbers.json" + path.write_text( + json.dumps({"schema_version": 1, "bindings": bindings}), + encoding="utf-8", + ) + path.chmod(0o600) + + def test_an_empty_feed_says_none_once(self) -> None: + shown = run([SP, "recover"], env=self.fixture.env()) + self.assertEqual("Closed conversations: none.\n", shown.stdout) + + def test_a_conversation_that_is_live_right_now_is_not_offered(self) -> None: + """A crash record whose conversation is open in the inventory is a + leftover, not lost work: restoring it would collide with the live + session (seen live 2026-08-12 after an estate-wide restart sweep).""" + self.manifest( + { + "s9": { + "provider": "codex", + "uuid": TWO, + "title": "Still Alive And Open", + "cwd": "/srv/project", + "crashed_at_unix_ms": 1_000, + } + } + ) + inventory = self.fixture.state / "inventory.json" + inventory.write_text( + json.dumps( + { + "schema_version": 1, + "sessions": [ + { + "provider": "codex", + "shpool_id_raw": "s77", + "identity": {"uuid": TWO}, + "title": "Still Alive And Open", + "cwd": "/srv/project", + } + ], + } + ), + encoding="utf-8", + ) + inventory.chmod(0o600) + shown = run([SP, "recover"], env=self.fixture.env()) + self.assertEqual("Closed conversations: none.\n", shown.stdout) + + def test_closed_conversations_and_lost_ones_share_one_list(self) -> None: + self.manifest( + { + "s9": { + "provider": "codex", + "uuid": TWO, + "title": "Lost To A Crash", + "cwd": "/srv/project", + "crashed_at_unix_ms": 1_000, + } + } + ) + self.closed_ledger( + { + "provider": "claude", + "uuid": ONE, + "title": "Closed On Purpose", + "title_source": "alias", + "cwd": "/srv/project", + "closed_at_unix_ms": 2_000, + "origin": "human", + }, + { + "provider": "shell", + "uuid": "", + "title": "A Shell", + "cwd": "/srv/project", + "closed_at_unix_ms": 1_500, + "origin": "human", + "shpool_id": "s-shell", + }, + ) + self.numbers(**{f"ai:claude:{ONE}": 11, f"ai:codex:{TWO}": 12}) + env = self.fixture.env() + shown = run([SP, "recover"], env=env) + lines = shown.stdout.splitlines() + self.assertIn("Closed On Purpose", lines[0]) + # A shell never had a name; it says so instead of borrowing one. + self.assertIn("unnamed", lines[1]) + self.assertIn("no conversation to reopen", lines[2]) + self.assertIn("Lost To A Crash", lines[3]) + self.assertIn("lost with its session", lines[3]) + self.assertRegex(lines[0], r" · \d+ days ago") + self.assertRegex(lines[3], r" · \d+ days ago") + self.assertEqual( + "Bring one back with: sp restore <number>, or the selector beside" + " a session that has none.", + lines[-1], + ) + # No screen prints an ID. + self.assertNotIn(ONE, shown.stdout) + self.assertNotIn(TWO, shown.stdout) + + def test_recovery_scrubs_terminal_controls_without_per_row_filters(self) -> None: + env = self.fixture.env() + self.closed_ledger( + { + "provider": "codex", + "uuid": TWO, + "title": "\x1b[31mClosed\x07 Conversation", + "title_source": "alias", + "cwd": "/srv/project", + "closed_at_unix_ms": 2_000, + "origin": "human", + } + ) + + shown = run([SP, "recover"], env=env) + self.assertNotIn("\x1b", shown.stdout) + self.assertNotIn("\x07", shown.stdout) + self.assertIn("[31mClosed Conversation", shown.stdout) + + def test_restore_brings_back_the_conversation_by_its_number(self) -> None: + self.closed_ledger( + { + "provider": "claude", + "uuid": ONE, + "title": "Closed On Purpose", + "title_source": "alias", + "cwd": str(self.fixture.project), + "closed_at_unix_ms": 2_000, + "origin": "human", + } + ) + self.numbers(**{f"ai:claude:{ONE}": 42}) + env = self.fixture.env() + env.update( + { + "STUB_DYNAMIC_PROVIDER": "claude", + "STUB_DYNAMIC_UUID": ONE, + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + } + ) + # By the number it has everywhere else, not by a row position. + restored = run([SP, "restore", "42"], env=env) + self.assertEqual("Restored Closed On Purpose.\n", restored.stdout) + self.assertTrue(self.fixture.shpool_log.read_text().startswith("attach ")) + + def test_large_ledger_selector_uses_the_matching_restore_stream(self) -> None: + env = self.fixture.env() + self.closed_ledger( + { + "provider": "codex", + "uuid": TWO, + "title": "Large Closed On Purpose", + "title_source": "alias", + "cwd": str(self.fixture.project), + "closed_at_unix_ms": 2_000, + "origin": "human", + } + ) + env.update( + { + "STUB_DYNAMIC_PROVIDER": "codex", + "STUB_DYNAMIC_UUID": TWO, + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + } + ) + restored = run( + [ + SP, + "restore", + "--allow-large-ledger", + "Large Closed On Purpose", + ], + env=env, + ) + self.assertEqual("Restored Large Closed On Purpose.\n", restored.stdout) + self.assertTrue(self.fixture.shpool_log.read_text().startswith("attach ")) + + def test_a_shell_row_offers_history_rather_than_a_restore(self) -> None: + self.closed_ledger( + { + "provider": "shell", + "uuid": "", + "title": "A Shell", + "cwd": str(self.fixture.project), + "closed_at_unix_ms": 2_000, + "origin": "human", + "shpool_id": "s-shell", + } + ) + env = self.fixture.env() + + # The list says so in a sentence, under the row it belongs to. A shell + # never had a conversation, so it never had a number either -- there + # is nothing to type, and nothing that could be restored if there were. + shown = run([SP, "recover"], env=env) + lines = shown.stdout.splitlines() + self.assertIn("unnamed", lines[0]) + self.assertIn("no conversation to reopen", lines[1]) + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_a_number_that_is_not_on_the_list_is_refused_in_one_line(self) -> None: + refused = run([SP, "restore", "20"], env=self.fixture.env(), check=False) + self.assertEqual(2, refused.returncode) + self.assertEqual( + "session-kit: there is no session 20 on that list. Type what the" + " row shows: its number, or the selector beside a session that has" + " none\n", + refused.stderr, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_codex_lifecycle_relaunch.py b/tests/test_codex_lifecycle_relaunch.py new file mode 100644 index 0000000..9fdf3c7 --- /dev/null +++ b/tests/test_codex_lifecycle_relaunch.py @@ -0,0 +1,406 @@ +"""Codex launch identity parity for title and account-switch relaunches.""" + +from __future__ import annotations + +import os +from pathlib import Path +import tempfile +import unittest + +from tests.support import REPO, run + + +BASHRC = REPO / "bashrc/shpool.bashrc" +UUID = "11111111-2222-4333-8444-555555555555" +TXID = "a" * 32 + + +class CodexLifecycleRelaunchTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory( + prefix=".codex-lifecycle-", dir=REPO + ) + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.home = self.root / "home" + self.home.mkdir(mode=0o700) + home_bin = self.home / ".local" / "bin" + home_bin.mkdir(parents=True, mode=0o700) + shpool = home_bin / "shpool" + shpool.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + shpool.chmod(0o755) + self.state_home = self.root / "state" + self.state_home.mkdir(mode=0o700) + self.start_dir = self.root / "start" + self.start_dir.mkdir(mode=0o700) + self.project = self.root / "project" + self.project.mkdir() + self.profile = self.root / "profile" + self.profile.mkdir() + self.fake_bin = self.root / "bin" + self.fake_bin.mkdir() + self.boot = self.root / "boot-id" + self.boot.write_text("fixture-boot\n", encoding="utf-8") + self.provider_log = self.root / "provider.log" + self.bounce_log = self.root / "bounce.log" + self.relaunch_log = self.root / "relaunch.log" + self.lifecycle_log = self.root / "lifecycle.log" + self.inventory = self.root / "inventory.py" + self.inventory.write_text( + "#!/usr/bin/env python3\n" + "import json, os, sys\n" + "args = sys.argv[1:]\n" + "if args[:1] == ['codex-bounce-title']:\n" + " with open(os.environ['BOUNCE_LOG'], 'a', encoding='utf-8') as stream:\n" + " stream.write(' '.join(args) + '\\n')\n" + " if os.environ.get('TITLE_AVAILABLE') == '1':\n" + " print('A real title')\n" + " raise SystemExit(0)\n" + " raise SystemExit(1)\n" + "if args[:2] in (['color', 'effective'], ['color', 'launch-pick']):\n" + " raise SystemExit(0)\n" + "if args[:2] == ['lifecycle', 'provider-exited']:\n" + " with open(os.environ['LIFECYCLE_LOG'], 'a', encoding='utf-8') as stream:\n" + " uuid = os.environ.get('SESSION_KIT_LIFECYCLE_CONVERSATION_UUID', '')\n" + " stream.write(uuid + '\\n')\n" + " raise SystemExit(0)\n" + "if args[:2] == ['account', 'resume-profile']:\n" + " print(json.dumps({'provider': args[2], 'alias': args[3], " + "'profile_dir': os.environ['ACCOUNT_PROFILE']}))\n" + " raise SystemExit(0)\n" + "if args[:2] in (['account', 'switch-apply'], ['account', 'switch-rollback']):\n" + " raise SystemExit(0)\n" + "if args[:1] == ['validate-worker-model']:\n" + " print(args[2])\n" + " raise SystemExit(0)\n" + "if args[:2] == ['action-log', 'launch']:\n" + " raise SystemExit(0)\n" + "raise SystemExit(1)\n", + encoding="utf-8", + ) + self.inventory.chmod(0o755) + codex = self.fake_bin / "codex" + codex.write_text( + "#!/bin/bash\n" + "printf '<%s>' \"$@\" >> \"$PROVIDER_LOG\"\n" + "printf '\\n' >> \"$PROVIDER_LOG\"\n" + "if [[ ${WRITE_SWITCH_REQUEST:-0} == 1 ]]; then\n" + " mkdir -p -- \"$(dirname -- \"$SWITCH_REQUEST\")\"\n" + " chmod 700 -- \"$(dirname -- \"$SWITCH_REQUEST\")\"\n" + " printf 'apply\\t%s\\ttarget\\tsource\\t%s\\t%s\\t%s\\t%s\\t%s\\n' " + "\"$SWITCH_TXID\" \"$THREAD_UUID\" \"$SESSION_STARTED\" " + "\"$SESSION_SHELL_PID\" \"$SESSION_SHELL_START\" \"$REQUESTED_MODEL\" > \"$SWITCH_REQUEST\"\n" + " chmod 600 -- \"$SWITCH_REQUEST\"\n" + "fi\n", + encoding="utf-8", + ) + codex.chmod(0o755) + fake_bash = self.fake_bin / "bash" + fake_bash.write_text( + "#!/bin/sh\nprintf '<%s>' \"$@\" > \"$RELAUNCH_LOG\"\n", + encoding="utf-8", + ) + fake_bash.chmod(0o755) + + def launch( + self, + mode: str, + *, + title_available: bool = False, + switch_request: bool = False, + model: str = "", + ): + uuid = "" if mode == "new" else UUID + start = self.start_dir / "main1" + start.write_text( + f"codex\t{self.project}\t{uuid}\t{mode}\n", encoding="utf-8" + ) + start.chmod(0o600) + account = Path(f"{start}.account") + account.write_text("codex\tsource\n", encoding="utf-8") + account.chmod(0o600) + inner = ( + 'shell_start=$(awk "{print \\$22}" /proc/$$/stat); ' + 'daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat); ' + 'export SESSION_SHELL_PID=$$ SESSION_SHELL_START=$shell_start; ' + 'printf "codex\\t%s\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n" ' + '"$2" "$$" "$shell_start" "$PPID" "$daemon_start" "$3" "$4" > "$5/main1.expected"; ' + 'if [[ -n $6 ]]; then ' + 'printf "codex\\t%s\\t%s\\tworker:fixture:1\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\n" ' + '"$2" "$6" "$$" "$shell_start" "$PPID" "$daemon_start" > "$5/main1.launch"; ' + 'fi; chmod 600 "$5/main1.expected" ${6:+"$5/main1.launch"}; source "$1"' + ) + switch_path = ( + self.state_home + / "session-kit" + / "account-switch-requests" + / "main1" + ) + environment = os.environ.copy() + environment.update( + { + "ACCOUNT_PROFILE": os.fspath(self.profile), + "BOUNCE_LOG": os.fspath(self.bounce_log), + "HOME": os.fspath(self.home), + "LIFECYCLE_LOG": os.fspath(self.lifecycle_log), + "PATH": f"{self.fake_bin}:{os.environ['PATH']}", + "PROVIDER_LOG": os.fspath(self.provider_log), + "RELAUNCH_LOG": os.fspath(self.relaunch_log), + "REQUESTED_MODEL": model, + "SESSION_KIT_BOOT_ID_FILE": os.fspath(self.boot), + "SESSION_KIT_INVENTORY_CORE": os.fspath(self.inventory), + "SESSION_KIT_ARCHIVE_DIR": os.fspath(self.root / "archives"), + "SESSION_KIT_JOURNAL_DIR": os.fspath(self.root / "journals"), + "SESSION_KIT_JOURNAL_RECOVERY_DIR": os.fspath(self.root / "recovery"), + "SESSION_KIT_START_DIR": os.fspath(self.start_dir), + "SESSION_KIT_STATE_DIR": os.fspath(self.state_home / "session-kit"), + "SHPOOL_JOURNAL": "disabled", + "SHPOOL_SESSION_NAME": "main1", + "SWITCH_REQUEST": os.fspath(switch_path), + "SWITCH_TXID": TXID, + "SESSION_STARTED": "1", + "THREAD_UUID": UUID, + "TITLE_AVAILABLE": "1" if title_available else "0", + "WRITE_SWITCH_REQUEST": "1" if switch_request else "0", + "XDG_CONFIG_HOME": os.fspath(self.root / "xdg-config"), + "XDG_DATA_HOME": os.fspath(self.root / "xdg-data"), + "XDG_STATE_HOME": os.fspath(self.state_home), + } + ) + completed = run( + [ + "/bin/bash", + "-c", + '/bin/bash --noprofile --norc -ic "$1" lifecycle-inner "$2" "$3" "$4" "$5" "$6" "$7"', + "codex-lifecycle-test", + inner, + BASHRC, + self.project, + uuid, + mode, + self.start_dir, + model, + ], + env=environment, + ) + return completed, start, switch_path + + def test_a_bounce_holds_its_marker_across_the_relaunch_and_bounces_once( + self, + ) -> None: + """The marker is the session's cover while its window is gone. + + Nothing in this shell can see the replacement window: it blocks on the + provider it just started, so every moment it could pick to drop the + marker is a moment before the window exists -- and in that moment the + session is an App Server and a broker with no window, which reads as a + machine's. So the shell empties the marker instead of removing it. + Emptied, this same block cannot bounce again, because the next read + finds no conversation ID. Present, the session goes on reading as + bouncing until collection sees the window and clears it. + """ + marker = self.state_home / "session-kit" / "provider-bounce" / "main1" + marker.parent.mkdir(parents=True, mode=0o700) + marker.write_text( + f"{UUID}\nxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\nrequestgeneration\n", + encoding="utf-8", + ) + marker.chmod(0o600) + # The provider itself reports what the classifier would have seen at + # the one instant that matters: the moment the replacement window + # exists. Nothing else in this harness can observe that instant. + codex = self.fake_bin / "codex" + codex.write_text( + "#!/bin/bash\n" + # Either name counts. The shell renames the marker as it takes the + # instruction, and both names read as bouncing -- what matters at + # this instant is that SOMETHING is still covering the row. + 'if [[ -f $BOUNCE_MARKER || -f $BOUNCE_MARKER.taken ]] || ' + 'compgen -G "$BOUNCE_MARKER.taken.*" >/dev/null; then\n' + ' printf "marker=present\\n" >> "$PROVIDER_LOG"\n' + "else\n" + ' printf "marker=absent\\n" >> "$PROVIDER_LOG"\n' + "fi\n", + encoding="utf-8", + ) + codex.chmod(0o755) + os.environ["BOUNCE_MARKER"] = os.fspath(marker) + self.addCleanup(os.environ.pop, "BOUNCE_MARKER", None) + + completed, _, _ = self.launch("resume") + + self.assertEqual(0, completed.returncode, completed.stderr) + seen = [ + line + for line in self.provider_log.read_text(encoding="utf-8").splitlines() + if line + ] + # Two launches: the bounce happened, and it happened once. An + # unconsumed marker here would bounce this session forever. + self.assertEqual(["marker=present", "marker=present"], seen) + # And no marker of either name outlives the session's last provider + # exit, so a bounce cannot leave classification suppressed for an id + # that nothing will consume or settle again. + self.assertFalse(marker.exists()) + self.assertFalse(Path(f"{marker}.taken").exists()) + self.assertEqual([], list(marker.parent.glob(f"{marker.name}.taken.*"))) + + def test_a_collection_while_the_bounce_is_pending_never_ends_the_session( + self, + ) -> None: + """The consequence of clearing a marker nobody has consumed. + + A bounce is requested while the window is still up, and every bounce + takes a fresh collection before it signals -- so a collection reading + `app_server_window` beside a full marker is the normal case, not a + corner. If that reading removes the marker, this shell finds no + instruction where its instruction should be, falls out of the relaunch + loop, and takes the ordinary provider exit: the session ends. + + That is not the visible-versus-hidden trade this branch is about. A + hidden row is one refresh from coming back; a closed session is gone. + """ + marker = self.state_home / "session-kit" / "provider-bounce" / "main1" + marker.parent.mkdir(parents=True, mode=0o700) + marker.write_text(f"{UUID}\n", encoding="utf-8") + marker.chmod(0o600) + collector = self.root / "collect.py" + collector.write_text( + "import sys\n" + f"sys.path.insert(0, {os.fspath(REPO / 'lib')!r})\n" + "from pathlib import Path\n" + "from sessionkit_inventory import origins\n" + "origins.apply_session_origins(\n" + " {'sessions': [{'shpool_id_raw': 'main1',\n" + " 'app_server_window': True}]},\n" + f" state_dir=Path({os.fspath(self.state_home / 'session-kit')!r}),\n" + ")\n", + encoding="utf-8", + ) + # The window is up for the whole of the first launch, which is exactly + # when the requesting collection runs. It runs once, from inside it. + codex = self.fake_bin / "codex" + codex.write_text( + "#!/bin/bash\n" + 'printf "launch\\n" >> "$PROVIDER_LOG"\n' + 'if [[ ! -f $COLLECT_DONE ]]; then\n' + ' : > "$COLLECT_DONE"\n' + ' python3 "$COLLECT_SCRIPT" || true\n' + "fi\n", + encoding="utf-8", + ) + codex.chmod(0o755) + os.environ["COLLECT_SCRIPT"] = os.fspath(collector) + os.environ["COLLECT_DONE"] = os.fspath(self.root / "collected") + self.addCleanup(os.environ.pop, "COLLECT_SCRIPT", None) + self.addCleanup(os.environ.pop, "COLLECT_DONE", None) + + completed, _, _ = self.launch("resume") + + self.assertEqual(0, completed.returncode, completed.stderr) + launches = [ + line + for line in self.provider_log.read_text(encoding="utf-8").splitlines() + if line + ] + self.assertEqual( + ["launch", "launch"], + launches, + "the collection cancelled the bounce, so the shell ended the " + "session instead of relaunching its window", + ) + + def test_first_generation_codex_account_switch_relaunches_exact_thread(self) -> None: + completed, start, request = self.launch("new", switch_request=True) + + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertNotIn("could not safely relaunch", completed.stderr) + self.assertFalse(request.exists()) + self.assertEqual( + f"codex\t{self.project}\t{UUID}\tresume\n", + start.read_text(encoding="utf-8"), + ) + self.assertEqual( + "codex\ttarget\n", + Path(f"{start}.account").read_text(encoding="utf-8"), + ) + self.assertEqual("<-i>", self.relaunch_log.read_text(encoding="utf-8")) + + def test_account_switch_relaunch_preserves_the_requested_model(self) -> None: + model = "gpt-5.6-codex" + completed, start, request = self.launch( + "resume", switch_request=True, model=model + ) + + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertFalse(request.exists()) + launch = Path(f"{start}.launch") + self.assertTrue(launch.is_file()) + fields = launch.read_text(encoding="utf-8").rstrip("\n").split("\t") + self.assertEqual(10, len(fields)) + self.assertEqual( + ["codex", str(self.project), model, "worker:fixture:1"], fields[:4] + ) + + def test_first_generation_rejects_retained_unbound_switch_request(self) -> None: + request = ( + self.state_home + / "session-kit" + / "account-switch-requests" + / "main1" + ) + request.parent.mkdir(parents=True, mode=0o700) + request.write_text( + f"apply\t{TXID}\ttarget\tsource\t{UUID}\n", encoding="utf-8" + ) + request.chmod(0o600) + + completed, start, retained = self.launch("new") + + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("could not safely relaunch", completed.stderr) + self.assertTrue(retained.is_file()) + self.assertFalse(start.exists()) + self.assertFalse(self.relaunch_log.exists()) + + def test_untitled_resume_leaves_the_one_shot_title_bounce_marker(self) -> None: + completed, _, _ = self.launch("resume") + + self.assertEqual(0, completed.returncode, completed.stderr) + marker = self.state_home / "session-kit/provider-untitled/main1" + self.assertTrue(marker.is_file()) + + def test_new_launch_marks_untitled_without_an_empty_uuid_lookup(self) -> None: + completed, _, _ = self.launch("new") + + self.assertEqual(0, completed.returncode, completed.stderr) + marker = self.state_home / "session-kit/provider-untitled/main1" + self.assertTrue(marker.is_file()) + self.assertFalse(self.bounce_log.exists()) + + def test_resume_title_lookup_is_read_only(self) -> None: + completed, _, _ = self.launch("resume", title_available=True) + + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertEqual( + f"codex-bounce-title --read-only {UUID}\n", + self.bounce_log.read_text(encoding="utf-8"), + ) + + def test_untitled_fork_leaves_the_one_shot_title_bounce_marker(self) -> None: + completed, _, _ = self.launch("fork") + + self.assertEqual(0, completed.returncode, completed.stderr) + marker = self.state_home / "session-kit/provider-untitled/main1" + self.assertTrue(marker.is_file()) + + def test_titled_resume_does_not_request_an_unneeded_bounce(self) -> None: + completed, _, _ = self.launch("resume", title_available=True) + + self.assertEqual(0, completed.returncode, completed.stderr) + marker = self.state_home / "session-kit/provider-untitled/main1" + self.assertFalse(marker.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_color_authority.py b/tests/test_color_authority.py new file mode 100644 index 0000000..c45544d --- /dev/null +++ b/tests/test_color_authority.py @@ -0,0 +1,308 @@ +"""One colour wins on every surface that draws a session. + +Measured live on 2026-08-12: a session showed green in the picker and another +colour in its own terminal. Three owners, not one. + + 1. The colour push wrote only into ``~/.claude/projects``. A session started + on an enrolled account keeps its transcript under that account's profile, + so the push found nothing and said so to nobody, six of seven live + sessions had no colour record at all, and every provider window therefore + picked its own colour while the picker showed the kit's. + 2. The in-session prompt bar was a colour constant. It could agree with the + picker only by luck. + 3. Nothing gave a live window the new colour after `sp color`; the store + changed and the window did not. + +The transcript record is what the provider renders AND what the collector +reads, so once the push lands in the right profile the two cannot disagree. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest + +from tests.support import REPO, run +from tests.test_commands import CommandFixture, inventory_document, session_row + +sys.path.insert(0, os.fspath(REPO / "lib")) + +from sessionkit_inventory import colors, names_push # noqa: E402 + +SP = REPO / "bin" / "sp" +CORE = REPO / "lib" / "session_inventory.py" +UUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + + +def facade() -> object: + spec = importlib.util.spec_from_file_location("session_inventory_colors", CORE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class ColorPushReachTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".color-reach-", dir=REPO) + self.addCleanup(self.temp.cleanup) + self.home = Path(self.temp.name) + + def transcript(self, root: Path) -> Path: + projects = root / "projects" / "-srv-project" + projects.mkdir(parents=True) + path = projects / f"{UUID}.jsonl" + path.write_text('{"type":"user"}\n', encoding="utf-8") + return path + + def records(self, path: Path, kind: str) -> list[dict]: + found = [] + for line in path.read_text(encoding="utf-8").splitlines(): + try: + record = json.loads(line) + except ValueError: + continue + if record.get("type") == kind: + found.append(record) + return found + + def test_a_colour_reaches_the_transcript_in_the_default_profile(self) -> None: + path = self.transcript(self.home / ".claude") + pushed, warnings = names_push._push_claude_color(self.home, UUID, "green") + self.assertEqual([], warnings) + self.assertIn("claude-transcript-color", pushed) + self.assertEqual("green", self.records(path, "agent-color")[-1]["agentColor"]) + + def test_a_colour_reaches_a_session_running_on_an_enrolled_account(self) -> None: + """The measured blindness: the transcript is under the profile.""" + path = self.transcript( + self.home / ".local/share/session-kit/accounts/claude/work" + ) + pushed, warnings = names_push._push_claude_color(self.home, UUID, "cyan") + self.assertEqual([], warnings) + self.assertIn("claude-transcript-color", pushed) + self.assertEqual("cyan", self.records(path, "agent-color")[-1]["agentColor"]) + + def test_a_name_reaches_the_same_profile_transcript(self) -> None: + (self.home / ".claude" / "sessions").mkdir(parents=True) + path = self.transcript( + self.home / ".local/share/session-kit/accounts/claude/work" + ) + pushed, _ = names_push._push_claude_title( + self.home, + UUID, + "Cache Sweep", + atomic_write_json=lambda target, payload: target.write_text( + json.dumps(payload), encoding="utf-8" + ), + max_session_records=8, + ) + self.assertIn("claude-transcript-name", pushed) + self.assertEqual("Cache Sweep", self.records(path, "agent-name")[-1]["agentName"]) + + def test_a_conversation_with_no_transcript_anywhere_says_so(self) -> None: + pushed, warnings = names_push._push_claude_color(self.home, UUID, "green") + self.assertEqual([], pushed) + self.assertEqual( + ["no Claude transcript for this conversation; color not pushed"], warnings + ) + + +class PublishedColorTests(unittest.TestCase): + """What the in-session prompt reads instead of holding its own opinion.""" + + def setUp(self) -> None: + self.core = facade() + self.temp = tempfile.TemporaryDirectory(prefix=".color-publish-", dir=REPO) + self.addCleanup(self.temp.cleanup) + self.state = Path(self.temp.name) + + def published(self, name: str) -> str: + return (self.state / "session-color" / name).read_text(encoding="utf-8") + + def test_every_session_publishes_its_colour_for_its_own_shell(self) -> None: + written = self.core.publish_session_colors( + { + "sessions": [ + {"shpool_id_raw": "s1", "display_color": "green"}, + {"shpool_id_raw": "s2", "display_color": "cyan"}, + ] + }, + state_dir=self.state, + ) + self.assertEqual(2, written) + self.assertEqual("green 38;2;63;221;115\n", self.published("s1")) + self.assertEqual("cyan 38;2;64;216;209\n", self.published("s2")) + self.assertEqual( + 0o600, os.stat(self.state / "session-color" / "s1").st_mode & 0o777 + ) + + def test_the_colour_the_picker_draws_is_the_colour_published(self) -> None: + """Same table, so the two can never drift apart.""" + for name in colors.SESSION_COLORS: + self.assertIn(name, colors.SESSION_SGR) + + def test_an_unchanged_colour_is_not_rewritten(self) -> None: + rows = {"sessions": [{"shpool_id_raw": "s1", "display_color": "green"}]} + self.core.publish_session_colors(rows, state_dir=self.state) + self.assertEqual(0, self.core.publish_session_colors(rows, state_dir=self.state)) + + def test_a_colour_file_never_outlives_its_session(self) -> None: + self.core.publish_session_colors( + { + "sessions": [ + { + "shpool_id_raw": "s1", + "display_color": "green", + "shpool_shell": {"pid": 101, "process_start_ticks": 1001}, + } + ] + }, + state_dir=self.state, + ) + captured = self.core.capture_session_color_generations(self.state) + self.core.publish_session_colors( + { + "sessions": [ + { + "shpool_id_raw": "s2", + "display_color": "cyan", + "shpool_shell": {"pid": 102, "process_start_ticks": 1002}, + } + ] + }, + state_dir=self.state, + retire_generations=captured, + ) + self.assertFalse((self.state / "session-color" / "s1").exists()) + self.assertTrue((self.state / "session-color" / "s2").exists()) + + def test_a_stale_colour_sweep_cannot_delete_a_newer_session_generation( + self, + ) -> None: + def row(pid: int, ticks: int) -> dict: + return { + "shpool_id_raw": "same", + "display_color": "green", + "shpool_shell": {"pid": pid, "process_start_ticks": ticks}, + } + + self.core.publish_session_colors( + {"sessions": [row(101, 1001)]}, state_dir=self.state + ) + captured = self.core.capture_session_color_generations(self.state) + self.core.publish_session_colors( + {"sessions": [row(202, 2002)]}, state_dir=self.state + ) + self.core.publish_session_colors( + {"sessions": []}, + state_dir=self.state, + retire_generations=captured, + ) + + self.assertTrue((self.state / "session-color" / "same").exists()) + + def test_a_row_with_no_usable_colour_publishes_nothing(self) -> None: + self.assertEqual( + 0, + self.core.publish_session_colors( + { + "sessions": [ + {"shpool_id_raw": "s1", "display_color": "chartreuse"}, + {"shpool_id_raw": "", "display_color": "green"}, + {"shpool_id_raw": "../escape", "display_color": "green"}, + ] + }, + state_dir=self.state, + ), + ) + + +class PromptBarTests(unittest.TestCase): + """The prompt draws the published colour, and old prompts still work.""" + + BASHRC = REPO / "bashrc" / "shpool.bashrc" + + def _bar(self, state: Path, session: str) -> str: + text = self.BASHRC.read_text(encoding="utf-8") + start = text.index(" __sk_bar() {") + body = text[start : text.index("\n }\n", start) + len("\n }\n")] + program = body.replace(" __sk_bar()", "__sk_bar()", 1) + completed = run( + [ + "bash", + "-c", + f"{program}\n__sk_bar", + ], + env={ + "XDG_STATE_HOME": os.fspath(state), + "SHPOOL_SESSION_NAME": session, + "PATH": os.environ["PATH"], + "HOME": os.fspath(state), + }, + ) + return completed.stdout + + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".color-bar-", dir=REPO) + self.addCleanup(self.temp.cleanup) + self.state = Path(self.temp.name) + self.directory = self.state / "session-kit" / "session-color" + self.directory.mkdir(parents=True) + + def test_the_bar_is_the_colour_the_session_was_published_with(self) -> None: + (self.directory / "s1").write_text("cyan 38;2;64;216;209\n", encoding="utf-8") + self.assertIn("\033[38;2;64;216;209m", self._bar(self.state, "s1")) + + def test_a_session_with_no_published_colour_keeps_the_old_bar(self) -> None: + self.assertIn("\033[38;5;71m", self._bar(self.state, "unpublished")) + + def test_a_corrupt_colour_file_never_reaches_the_terminal(self) -> None: + (self.directory / "s1").write_text( + "evil \033]0;pwned\007\n", encoding="utf-8" + ) + bar = self._bar(self.state, "s1") + self.assertNotIn("pwned", bar) + self.assertIn("\033[38;5;71m", bar) + + +class ColorChangeReachesTheWindowTests(unittest.TestCase): + def setUp(self) -> None: + self.fixture = CommandFixture() + row = session_row("main1", provider="claude", uuid=UUID) + row["agent_status"] = "idle" + self.fixture.inventory.write_text( + json.dumps(inventory_document(row)), encoding="utf-8" + ) + + def tearDown(self) -> None: + self.fixture.close() + + def test_colouring_a_session_marks_its_window_for_the_new_colour(self) -> None: + colored = run([SP, "color", "main1", "cyan"], env=self.fixture.env()) + self.assertIn("Colored", colored.stdout) + self.assertIn( + "The picker and the prompt show it now; the Claude window shows it" + " from its next start.", + colored.stdout, + ) + self.assertTrue( + (self.fixture.state / "provider-untitled" / "main1").is_file() + ) + # Marking is not restarting: nothing was killed to recolour a session. + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_resetting_a_colour_marks_the_window_too(self) -> None: + run([SP, "color", "reset", "main1"], env=self.fixture.env()) + self.assertTrue( + (self.fixture.state / "provider-untitled" / "main1").is_file() + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_commands.py b/tests/test_commands.py index 72c3411..e7a82a2 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -8,12 +8,16 @@ import shutil import stat import subprocess +import sys import tempfile import time import unittest from tests.support import REPO, run +sys.path.insert(0, str(REPO / "lib")) +from sessionkit_inventory import lifecycle # noqa: E402 + SP = REPO / "bin" / "sp" REAPER = REPO / "bin" / "shpool_reaper" @@ -151,7 +155,7 @@ def run_queued_creator_after_marker_switch( write_executable( fake_bin / "flock", """#!/usr/bin/env bash -if [[ ${1:-} == -x && ${2:-} == 9 ]]; then +if [[ ${1:-} == -x && ${*: -1} == 9 ]]; then : > "$TEST_FLOCK_ENTERED" fi exec "$TEST_REAL_FLOCK" "$@" @@ -203,7 +207,10 @@ def __init__(self) -> None: self.home = self.base / "home" self.home.mkdir() self.state = self.base / "state" - self.state.mkdir() + # The real core refuses a state directory that is not owner-private, + # and the recovery list is served by it, so the fixture has to hold + # the same contract a real install does. + self.state.mkdir(mode=0o700) self.boot_id = self.base / "boot-id" self.boot_id.write_text("fixture-boot\n", encoding="utf-8") self.release_id = "a" * 40 @@ -237,10 +244,22 @@ def __init__(self) -> None: self.config.chmod(0o600) self.fake_shpool = self.base / "fake-shpool" self.fake_core = self.base / "fake-inventory" + home_bin = self.home / ".local" / "bin" + home_bin.mkdir(parents=True, mode=0o700) + write_executable( + home_bin / "shpool", + "#!/usr/bin/env bash\nexec \"$SESSION_KIT_SHPOOL_CMD\" \"$@\"\n", + ) + self.close_intent_log = self.base / "close-intent.log" + self.worktree_log = self.base / "worktree.log" + self.origin_log = self.base / "origin.log" + self.origin_instance_log = self.base / "origin-instance.log" + self.closed_log = self.base / "closed-sessions.log" + self.name_push_log = self.base / "name-push.log" write_executable( self.fake_shpool, """#!/usr/bin/env python3 -import fcntl, json, os, pathlib, sys, tempfile, time +import fcntl, json, os, pathlib, shutil, sys, tempfile, time state=pathlib.Path(os.environ["FAKE_SHPOOL_STATE"]) log=pathlib.Path(os.environ["FAKE_SHPOOL_LOG"]) lock=state.with_suffix(".lock") @@ -268,6 +287,20 @@ def __init__(self) -> None: if os.environ.get("FAKE_ATTACH_FAIL") == "1": raise SystemExit(5) name=args[-1] + collision_marker=os.environ.get("FAKE_CREATE_COLLISION_ONCE") + if collision_marker and not pathlib.Path(collision_marker).exists(): + pathlib.Path(collision_marker).write_text(name) + if not any(row.get("name") == name for row in data["sessions"]): + data["sessions"].append({ + "name":name, + "status":"Disconnected", + "started_at_unix_ms":int(time.time()*1000)-1000, + }) + fd,tmp=tempfile.mkstemp(prefix=".state.",dir=state.parent) + with os.fdopen(fd,"w") as out: json.dump(data,out) + os.replace(tmp,state) + with log.open("a") as out: out.write("collision "+name+"\\n") + raise SystemExit(17) target=next( (row for row in data["sessions"] if row.get("name") == name), None, @@ -292,10 +325,21 @@ def __init__(self) -> None: action="attach" if os.environ.get("FAKE_DROP_BEFORE_ATTACH") == "1": data["sessions"]=[row for row in data["sessions"] if row.get("name") != name] - exits_immediately="--cmd" in args and args[args.index("--cmd")+1] == "/bin/false" + immediate_command=( + args[args.index("--cmd")+1] if "--cmd" in args else "" + ) + exits_immediately=immediate_command in {"/bin/false", "/bin/true"} + if immediate_command == "/bin/true": + data["sessions"]=[ + row for row in data["sessions"] if row.get("name") != name + ] + action="attach-exit" if not exits_immediately and not any(row.get("name") == name for row in data["sessions"]): data["sessions"].append({"name":name,"status":"Disconnected","started_at_unix_ms":int(time.time()*1000)}) elif len(args) == 2 and args[0] == "kill": + required_signal=os.environ.get("FAKE_REQUIRE_EXACT_SIGNAL") + if required_signal and not pathlib.Path(required_signal).is_file(): + raise SystemExit(89) expected_lock=os.environ.get( "FAKE_EXPECT_KILL_LOCK", os.environ.get("FAKE_EXPECT_CREATE_LOCK"), @@ -315,6 +359,19 @@ def __init__(self) -> None: data["sessions"]=[row for row in data["sessions"] if row.get("name") != name] if len(data["sessions"]) == before: raise SystemExit(4) + proc_pid=os.environ.get("FAKE_KILL_PROC_PID","") + proc_root=os.environ.get("SESSION_KIT_PROC_ROOT","") + if proc_pid.isdigit() and proc_root: + shutil.rmtree(pathlib.Path(proc_root)/proc_pid, ignore_errors=True) + inventory_path=os.environ.get("FAKE_DROP_INVENTORY_ROW") + if inventory_path: + inventory_file=pathlib.Path(inventory_path) + inventory=json.loads(inventory_file.read_text()) + inventory["sessions"]=[ + row for row in inventory.get("sessions",[]) + if row.get("shpool_id_raw") != name and row.get("shpool_id") != name + ] + inventory_file.write_text(json.dumps(inventory)) action="kill" else: raise SystemExit(2) @@ -329,6 +386,190 @@ def __init__(self) -> None: """#!/usr/bin/env python3 import json, os, pathlib, sys, tempfile, unicodedata args=sys.argv[1:] +if args[:2] in (["account", "launch-profile"], ["account", "resume-profile"]) and len(args) == 4: + # This branch came from the profile-enrolment fixture while the account + # switch branch added the ordered call log below. A merged fixture must do + # both: returning here before logging made the real launch-profile step + # disappear from five ordering tests even though production ran it. + log=os.environ.get("STUB_ACCOUNT_LOG") + if log: + with open(log, "a") as handle: + handle.write(json.dumps(args)+"\\n") + if args[1] == "launch-profile" and os.environ.get("STUB_ACCOUNT_LAUNCH_FAILS") == "1": + sys.stderr.write("account "+args[3]+" is not selectable: blocked\\n") + raise SystemExit(1) + # Enrollment is stubbed by naming a directory. With none named the + # account is simply not enrolled, which is what the real core reports. + profile=os.environ.get("STUB_ACCOUNT_PROFILE") + if not profile: + sys.stderr.write("no such enrolled account\\n") + raise SystemExit(1) + marker=os.environ.get("STUB_ACCOUNT_LAUNCH_MARKER") + if args[1] == "launch-profile" and marker: + pathlib.Path(marker).write_text(json.dumps({"provider":args[2],"alias":args[3]})) + print(json.dumps({"provider": args[2], "alias": args[3], "profile_dir": profile})) + raise SystemExit(0) +if args[:2] == ["account", "bind"]: + print(json.dumps({"bound": True})) + raise SystemExit(0) +if args[:2] == ["platform", "terminate-exact-process"] and not os.environ.get( + "SESSION_KIT_PROC_ROOT" +): + if os.environ.get("STUB_EXACT_ALREADY_GONE") == "1": + raise SystemExit(1) + log=os.environ.get("SESSION_KIT_TEST_EXACT_SIGNAL_LOG") + if log: + with open(log,"a",encoding="utf-8") as handle: + handle.write(f"{args[2]}\\t{args[3]}\\t15\\n") + drop_name=os.environ.get("FAKE_DROP_EXACT_SESSION") + inventory_path=os.environ.get("FAKE_DROP_INVENTORY_ROW") + if drop_name and inventory_path: + inventory_file=pathlib.Path(inventory_path) + inventory=json.loads(inventory_file.read_text()) + inventory["sessions"]=[ + row for row in inventory.get("sessions",[]) + if row.get("shpool_id_raw") != drop_name and row.get("shpool_id") != drop_name + ] + inventory_file.write_text(json.dumps(inventory)) + print("terminated") + raise SystemExit(0) +if args[:2] == ["platform", "exact-shell-gone"] and not os.environ.get( + "SESSION_KIT_PROC_ROOT" +): + print("gone") + raise SystemExit(0) +if args[:2] == ["platform", "shpool-holder-generation"] and not os.environ.get( + "SESSION_KIT_PROC_ROOT" +): + inventory=json.loads(pathlib.Path(os.environ["STUB_INVENTORY"]).read_text()) + daemon=inventory["daemon_generation"] + print(f"{daemon['pid']}\t{daemon['process_start_ticks']}") + raise SystemExit(0) +if args[:2] in ( + ["platform", "shpool-holder-generation"], + ["platform", "exact-shell-gone"], + ["platform", "terminate-exact-process"], +): + os.execvp("python3", ["python3", os.environ["STUB_REAL_CORE"], *args]) +if args[:2] == ["platform", "process-table"] and len(args) == 2: + table=os.environ.get("STUB_PROCESS_TABLE") + if not table: + raise SystemExit(2) + print(table) + raise SystemExit(0) +if args[:2] == ["worktree", "lookup"]: + # Answered by the real core against the fixture's own registry: a restore + # that has to find the repository a released copy came from is exactly the + # path a stub would hide. + os.execv(sys.executable, [sys.executable, os.environ["STUB_REAL_CORE"], *args]) +if args[:1] in (["model-availability"], ["model-served"]): + # The one gate that decides whether a launch may happen at all. The stub + # hands it to the real core rather than answering for it: a fixture that + # says yes to every model would test nothing. + os.execv(sys.executable, [sys.executable, os.environ["STUB_REAL_CORE"], *args]) +if args[:1] == ["worktree"]: + # Delegated work gets its own copy of the code, so the create path asks + # for one. The stub answers with a directory under the fixture's state so + # the launch is exercised end to end, and logs every call it is given. + log=os.environ.get("STUB_WORKTREE_LOG") + if log: + with open(log,"a",encoding="utf-8") as handle: + handle.write(json.dumps(args)+"\\n") + if len(args) > 1 and args[1] == "copy-check": + # Three answers, and the launcher must tell them apart. Exit 0 with a + # reason: do not copy this one. Exit 1: copying it is fine, the + # ordinary answer. Anything else: the check itself broke, which is not + # a pass -- STUB_COPY_CHECK_BROKEN makes it break. + if os.environ.get("STUB_COPY_CHECK_BROKEN"): + print("Traceback (most recent call last): simulated", file=sys.stderr) + raise SystemExit(70) + shared=os.environ.get("STUB_SHARED_REPO","") + if shared and shared in args: + print(f"{shared} carries a .session-kit-shared marker") + raise SystemExit(0) + raise SystemExit(1) + if len(args) > 1 and args[1] == "materialize": + options=dict(zip(args[2::2], args[3::2])) + branch=options.get("--branch","") + root=pathlib.Path(os.environ["SESSION_KIT_STATE_DIR"])/"worktrees"/"trees" + path=root/branch.replace("/","-") + path.mkdir(parents=True, exist_ok=True) + print(json.dumps({ + "path":str(path), + "branch":branch, + "repo":options.get("--repo",""), + "auto":"--auto" in args, + "created":True, + },sort_keys=True)) + raise SystemExit(0) + print("{}") + raise SystemExit(0) +if args[:2] == ["platform", "codex-refresh-target"] and len(args) == 6: + print("\t".join(( + os.environ.get("STUB_REFRESH_PID", args[4]), + os.environ.get("STUB_REFRESH_START", args[5]), + ))) + raise SystemExit(0) +if args[:2] == ["platform", "process-is"] and len(args) == 5: + if os.environ.get("STUB_PROCESS_IS_TRUE") == "1": + raise SystemExit(0) + pid=int(args[2]); generation=int(args[3]); executable=args[4] + try: + stat_text=pathlib.Path(f"/proc/{pid}/stat").read_text() + current=int(stat_text.rsplit(")",1)[1].split()[19]) + argv=pathlib.Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\\0") + actual=pathlib.Path(argv[0].decode("utf-8","replace")).name + except (OSError,ValueError,IndexError): + raise SystemExit(1) + raise SystemExit(0 if current == generation and actual == executable else 1) +if args[:1] == ["account"] and len(args) >= 2: + # Every account verb the automatic handoff calls, answered from fixtures + # and logged in call order. No real profile, feed or login is touched. + log=os.environ.get("STUB_ACCOUNT_LOG") + if log: + with open(log, "a") as handle: + handle.write(json.dumps(args)+"\\n") + verb=args[1] + if verb == "auto-plan": + print(os.environ.get("STUB_ACCOUNT_PLAN") or json.dumps({"action":"hold","reason":"no fixture plan","source_alias":"","target_alias":""})) + raise SystemExit(0) + if verb == "auto-begin": + if os.environ.get("STUB_ACCOUNT_BEGIN_FAILS") == "1": + sys.stderr.write("this conversation has already had its 1 automatic move\\n") + raise SystemExit(1) + print(os.environ.get("STUB_ACCOUNT_HOP_TOKEN", "b"*32)) + raise SystemExit(0) + if verb in ("auto-commit","auto-release"): + raise SystemExit(0) + if verb == "auto-target-ok": + if os.environ.get("STUB_ACCOUNT_TARGET_GONE") == "1": + print(json.dumps({"alias":args[3],"eligible":False,"reason":"it was switched off"})) + raise SystemExit(1) + print(json.dumps({"alias":args[3],"eligible":True,"reason":""})) + raise SystemExit(0) + if verb == "switch-prepare": + print(json.dumps({"txid":os.environ.get("STUB_ACCOUNT_TXID","0"*32)})) + raise SystemExit(0) + if verb == "switch-commit" and os.environ.get("STUB_ACCOUNT_COMMIT_FAILS") == "1": + raise SystemExit(1) + if verb in ("sync-ui","switch-commit","switch-rollback"): + print(json.dumps({"ok":True})) + raise SystemExit(0) + raise SystemExit(2) +if args[:1] == ["codex-bounce-title"] and len(args) == 2: + title=os.environ.get("STUB_CODEX_BOUNCE_TITLE", "") + if title: + print(title) + raise SystemExit(0) + raise SystemExit(1) +if args[:1] == ["claude-bounce-title"] and len(args) == 2: + if os.environ.get("STUB_CLAUDE_BOUNCE_CLEAR") == "1": + raise SystemExit(3) + title=os.environ.get("STUB_CLAUDE_BOUNCE_TITLE", "") + if title: + print(title) + raise SystemExit(0) + raise SystemExit(1) if args[:2] == ["automatic-title", "self-name"] and len(args) == 3: print(json.dumps({ "schema_version":1, @@ -378,8 +619,76 @@ def __init__(self) -> None: handle.write(json.dumps(args)+"\\n") print(json.dumps({"schema_version":1,"aliases":aliases},sort_keys=True)) raise SystemExit(0) +if args[:2] == ["color", "conversation-pick"] and len(args) == 4: + color_log=os.environ.get("STUB_COLOR_LOG") + if color_log: + with pathlib.Path(color_log).open("a",encoding="utf-8") as handle: + handle.write(json.dumps(args)+"\\n") + print(json.dumps({"schema_version":1,"color":"orange"},sort_keys=True)) + raise SystemExit(0) +if args[:2] in (["color", "set"], ["color", "delete"]): + action=args[1] + tail=args[2:] + if action == "set" and len(tail) == 3: + provider,uuid,color=tail + elif action == "delete" and len(tail) == 2: + provider,uuid=tail + color=None + else: + raise SystemExit(2) + if provider not in {"claude","codex"}: + raise SystemExit(2) + config=pathlib.Path(os.environ["SESSION_KIT_CONFIG"]) + data=json.loads(config.read_text()) + stored=dict(data.get("colors",{})) + key=f"{provider}:{uuid.lower()}" + if action == "set": + stored[key]=color + else: + stored.pop(key,None) + data["colors"]=stored + fd,tmp=tempfile.mkstemp(prefix=".config.",dir=config.parent) + with os.fdopen(fd,"w") as out: json.dump(data,out) + os.replace(tmp,config) + print(json.dumps({"schema_version":1,"colors":stored},sort_keys=True)) + raise SystemExit(0) +if args[:2] == ["color", "propagate"] and len(args) == 4: + color_log=os.environ.get("STUB_COLOR_LOG") + if color_log: + with pathlib.Path(color_log).open("a",encoding="utf-8") as handle: + handle.write(json.dumps(args)+"\\n") + if os.environ.get("STUB_COLOR_PROPAGATE_FAIL") == "1": + raise SystemExit(1) + print(json.dumps({"schema_version":1,"provider_color_pushes":[],"provider_color_warnings":[]},sort_keys=True)) + raise SystemExit(0) +if args[:2] == ["alias", "push"] and len(args) == 4: + name_log=os.environ.get("STUB_NAME_PUSH_LOG") + if name_log: + with pathlib.Path(name_log).open("a",encoding="utf-8") as handle: + handle.write(json.dumps(args)+"\\n") + if os.environ.get("STUB_ALIAS_PUSH_FAIL") == "1": + raise SystemExit(1) + if os.environ.get("STUB_ALIAS_PUSH_PARTIAL") == "1": + print(json.dumps({"schema_version":1,"title":"fixture name","provider_title_pushes":["codex-session-index"],"provider_title_warnings":["Codex thread row not found; thread title not set"]},sort_keys=True)) + raise SystemExit(3) + print(json.dumps({"schema_version":1,"title":"fixture name","provider_title_pushes":["claude-nameintent"],"provider_title_warnings":[]},sort_keys=True)) + raise SystemExit(0) if args and args[0] == "snapshot": dynamic_provider=os.environ.get("STUB_DYNAMIC_PROVIDER") + dynamic_status=os.environ.get("STUB_DYNAMIC_AGENT_STATUS", "working") + dynamic_count=0 + if dynamic_provider and any(os.environ.get(name) for name in ( + "STUB_DYNAMIC_AGENT_STATUS_AFTER_FIRST", + "STUB_DYNAMIC_ACCOUNT_ALIAS_AFTER_FIRST", + "STUB_DYNAMIC_ACCOUNT_MISMATCH_AFTER_FIRST", + )): + counter=pathlib.Path(os.environ["STUB_SNAPSHOT_COUNT"]) + try: dynamic_count=int(counter.read_text()) + except (OSError,ValueError): dynamic_count=0 + dynamic_count += 1 + counter.write_text(str(dynamic_count)) + if dynamic_count > 1 and os.environ.get("STUB_DYNAMIC_AGENT_STATUS_AFTER_FIRST"): + dynamic_status=os.environ["STUB_DYNAMIC_AGENT_STATUS_AFTER_FIRST"] if dynamic_provider and os.environ.get("STUB_DYNAMIC_AFTER_SESSIONS"): current_state=json.loads(pathlib.Path(os.environ["FAKE_SHPOOL_STATE"]).read_text()) if len(current_state.get("sessions",[])) <= int(os.environ["STUB_DYNAMIC_AFTER_SESSIONS"]): @@ -389,6 +698,20 @@ def __init__(self) -> None: rows=[] for index,item in enumerate(state.get("sessions",[]),1): name=item["name"] + skip_name_file=os.environ.get("STUB_DYNAMIC_SKIP_NAME_FILE") + if (skip_name_file and pathlib.Path(skip_name_file).is_file() + and pathlib.Path(skip_name_file).read_text() == name): + continue + gone_pid=os.environ.get("FAKE_KILL_PROC_PID") + exact_log=os.environ.get("SESSION_KIT_TEST_EXACT_SIGNAL_LOG") + gone_name=os.environ.get("STUB_DYNAMIC_SKIP_NAME_AFTER_SIGNAL") + proc_root=os.environ.get("SESSION_KIT_PROC_ROOT") + exact_gone=( + bool(exact_log and pathlib.Path(exact_log).is_file()) + or bool(gone_pid and proc_root and not (pathlib.Path(proc_root)/gone_pid).exists()) + ) + if ((gone_pid == str(1000 + index) or gone_name == name) and exact_gone): + continue if os.environ.get("STUB_CONSUME_ARMED_LAUNCH") == "1": start=pathlib.Path(os.environ["SESSION_KIT_START_DIR"])/name expected=start.with_name(start.name+".expected") @@ -408,14 +731,46 @@ def __init__(self) -> None: "shpool_status":item["status"], "availability":"ready", "provider":dynamic_provider, - "identity":{"uuid":uuid_overrides.get(name,os.environ.get("STUB_DYNAMIC_UUID")),"pid":2000+index,"process_start_ticks":20000+index,"confidence":"exact"}, + "identity":{"uuid":uuid_overrides.get(name,os.environ.get("STUB_DYNAMIC_UUID")),"pid":int(os.environ.get("STUB_DYNAMIC_PROVIDER_PID") or 2000+index),"process_start_ticks":int(os.environ.get("STUB_DYNAMIC_PROVIDER_START") or 20000+index),"confidence":"exact"}, "title":"dynamic proof", "native_title":"dynamic proof", "cwd":os.environ.get("STUB_DYNAMIC_CWD","/srv/project"), "process_age_seconds":1, - "agent_status":"working", + "agent_status":dynamic_status, + "model":os.environ.get("STUB_DYNAMIC_MODEL",""), + # A real snapshot carries the stamp beside what the row IS, and + # only where a stamp exists. STUB_DYNAMIC_ORIGIN asks for a + # STAMPED session, so the stub writes both. + # STUB_DYNAMIC_ORIGIN_INFERRED asks for the other kind: a row + # that reads as a machine's with no stamp behind it, which is + # what an unstamped App Server looks like the moment its window + # is gone. + "origin":os.environ.get( + "STUB_DYNAMIC_ORIGIN", + os.environ.get("STUB_DYNAMIC_ORIGIN_INFERRED", "human"), + ), + **({"origin_recorded":os.environ["STUB_DYNAMIC_ORIGIN"]} + if os.environ.get("STUB_DYNAMIC_ORIGIN") else {}), + "model_handoff_capable":os.environ.get("STUB_DYNAMIC_MODEL_HANDOFF","1") == "1", "needs_you":False, "subagents":[], + # An account switch is not instant: the request file is written, + # the managed shell consumes it, and only then does the row + # report the new account. AFTER_REQUEST stands in for that shell + # so the wait-for-the-new-alias step can actually be exercised. + "account_alias":( + os.environ["STUB_DYNAMIC_ACCOUNT_ALIAS_AFTER_REQUEST"] + if os.environ.get("STUB_DYNAMIC_ACCOUNT_ALIAS_AFTER_REQUEST") + and (pathlib.Path(os.environ.get("SESSION_KIT_STATE_DIR","/nonexistent"))/"account-switch-requests"/name).exists() + else os.environ["STUB_DYNAMIC_ACCOUNT_ALIAS_AFTER_FIRST"] + if dynamic_count > 1 and os.environ.get("STUB_DYNAMIC_ACCOUNT_ALIAS_AFTER_FIRST") + else os.environ.get("STUB_DYNAMIC_ACCOUNT_ALIAS","") + ), + "account_binding_mismatch":( + dynamic_count > 1 + and os.environ.get("STUB_DYNAMIC_ACCOUNT_MISMATCH_AFTER_FIRST") == "1" + ), + "account_switch_capable":os.environ.get("STUB_DYNAMIC_ACCOUNT_CAPABLE","0") == "1", "recovery":{"available":False}, "diagnostics":[], }) @@ -464,6 +819,57 @@ def __init__(self) -> None: if "--guard-live" in args and os.environ.get("STUB_GUARD_FAIL") == "1": raise SystemExit(3) print(json.dumps(document)) +elif args[:1] == ["validate-worker-model"] and len(args) == 3: + import re as _re + provider, model = args[1], args[2] + if _re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", model): + print(model) + else: + raise SystemExit(1) +elif args[:2] == ["closed-sessions", "record"]: + with open(os.environ["STUB_CLOSED_LOG"], "a") as handle: + handle.write(json.dumps(args)+"\\n") + print(json.dumps({"recorded": True})) +elif args[:2] == ["closed-sessions", "forget"]: + with open(os.environ["STUB_CLOSED_LOG"], "a") as handle: + handle.write(json.dumps(args)+"\\n") + print(json.dumps({"forgotten": 1})) +elif args[:2] == ["recovery-pending", "list"] or args[:1] == ["recovery-selectors"]: + # Delegated to the real core on purpose. This list is the one projection + # both recovery surfaces read, and what a screen printed beside each row is + # how a restore knows the word it was handed still means that row, so a + # stub reimplementation here would test the stub. The fixture already owns + # every store either of them reads. + import subprocess + # sys.executable is empty under this stub's shebang, so name the + # interpreter rather than borrowing one that is not there. + real=os.environ["STUB_REAL_INVENTORY_CORE"] + raise SystemExit(subprocess.run(["python3", real, *args]).returncode) +elif args[:2] == ["closed-sessions", "list"]: + payload=os.environ.get("STUB_CLOSED_LIST") + print(payload if payload else json.dumps({"closed": []})) +elif args[:2] == ["closed-sessions", "stream"]: + payload=os.environ.get("STUB_CLOSED_LIST") + document=json.loads(payload) if payload else {"closed": []} + for row in document.get("closed") or []: + print(json.dumps(row)) +elif args[:2] == ["origin", "record"] and len(args) == 4: + with open(os.environ["STUB_ORIGIN_LOG"], "a") as handle: + handle.write(json.dumps(args[2:])+"\\n") + with open(os.environ["STUB_ORIGIN_INSTANCE_LOG"], "a") as handle: + handle.write(json.dumps({ + "shell_pid":os.environ.get("SESSION_KIT_ORIGIN_SHELL_PID"), + "shell_start_ticks":os.environ.get("SESSION_KIT_ORIGIN_SHELL_START_TICKS"), + "started_at_unix_ms":os.environ.get("SESSION_KIT_ORIGIN_STARTED_AT_UNIX_MS"), + },sort_keys=True)+"\\n") + print(json.dumps({"recorded": True})) +elif args and args[0] == "close-intent": + with open(os.environ["STUB_CLOSE_INTENT_LOG"], "a") as handle: + handle.write(json.dumps(args)+"\\n") + if os.environ.get("STUB_CLOSE_INTENT_FAIL") == "1": + print(json.dumps({"recorded": False, "reason": "injected ledger failure"})) + raise SystemExit(1) + print(json.dumps({"recorded": True})) elif args and args[0] == "render": print("fixture renderer") elif args and args[0] == "waiting-count": @@ -479,6 +885,8 @@ def close(self) -> None: def env(self) -> dict[str, str]: return { "HOME": str(self.home), + "XDG_STATE_HOME": str(self.home / ".local" / "state"), + "XDG_DATA_HOME": str(self.home / ".local" / "share"), "SESSION_KIT_STATE_DIR": str(self.state), "SESSION_KIT_JOURNAL_DIR": str(self.journals), "SESSION_KIT_ARCHIVE_DIR": str(self.archives), @@ -488,6 +896,7 @@ def env(self) -> dict[str, str]: "SESSION_KIT_CONFIG": str(self.config), "SESSION_KIT_SHPOOL_CMD": str(self.fake_shpool), "SESSION_KIT_INVENTORY_CORE": str(self.fake_core), + "STUB_REAL_INVENTORY_CORE": str(REPO / "lib" / "session_inventory.py"), "SESSION_KIT_NONINTERACTIVE": "1", "SESSION_KIT_NO_COLOR": "1", "SESSION_KIT_RELEASE_ID": self.release_id, @@ -496,6 +905,18 @@ def env(self) -> dict[str, str]: "FAKE_SHPOOL_LOG": str(self.shpool_log), "STUB_INVENTORY": str(self.inventory), "STUB_SNAPSHOT_COUNT": str(self.snapshot_count), + "STUB_CLOSE_INTENT_LOG": str(self.close_intent_log), + "STUB_WORKTREE_LOG": str(self.worktree_log), + "STUB_REAL_CORE": str(REPO / "lib" / "session_inventory.py"), + # A delegated session now cuts a worktree without being asked, and + # this sandbox sits inside a checkout. Pin the root so nothing a + # test starts can ever materialize a worktree into a real + # repository's metadata, whichever core answers the call. + "SESSION_KIT_WORKTREE_ROOT": str(self.base / "worktrees"), + "STUB_ORIGIN_LOG": str(self.origin_log), + "STUB_ORIGIN_INSTANCE_LOG": str(self.origin_instance_log), + "STUB_CLOSED_LOG": str(self.closed_log), + "STUB_NAME_PUSH_LOG": str(self.name_push_log), "PYTHONDONTWRITEBYTECODE": "1", } @@ -535,6 +956,205 @@ def test_concurrent_create_allocates_unique_unbounded_ids_and_list_is_safe(self) listed = run([SP, "list"], env=self.fixture.env()) self.assertEqual("fixture renderer\n", listed.stdout) + def test_every_selector_verb_refuses_a_wrong_number_the_same_way(self) -> None: + """One message, one status, whichever verb typed it. + + Nine verbs used to answer "no unique session matches that selection" + with status 1 while `sp detail` answered a different sentence with + status 2, and `sp repair` echoed the number back. + """ + env = self.fixture.env() + for argv in ( + ["go", "999"], + ["close", "999"], + ["takeover", "999"], + ["history", "999"], + ["name", "999", "x"], + ["name", "reset", "999"], + ["color", "999", "red"], + ["color", "reset", "999"], + ["verify-start", "999"], + ["teardown", "999"], + ["repair", "999"], + ): + with self.subTest(verb=argv[0]): + refused = run([SP, *argv], env=env, check=False) + self.assertEqual(2, refused.returncode) + self.assertEqual( + "session-kit: no session matches that selector\n", + refused.stderr, + ) + self.assertNotIn("999", refused.stderr) + + def test_a_wrong_argument_count_says_what_was_wrong_first(self) -> None: + """`usage:` alone named the shape of the command, never the mistake.""" + env = self.fixture.env() + refused = run([SP, "close"], env=env, check=False) + self.assertEqual(2, refused.returncode) + self.assertTrue( + refused.stderr.startswith("session-kit: sp close takes one session"), + refused.stderr, + ) + self.assertIn("usage: sp <command>", refused.stderr) + + def test_an_unknown_account_command_never_answers_in_pythons_voice(self) -> None: + """argparse used to answer here: a sixth error prefix, an internal + file name, and the seventeen machine subcommands `sp help` withholds + on purpose.""" + env = self.fixture.env() + for group, machine_verb in (("account", "choices"), ("worktree", "lookup")): + with self.subTest(group=group): + refused = run([SP, group, "bogus"], env=env, check=False) + self.assertEqual(2, refused.returncode) + self.assertIn( + f"session-kit: no {group} command named bogus", refused.stderr + ) + self.assertNotIn("session_inventory.py", refused.stderr) + self.assertNotIn("usage:", refused.stderr) + self.assertNotIn(machine_verb, refused.stderr) + + def test_no_verb_asks_a_question(self) -> None: + """No `[y/N]`, no typed words, no confirmation step, anywhere.""" + banned = ("[y/N]", "[Y/n]", "Confirm?", "read -r -n 1 -p") + for name in ( + "bin/sp", + "bin/session_kit_common", + "lib/sh/sp_commands.sh", + "lib/sh/sp_sessions.sh", + "lib/sh/sp_core.sh", + ): + text = (REPO / name).read_text(encoding="utf-8") + for phrase in banned: + with self.subTest(file=name, phrase=phrase): + self.assertNotIn(phrase, text) + + def test_shipped_production_tree_has_no_name_addressed_shpool_kill(self) -> None: + """No automated surface may select a victim by a reusable name.""" + offenders: list[str] = [] + for root in (REPO / "bin", REPO / "lib", REPO / "bashrc"): + for path in root.rglob("*"): + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8", errors="replace") + if "shpool kill" in text or '"$SK_SHPOOL" kill' in text: + offenders.append(str(path.relative_to(REPO))) + self.assertEqual([], offenders) + + def test_stale_name_reallocation_moves_every_staged_launch_record(self) -> None: + old_id = "stale-create-name" + staged = { + "": "start record\n", + ".account": "account record\n", + ".launch": "launch record\n", + ".prompt": "prompt record\n", + } + self.fixture.start.mkdir() + for suffix, content in staged.items(): + (self.fixture.start / f"{old_id}{suffix}").write_text( + content, encoding="utf-8" + ) + self.fixture.shpool_state.write_text( + json.dumps( + { + "sessions": [ + {"name": old_id, "status": "Disconnected"} + ] + } + ), + encoding="utf-8", + ) + reallocated = run( + [ + "bash", + "--noprofile", + "--norc", + "-c", + 'source "$1"; sk_reallocate_stale_launch_name "$2"', + "stale-record-reallocation", + REPO / "bin" / "session_kit_common", + old_id, + ], + env=self.fixture.env(), + check=False, + ) + self.assertEqual(0, reallocated.returncode, reallocated.stderr) + new_id = reallocated.stdout.strip() + self.assertNotEqual(old_id, new_id) + self.assertIn(f"stale shpool entry {old_id}", reallocated.stderr) + self.assertIn(f"allocated fresh session name {new_id}", reallocated.stderr) + for suffix, content in staged.items(): + self.assertFalse((self.fixture.start / f"{old_id}{suffix}").exists()) + self.assertEqual( + content, + (self.fixture.start / f"{new_id}{suffix}").read_text( + encoding="utf-8" + ), + ) + state = json.loads(self.fixture.shpool_state.read_text(encoding="utf-8")) + self.assertEqual([old_id], [row["name"] for row in state["sessions"]]) + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_new_allocates_again_when_a_stale_entry_wins_the_attach_race(self) -> None: + marker = self.fixture.base / "new-name-collision" + env = self.fixture.env() + env.update( + { + "SESSION_KIT_BACKGROUND": "1", + "STUB_DYNAMIC_PROVIDER": "shell", + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "FAKE_CREATE_COLLISION_ONCE": str(marker), + } + ) + started = run([SP, "new", "shell", "fixture"], env=env, check=False) + self.assertEqual(0, started.returncode, started.stderr) + collided = marker.read_text(encoding="utf-8") + created = started.stdout.strip().splitlines()[-1] + self.assertNotEqual(collided, created) + self.assertIn(f"stale shpool entry {collided}", started.stderr) + self.assertIn(f"allocated fresh session name {created}", started.stderr) + self.assertEqual( + [f"collision {collided}", f"attach {created}"], + self.fixture.shpool_log.read_text(encoding="utf-8").splitlines(), + ) + state = json.loads(self.fixture.shpool_state.read_text(encoding="utf-8")) + self.assertEqual( + {collided, created}, {row["name"] for row in state["sessions"]} + ) + + def test_restore_allocates_again_when_a_stale_entry_wins_the_attach_race(self) -> None: + marker = self.fixture.base / "restore-name-collision" + exact_uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + env = self.fixture.env() + env.update( + { + "SESSION_KIT_BACKGROUND": "1", + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + "STUB_DYNAMIC_PROVIDER": "claude", + "STUB_DYNAMIC_UUID": exact_uuid, + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "FAKE_CREATE_COLLISION_ONCE": str(marker), + } + ) + restored = run( + [SP, "restore-exact", "claude", exact_uuid, self.fixture.project], + env=env, + check=False, + ) + self.assertEqual(0, restored.returncode, restored.stderr) + collided = marker.read_text(encoding="utf-8") + created = restored.stdout.strip().splitlines()[-1] + self.assertNotEqual(collided, created) + self.assertIn(f"stale shpool entry {collided}", restored.stderr) + self.assertIn(f"allocated fresh session name {created}", restored.stderr) + self.assertEqual( + [f"collision {collided}", f"attach {created}"], + self.fixture.shpool_log.read_text(encoding="utf-8").splitlines(), + ) + state = json.loads(self.fixture.shpool_state.read_text(encoding="utf-8")) + self.assertEqual( + {collided, created}, {row["name"] for row in state["sessions"]} + ) + def test_release_bound_integration_marker_fails_closed_until_exact_match(self) -> None: marker = self.fixture.state / "integration-ready-v1" env = self.fixture.env() @@ -675,6 +1295,23 @@ def test_integration_marker_rejects_trailing_bytes(self) -> None: self.assertIn("validated for this release", refused.stderr) self.assertFalse(self.fixture.shpool_log.exists()) + def test_ignored_directory_is_never_a_launch_target(self) -> None: + self.fixture.projects.write_text( + f"fixture\tignore\t{self.fixture.project}\n", encoding="utf-8" + ) + env = self.fixture.env() + env.update( + { + "SESSION_KIT_BACKGROUND": "1", + "STUB_DYNAMIC_PROVIDER": "shell", + "STUB_DYNAMIC_CWD": str(self.fixture.project), + } + ) + refused = run([SP, "new", "shell", "fixture"], env=env, check=False) + self.assertNotEqual(0, refused.returncode) + self.assertIn("unknown or invalid project", refused.stderr) + self.assertFalse(self.fixture.shpool_log.exists()) + def test_ai_startup_proof_failure_keeps_session_and_record_without_kill(self) -> None: env = self.fixture.env() env.update( @@ -687,7 +1324,9 @@ def test_ai_startup_proof_failure_keeps_session_and_record_without_kill(self) -> ) failed = run([SP, "new", "claude", "fixture"], env=env, check=False) self.assertNotEqual(0, failed.returncode) - self.assertIn("startup was not proven", failed.stderr) + self.assertIn( + "could not confirm that Claude started", failed.stderr + ) state = json.loads(self.fixture.shpool_state.read_text()) self.assertEqual(1, len(state["sessions"])) shpool_id = state["sessions"][0]["name"] @@ -784,7 +1423,8 @@ def test_verify_start_reproves_and_clears_a_retained_exact_record(self) -> None: env["STUB_DYNAMIC_PROVIDER"] = "claude" verified = run([SP, "verify-start", shpool_id], env=env) - self.assertIn(f"cleared retained launch record for {shpool_id}", verified.stdout) + self.assertIn("cleared the retained launch record for", verified.stdout) + self.assertNotIn(shpool_id, verified.stdout) self.assertFalse(record.exists()) self.assertFalse(expected.exists()) self.assertFalse(any( @@ -814,7 +1454,9 @@ def test_verify_start_refuses_sidecar_from_previous_boot(self) -> None: env["STUB_DYNAMIC_PROVIDER"] = "claude" refused = run([SP, "verify-start", shpool_id], env=env, check=False) self.assertNotEqual(0, refused.returncode) - self.assertIn("exact provider is not active yet", refused.stderr) + self.assertIn( + "Claude is not running in that session yet", refused.stderr + ) self.assertTrue(record.is_file()) self.assertTrue(expected.is_file()) @@ -839,7 +1481,7 @@ def test_restore_same_provider_duplicate_skips_null_shell_then_refuses_outside_u check=False, ) self.assertNotEqual(0, refused.returncode) - self.assertIn("is already active", refused.stderr) + self.assertIn("that conversation is already open", refused.stderr) self.assertFalse(self.fixture.shpool_log.exists()) self.assertFalse(self.fixture.start.exists()) @@ -893,6 +1535,7 @@ def test_new_codex_session_is_proven_by_its_process_not_a_rollout(self) -> None: "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "2", "STUB_DYNAMIC_PROVIDER": "unknown", "STUB_DYNAMIC_CWD": str(self.fixture.project), + "SESSION_KIT_TESTING": "1", "SESSION_KIT_PROVIDER_PRESENCE_OVERRIDE": "present", } ) @@ -903,251 +1546,285 @@ def test_new_codex_session_is_proven_by_its_process_not_a_rollout(self) -> None: self.assertFalse((self.fixture.start / shpool_id).exists()) self.assertFalse((self.fixture.start / f"{shpool_id}.expected").exists()) - def test_new_session_is_refused_when_the_provider_never_started(self) -> None: - """Relaxing the identity check must not accept an empty shell.""" + def test_new_claude_fallback_reserves_exact_color_before_propagation(self) -> None: + exact_uuid = "00000000-0000-4000-8000-000000000019" + color_log = self.fixture.base / "color.log" env = self.fixture.env() env.update( { "SESSION_KIT_BACKGROUND": "1", + "SESSION_KIT_NO_PREBAKE": "1", "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "2", - "STUB_DYNAMIC_PROVIDER": "unknown", + "STUB_DYNAMIC_PROVIDER": "claude", + "STUB_DYNAMIC_UUID": exact_uuid, "STUB_DYNAMIC_CWD": str(self.fixture.project), - "SESSION_KIT_PROVIDER_PRESENCE_OVERRIDE": "absent", + "STUB_COLOR_LOG": str(color_log), } ) - refused = run( - [SP, "new", "codex"], env=env, check=False, cwd=self.fixture.project - ) - self.assertNotEqual(0, refused.returncode) - self.assertIn("startup was not proven", refused.stderr) - def test_resume_still_requires_its_exact_conversation(self) -> None: - """The relaxation is for new sessions only. + started = run([SP, "new", "claude"], env=env, cwd=self.fixture.project) - A resume that accepted a process instead of its exact UUID could bring - back the wrong conversation. - """ - env = self.fixture.env() - env.update( - { - "SESSION_KIT_BACKGROUND": "1", - "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "2", - "STUB_DYNAMIC_PROVIDER": "unknown", - "STUB_DYNAMIC_CWD": str(self.fixture.project), - "SESSION_KIT_PROVIDER_PRESENCE_OVERRIDE": "present", - } + self.assertRegex( + started.stdout.strip().splitlines()[-1], + r"^s[0-9]{8}-[0-9]{6}-[0-9]+$", ) - refused = run( + self.assertEqual( [ - SP, - "restore-exact", - "codex", - "00000000-0000-4000-8000-000000000009", - self.fixture.project, + ["color", "conversation-pick", "claude", exact_uuid], + ["color", "propagate", "claude", exact_uuid], ], - env=env, - check=False, + [json.loads(line) for line in color_log.read_text().splitlines()], ) - self.assertNotEqual(0, refused.returncode) - self.assertIn("recovery was not proven", refused.stderr) - def test_repair_closes_the_dead_session_then_restores_its_conversation(self) -> None: - """`sp repair` is the unattended half of the wedge fix. + def test_a_deployed_title_template_reaches_the_codex_command_line(self) -> None: + """The whole shell wiring, not just the parser inside it. - shpool can lose the thread that serves a session's terminal. The - session then cannot be attached again and whatever runs inside it - blocks on its next write for ever. Repair ends that session and brings - the same conversation up in a new one. + The fallback case is covered by the fork test above; this proves a + template the installer deployed actually decides what a launched Codex + session puts on the tab. """ - exact_uuid = "00000000-0000-4000-8000-000000000001" - row = session_row("wedged") - self.fixture.inventory.write_text( - json.dumps(inventory_document(row)), encoding="utf-8" + (self.fixture.home / ".no_shpool_journal").write_text("", encoding="utf-8") + source_uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + deployed = self.fixture.home / ".codex" / "session-kit" + deployed.mkdir(parents=True) + (deployed / "terminal-title.toml").write_text( + '[tui]\nterminal_title = ["thread", "project"]\n', encoding="utf-8" ) - self.fixture.shpool_state.write_text( - json.dumps( - { - "sessions": [ - { - "name": "wedged", - "status": "Disconnected", - "started_at_unix_ms": row["started_at_unix_ms"], - } - ] - } - ), + if self.fixture.start.exists(): + shutil.rmtree(self.fixture.start) + self.fixture.start.mkdir() + record = self.fixture.start / "fork-session" + record.write_text( + f"codex\t{self.fixture.project}\t{source_uuid}\tfork\n", encoding="utf-8", ) + fake_bin = self.fixture.base / "codex-template-bin" + fake_bin.mkdir() + provider_log = self.fixture.base / "codex-template.log" + write_executable( + fake_bin / "codex", + '#!/usr/bin/env bash\nprintf "%s\\n" "$*" > "$PROVIDER_LAUNCH_LOG"\n', + ) env = self.fixture.env() env.update( { - "SESSION_KIT_BACKGROUND": "1", - "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", - "STUB_DYNAMIC_PROVIDER": "codex", - "STUB_DYNAMIC_UUID": exact_uuid, - "STUB_DYNAMIC_CWD": str(self.fixture.project), - "FAKE_EXPECT_KILL_LOCK": "locked", + "SHPOOL_SESSION_NAME": "fork-session", + "SHPOOL_JOURNAL": "", + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "PROVIDER_LAUNCH_LOG": str(provider_log), } ) - repaired = run([SP, "repair", "wedged"], env=env) - log = self.fixture.shpool_log.read_text() - # The dead session is ended before anything is launched, and the - # replacement is created detached so it waits in the session list. - self.assertIn("kill wedged\n", log) - self.assertLess(log.index("kill wedged"), log.index("attach")) - new_id = repaired.stdout.strip().splitlines()[-1] - self.assertRegex(new_id, r"^s[0-9]{8}-[0-9]{6}-[0-9]+$") - self.assertNotEqual("wedged", new_id) - - def test_repair_refuses_a_session_with_no_conversation_to_move(self) -> None: - self.fixture.inventory.write_text( - json.dumps(inventory_document(unknown_session_row("halfstarted"))), - encoding="utf-8", + launched = run( + [ + "bash", + "-c", + """ +bash --noprofile --norc -ic ' + shell_start=$(awk "{print \\$22}" /proc/$$/stat) + daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat) + printf "%s\\t%s\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\t%s\\tfork\\n" \ + "$4" "$2" "$$" "$shell_start" "$PPID" "$daemon_start" "$5" > "$3/fork-session.expected" + source "$1" +' fork-launch-inner "$1" "$2" "$3" "$4" "$5" +""", + "codex-template-test", + BASHRC, + self.fixture.project, + self.fixture.start, + "codex", + source_uuid, + ], + env=env, + ) + self.assertEqual(0, launched.returncode, launched.stderr) + self.assertIn( + '-c tui.terminal_title=["thread", "project"]', provider_log.read_text() ) - refused = run([SP, "repair", "halfstarted"], env=self.fixture.env(), check=False) - self.assertNotEqual(0, refused.returncode) - self.assertFalse(self.fixture.shpool_log.exists()) - - def test_restore_is_not_blocked_by_an_unrelated_unknown_session(self) -> None: - """A half-started session must not be able to block recovery. - Recovery previously demanded a perfect inventory, so any session that - had not yet published a provider identity -- a shell opened seconds - ago, or one sitting on a provider's startup prompt -- made every - restore refuse. That turned an unrelated row into an outage during the - exact moment recovery is needed. The duplicate-conversation guard is - what protects this operation, and it is covered separately by - test_restore_same_provider_duplicate_skips_null_shell_then_refuses_outside_uuid. - """ - exact_uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" - self.fixture.inventory.write_text( - json.dumps(inventory_document(unknown_session_row())), + def test_tab_title_kill_switch_is_case_insensitive_on_codex_launch(self) -> None: + """The launch path must agree with the writer and doctor about `off`.""" + (self.fixture.home / ".no_shpool_journal").write_text("", encoding="utf-8") + source_uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + fake_bin = self.fixture.base / "codex-title-off-bin" + fake_bin.mkdir() + provider_log = self.fixture.base / "codex-title-off.log" + write_executable( + fake_bin / "codex", + '#!/usr/bin/env bash\nprintf "%s\\n" "$*" > "$PROVIDER_LAUNCH_LOG"\n', + ) + for value in ("off", "OFF", "Off", " off "): + with self.subTest(value=value): + if self.fixture.start.exists(): + shutil.rmtree(self.fixture.start) + self.fixture.start.mkdir() + (self.fixture.start / "fork-session").write_text( + f"codex\t{self.fixture.project}\t{source_uuid}\tfork\n", + encoding="utf-8", + ) + env = self.fixture.env() + env.update( + { + "SHPOOL_SESSION_NAME": "fork-session", + "SHPOOL_JOURNAL": "", + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "PROVIDER_LAUNCH_LOG": str(provider_log), + "SESSION_KIT_TAB_TITLE": value, + } + ) + launched = run( + [ + "bash", + "-c", + """ +bash --noprofile --norc -ic ' + shell_start=$(awk "{print \\$22}" /proc/$$/stat) + daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat) + printf "%s\\t%s\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\t%s\\tfork\\n" \ + "$4" "$2" "$$" "$shell_start" "$PPID" "$daemon_start" "$5" > "$3/fork-session.expected" + source "$1" +' fork-launch-inner "$1" "$2" "$3" "$4" "$5" +""", + "codex-title-off-test", + BASHRC, + self.fixture.project, + self.fixture.start, + "codex", + source_uuid, + ], + env=env, + ) + self.assertEqual(0, launched.returncode, launched.stderr) + launch_line = provider_log.read_text(encoding="utf-8") + self.assertNotIn("tui.terminal_title=", launch_line) + self.assertIn(f"--no-alt-screen fork {source_uuid}", launch_line) + + def test_a_broken_title_template_falls_back_instead_of_breaking_the_launch( + self, + ) -> None: + """`["thread]` is safe-looking text and unterminated TOML.""" + (self.fixture.home / ".no_shpool_journal").write_text("", encoding="utf-8") + source_uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + deployed = self.fixture.home / ".codex" / "session-kit" + deployed.mkdir(parents=True) + (deployed / "terminal-title.toml").write_text( + '[tui]\nterminal_title = ["thread]\n', encoding="utf-8" + ) + if self.fixture.start.exists(): + shutil.rmtree(self.fixture.start) + self.fixture.start.mkdir() + record = self.fixture.start / "fork-session" + record.write_text( + f"codex\t{self.fixture.project}\t{source_uuid}\tfork\n", encoding="utf-8", ) + fake_bin = self.fixture.base / "codex-broken-bin" + fake_bin.mkdir() + provider_log = self.fixture.base / "codex-broken.log" + write_executable( + fake_bin / "codex", + '#!/usr/bin/env bash\nprintf "%s\\n" "$*" > "$PROVIDER_LAUNCH_LOG"\n', + ) env = self.fixture.env() env.update( { - "SESSION_KIT_BACKGROUND": "1", - "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", - "STUB_DYNAMIC_PROVIDER": "codex", - "STUB_DYNAMIC_AFTER_SESSIONS": "0", - "STUB_DYNAMIC_UUID": exact_uuid, - "STUB_DYNAMIC_CWD": str(self.fixture.project), + "SHPOOL_SESSION_NAME": "fork-session", + "SHPOOL_JOURNAL": "", + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "PROVIDER_LAUNCH_LOG": str(provider_log), } ) - restored = run( - [SP, "restore-exact", "codex", exact_uuid, self.fixture.project], + launched = run( + [ + "bash", + "-c", + """ +bash --noprofile --norc -ic ' + shell_start=$(awk "{print \\$22}" /proc/$$/stat) + daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat) + printf "%s\\t%s\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\t%s\\tfork\\n" \ + "$4" "$2" "$$" "$shell_start" "$PPID" "$daemon_start" "$5" > "$3/fork-session.expected" + source "$1" +' fork-launch-inner "$1" "$2" "$3" "$4" "$5" +""", + "codex-broken-test", + BASHRC, + self.fixture.project, + self.fixture.start, + "codex", + source_uuid, + ], env=env, ) - self.assertNotIn("strict live snapshot unavailable", restored.stderr) - self.assertTrue(self.fixture.shpool_log.exists()) - shpool_id = restored.stdout.strip().splitlines()[-1] - self.assertRegex(shpool_id, r"^s[0-9]{8}-[0-9]{6}-[0-9]+$") + self.assertEqual(0, launched.returncode, launched.stderr) + launch_line = provider_log.read_text() + self.assertIn('-c tui.terminal_title=["activity", "thread"]', launch_line) + self.assertNotIn('["thread]', launch_line) - def test_stale_expected_namespace_forces_allocator_to_next_exact_id(self) -> None: - fake_bin = self.fixture.base / "allocator-bin" - fake_bin.mkdir() - stale_content = b"stale-sidecar-reservation\n" - write_executable( - fake_bin / "date", - """#!/usr/bin/env bash -if [[ ${1:-} == +%Y%m%d-%H%M%S ]]; then - mkdir -p "$SESSION_KIT_START_DIR" - owner_pid=$(awk '{print $4}' "/proc/$PPID/stat") - printf 'stale-sidecar-reservation\\n' > "$SESSION_KIT_START_DIR/s20260728-010203-$owner_pid.expected" - printf '20260728-010203\\n' - exit 0 -fi -exec /bin/date "$@" -""", - ) + def test_new_session_is_refused_when_the_provider_never_started(self) -> None: + """Relaxing the identity check must not accept an empty shell.""" env = self.fixture.env() env.update( { "SESSION_KIT_BACKGROUND": "1", - "STUB_DYNAMIC_PROVIDER": "shell", + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "2", + "STUB_DYNAMIC_PROVIDER": "unknown", "STUB_DYNAMIC_CWD": str(self.fixture.project), - "PATH": f"{fake_bin}:{os.environ['PATH']}", + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_PROVIDER_PRESENCE_OVERRIDE": "absent", } ) - created = run([SP, "new", "shell", "fixture"], env=env) - shpool_id = created.stdout.strip().splitlines()[-1] - self.assertRegex(shpool_id, r"^s20260728-010203-[0-9]+-1$") - reserved = self.fixture.start / f"{shpool_id.removesuffix('-1')}.expected" - self.assertEqual(stale_content, reserved.read_bytes()) - self.assertTrue((self.fixture.start / shpool_id).is_file()) - self.assertTrue((self.fixture.start / f"{shpool_id}.expected").is_file()) + refused = run( + [SP, "new", "codex"], env=env, check=False, cwd=self.fixture.project + ) + self.assertNotEqual(0, refused.returncode) + self.assertIn( + "could not confirm that Codex started", refused.stderr + ) - def test_start_writer_refuses_existing_expected_namespace(self) -> None: - self.fixture.start.mkdir() - expected = self.fixture.start / "collision.expected" - expected.write_bytes(b"existing-sidecar\n") + def test_resume_still_requires_its_exact_conversation(self) -> None: + """The relaxation is for new sessions only. + + A resume that accepted a process instead of its exact UUID could bring + back the wrong conversation. + """ + env = self.fixture.env() + env.update( + { + "SESSION_KIT_BACKGROUND": "1", + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "2", + "STUB_DYNAMIC_PROVIDER": "unknown", + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_PROVIDER_PRESENCE_OVERRIDE": "present", + } + ) refused = run( [ - "bash", - "-c", - 'source "$1"; sk_write_start_record collision shell "$2"', - "writer-collision-test", - COMMON, + SP, + "restore-exact", + "codex", + "00000000-0000-4000-8000-000000000009", self.fixture.project, ], - env=self.fixture.env(), + env=env, check=False, ) self.assertNotEqual(0, refused.returncode) - self.assertFalse((self.fixture.start / "collision").exists()) - self.assertEqual(b"existing-sidecar\n", expected.read_bytes()) - - def test_launch_records_accept_legacy_and_reject_multiline_or_extra_fields(self) -> None: - self.fixture.start.mkdir() - source_uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" - start = self.fixture.start / "launch" - expected = self.fixture.start / "launch.expected" - legacy_start = f"claude\t{self.fixture.project}\t{source_uuid}\n" - legacy_expected = ( - f"claude\t{self.fixture.project}\tfixture-boot\t1700000000001\t" - f"1001\t10010\t10\t100\t{source_uuid}\n" + self.assertIn( + "could not confirm that Codex reopened the conversation", + refused.stderr, ) - fork_start = legacy_start.rstrip("\n") + "\tfork\n" - fork_expected = legacy_expected.rstrip("\n") + "\tfork\n" - - def read_record() -> subprocess.CompletedProcess[str]: - return run( - [ - "bash", - "-c", - 'source "$1"; sk_read_start_expectation launch && ' - 'printf "%s\\n" "$SK_EXPECT_LAUNCH_MODE"', - "launch-record-test", - COMMON, - ], - env=self.fixture.env(), - check=False, - ) - - start.write_text(legacy_start, encoding="utf-8") - expected.write_text(legacy_expected, encoding="utf-8") - self.assertEqual("resume\n", read_record().stdout) - - start.write_text(fork_start, encoding="utf-8") - expected.write_text(fork_expected, encoding="utf-8") - self.assertEqual("fork\n", read_record().stdout) - invalid_pairs = [ - (fork_start.rstrip("\n") + "\textra\n", fork_expected), - (fork_start, fork_expected.rstrip("\n") + "\textra\n"), - (fork_start + "second-line\n", fork_expected), - (fork_start, fork_expected + "second-line\n"), - ] - for index, (main_value, side_value) in enumerate(invalid_pairs): - with self.subTest(index=index): - start.write_text(main_value, encoding="utf-8") - expected.write_text(side_value, encoding="utf-8") - rejected = read_record() - self.assertNotEqual(0, rejected.returncode) + def test_repair_closes_the_dead_session_then_restores_its_conversation(self) -> None: + """`sp repair` is the unattended half of the wedge fix. - def test_attach_race_cannot_leave_a_replacement_shell(self) -> None: - row = session_row("target") + shpool can lose the thread that serves a session's terminal. The + session then cannot be attached again and whatever runs inside it + blocks on its next write for ever. Repair ends that session and brings + the same conversation up in a new one. + """ + exact_uuid = "00000000-0000-4000-8000-000000000001" + row = session_row("wedged") self.fixture.inventory.write_text( json.dumps(inventory_document(row)), encoding="utf-8" ) @@ -1156,7 +1833,7 @@ def test_attach_race_cannot_leave_a_replacement_shell(self) -> None: { "sessions": [ { - "name": "target", + "name": "wedged", "status": "Disconnected", "started_at_unix_ms": row["started_at_unix_ms"], } @@ -1166,101 +1843,559 @@ def test_attach_race_cannot_leave_a_replacement_shell(self) -> None: encoding="utf-8", ) env = self.fixture.env() - env["FAKE_DROP_BEFORE_ATTACH"] = "1" - self.fixture.journals.mkdir() - (self.fixture.journals / "target.raw").write_text( - "old terminal output must not replay automatically\n", encoding="utf-8" - ) - attached = run([SP, "go", "target"], env=env) - self.assertEqual(0, attached.returncode) - self.assertNotIn("old terminal output", attached.stdout) - self.assertEqual([], json.loads(self.fixture.shpool_state.read_text())["sessions"]) - self.assertEqual("attach target\n", self.fixture.shpool_log.read_text()) - - def test_no_journal_sentinel_disables_wrapper_and_consumes_shell_launch(self) -> None: - (self.fixture.home / ".no_shpool_journal").write_text("", encoding="utf-8") - self.fixture.start.mkdir() - record = self.fixture.start / "sentinel-session" - record.write_text( - f"shell\t{self.fixture.project}\t\n", encoding="utf-8" - ) - fake_bin = self.fixture.base / "fake-bin" - fake_bin.mkdir() - script_log = self.fixture.base / "script-wrapper.log" - write_executable( - fake_bin / "script", - "#!/usr/bin/env bash\nprintf called > \"$SCRIPT_WRAPPER_LOG\"\nexit 99\n", - ) - env = self.fixture.env() env.update( { - "SHPOOL_SESSION_NAME": "sentinel-session", - "SHPOOL_JOURNAL": "", - "PATH": f"{fake_bin}:{os.environ['PATH']}", - "SCRIPT_WRAPPER_LOG": str(script_log), + "SESSION_KIT_BACKGROUND": "1", + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + "STUB_DYNAMIC_PROVIDER": "codex", + "STUB_DYNAMIC_UUID": exact_uuid, + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "FAKE_EXPECT_KILL_LOCK": "locked", + **self._bound_kill_fixture(row), } ) - sourced = run( - [ - "bash", - "-c", - """ -bash --noprofile --norc -ic ' - shell_start=$(awk "{print \\$22}" /proc/$$/stat) - daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat) - printf "shell\\t%s\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\t\\n" \ - "$2" "$$" "$shell_start" "$PPID" "$daemon_start" > "$3/sentinel-session.expected" - source "$1" - printf "journal=%s\\n" "$SHPOOL_JOURNAL" -' journal-disabled-inner "$1" "$2" "$3" + repaired = run([SP, "repair", "wedged"], env=env) + log = self.fixture.shpool_log.read_text() + # The exact old shell is ended before the replacement is launched. + # Its stale manager entry is finalized before replacement allocation. + self.assertNotIn("kill wedged\n", log) + self.assertTrue(log.startswith("attach-exit wedged\nattach ")) + new_id = repaired.stdout.strip().splitlines()[-1] + self.assertRegex(new_id, r"^s[0-9]{8}-[0-9]{6}-[0-9]+$") + self.assertNotEqual("wedged", new_id) + stamps = [ + json.loads(line) + for line in self.fixture.origin_log.read_text(encoding="utf-8").splitlines() + ] + self.assertEqual("human", stamps[-1][1]) + + def test_repair_preserves_a_machine_sessions_origin(self) -> None: + exact_uuid = "00000000-0000-4000-8000-000000000001" + source = session_row("worker") + source["origin"] = "machine" + self.fixture.inventory.write_text( + json.dumps(inventory_document(source)), encoding="utf-8" + ) + self.fixture.shpool_state.write_text( + json.dumps( + { + "sessions": [ + { + "name": "worker", + "status": "Disconnected", + "started_at_unix_ms": source["started_at_unix_ms"], + } + ] + } + ), + encoding="utf-8", + ) + env = self.fixture.env() + env.update( + { + "SESSION_KIT_BACKGROUND": "1", + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + "STUB_DYNAMIC_PROVIDER": "codex", + "STUB_DYNAMIC_UUID": exact_uuid, + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "STUB_DYNAMIC_ORIGIN": "machine", + **self._bound_kill_fixture(source), + } + ) + run([SP, "repair", "worker"], env=env) + stamps = [ + json.loads(line) + for line in self.fixture.origin_log.read_text(encoding="utf-8").splitlines() + ] + self.assertEqual("machine", stamps[-1][1]) + + def test_repair_refuses_foreign_socket_holder_before_any_kill(self) -> None: + row = session_row("wedged") + row["cwd"] = str(self.fixture.project) + row["recovery"]["cwd"] = str(self.fixture.project) + row["shpool_shell"] = {"pid": 100, "process_start_ticks": 10_000} + row["identity"]["pid"] = 200 + row["identity"]["process_start_ticks"] = 20_000 + guard = inventory_document(row) + guard["daemon_generation"] = {"pid": 10, "process_start_ticks": 1_000} + self.fixture.inventory.write_text(json.dumps(guard), encoding="utf-8") + self.fixture.shpool_state.write_text( + json.dumps({"sessions": [{ + "name": "wedged", + "status": "Disconnected", + "started_at_unix_ms": row["started_at_unix_ms"], + }]}), + encoding="utf-8", + ) + proc_root = self.fixture.base / "repair-flip-proc" + runtime = self.fixture.base / "repair-flip-runtime" + (proc_root / "net").mkdir(parents=True) + (runtime / "shpool").mkdir(parents=True) + self._write_proc(proc_root, 10, 1, "shpool", b"/operator/shpool\0daemon\0") + self._write_proc( + proc_root, 100, 10, "bash", b"bash\0", b"SHPOOL_SESSION_NAME=wedged\0" + ) + self._write_proc(proc_root, 200, 100, "codex", b"codex\0") + self._write_proc(proc_root, 20, 1, "shpool", b"/foreign/shpool\0daemon\0") + self._write_proc( + proc_root, 300, 20, "bash", b"bash\0", b"SHPOOL_SESSION_NAME=wedged\0" + ) + socket_path = runtime / "shpool" / "shpool.socket" + (proc_root / "net" / "unix").write_text( + "Num RefCount Protocol Flags Type St Inode Path\n" + "0000000000000000: 00000002 00000000 00010000 0001 01 " + f"11065 {socket_path}\n", + encoding="utf-8", + ) + for pid in (10, 20): + (proc_root / str(pid) / "fd").mkdir() + (proc_root / "10/fd/3").symlink_to("/dev/null") + (proc_root / "20/fd/3").symlink_to("socket:[11065]") + kill_log = self.fixture.base / "repair-flip-kill.log" + client = self.fixture.base / "repair-flip-shpool" + write_executable( + client, + """#!/usr/bin/env bash +case ${1:-} in + list) cat "$FAKE_SHPOOL_STATE" ;; + kill) printf '%s\n' "${2:-}" >> "$REPAIR_FLIP_KILL_LOG" ;; + daemon) exit 70 ;; + *) exit 2 ;; +esac """, - "journal-disabled-test", - REPO / "bashrc/shpool.bashrc", - self.fixture.project, - self.fixture.start, - ], - env=env, ) - self.assertIn("journal=disabled", sourced.stdout) - self.assertFalse(script_log.exists()) - self.assertFalse(record.exists()) - self.assertFalse((self.fixture.start / "sentinel-session.expected").exists()) + env = self.fixture.env() + env.update({ + "XDG_RUNTIME_DIR": str(runtime), + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_TEST_PLATFORM": "Linux", + "SESSION_KIT_PROC_ROOT": str(proc_root), + "SESSION_KIT_SHPOOL_CMD": str(client), + "REPAIR_FLIP_KILL_LOG": str(kill_log), + }) + result = run([SP, "repair", "wedged"], env=env, check=False) + self.assertNotEqual(0, result.returncode) + self.assertIn("socket holder changed before repair close", result.stderr) + self.assertFalse(kill_log.exists()) + self.assertTrue((proc_root / "100/stat").is_file()) + self.assertTrue((proc_root / "200/stat").is_file()) + self.assertEqual( + ["wedged"], + [item["name"] for item in json.loads(self.fixture.shpool_state.read_text())["sessions"]], + ) + def test_repair_never_turns_a_reading_of_the_moment_into_a_stamp(self) -> None: + """A repair carries the stamp, and an unstamped session has none. + + Repair closes a session and opens it again, declaring an origin as it + goes -- and a declaration is written down as a real stamp that no + later refresh can overturn. If it declared what the row currently + READS as, an unstamped session whose window is missing would come back + stamped machine, permanently. That is not a rare pairing: repair is + the path taken when a window could not be opened, which is the state + most likely to read "no window" in the first place. + """ + exact_uuid = "00000000-0000-4000-8000-000000000001" + source = session_row("worker") + # No stamp anywhere: what the row reads as is all there is. + source["origin"] = "machine" + self.fixture.inventory.write_text( + json.dumps(inventory_document(source)), encoding="utf-8" + ) + self.fixture.shpool_state.write_text( + json.dumps( + { + "sessions": [ + { + "name": "worker", + "status": "Disconnected", + "started_at_unix_ms": source["started_at_unix_ms"], + } + ] + } + ), + encoding="utf-8", + ) + env = self.fixture.env() + env.update( + { + "SESSION_KIT_BACKGROUND": "1", + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + "STUB_DYNAMIC_PROVIDER": "codex", + "STUB_DYNAMIC_UUID": exact_uuid, + "STUB_DYNAMIC_CWD": str(self.fixture.project), + # What an unstamped App Server row looks like at the instant + # its window is gone: machine by inference, no stamp anywhere. + "STUB_DYNAMIC_ORIGIN_INFERRED": "machine", + } + ) + run([SP, "repair", "worker"], env=env) + stamps = [ + json.loads(line) + for line in self.fixture.origin_log.read_text(encoding="utf-8").splitlines() + ] + self.assertEqual("human", stamps[-1][1]) - def test_previous_boot_armed_record_never_executes_provider(self) -> None: - (self.fixture.home / ".no_shpool_journal").write_text("", encoding="utf-8") - self.fixture.start.mkdir() - record = self.fixture.start / "stale-session" - record.write_text( - f"claude\t{self.fixture.project}\t\n", encoding="utf-8" + def test_repair_from_inside_a_session_still_leaves_an_unproven_row_visible( + self, + ) -> None: + """The same repair, run where every agent runs it. + + Not carrying the row's reading is only half the fix: with nothing + declared and nothing recorded, the restore underneath used to read the + CALLER instead, and inside a managed session the caller reads machine. + So the guess came back through the other door and was written down as + a permanent stamp -- and the operator was told `Moving …` with no + warning. A repair reopens a conversation that already had an owner; if + nobody knows who, it is theirs. + """ + exact_uuid = "00000000-0000-4000-8000-000000000001" + source = session_row("worker") + source["origin"] = "machine" + self.fixture.inventory.write_text( + json.dumps(inventory_document(source)), encoding="utf-8" ) - fake_bin = self.fixture.base / "stale-bin" + self.fixture.shpool_state.write_text( + json.dumps( + { + "sessions": [ + { + "name": "worker", + "status": "Disconnected", + "started_at_unix_ms": source["started_at_unix_ms"], + } + ] + } + ), + encoding="utf-8", + ) + env = self.fixture.env() + env.update( + { + "SESSION_KIT_BACKGROUND": "1", + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + "STUB_DYNAMIC_PROVIDER": "codex", + "STUB_DYNAMIC_UUID": exact_uuid, + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "STUB_DYNAMIC_ORIGIN_INFERRED": "machine", + "SHPOOL_SESSION_NAME": "s20200102-050607-2000000", + } + ) + run([SP, "repair", "worker"], env=env) + stamps = [ + json.loads(line) + for line in self.fixture.origin_log.read_text(encoding="utf-8").splitlines() + ] + self.assertEqual("human", stamps[-1][1]) + + def test_repair_of_an_ordinary_unstamped_session_stays_the_persons(self) -> None: + """The plain case, and the one on their box: no stamp, no verdict. + + Not an App Server, nothing inferred, nothing recorded -- an ordinary + session that has simply never been stamped, which today is every + session running on their machine. Repairing one from inside a session + must leave it exactly where it was: in their list. This is the shape + that regressed, so it is asserted on its own rather than as a variant + of the inferred-machine case. + """ + exact_uuid = "00000000-0000-4000-8000-000000000001" + source = session_row("worker") + self.fixture.inventory.write_text( + json.dumps(inventory_document(source)), encoding="utf-8" + ) + self.fixture.shpool_state.write_text( + json.dumps( + { + "sessions": [ + { + "name": "worker", + "status": "Disconnected", + "started_at_unix_ms": source["started_at_unix_ms"], + } + ] + } + ), + encoding="utf-8", + ) + env = self.fixture.env() + env.update( + { + "SESSION_KIT_BACKGROUND": "1", + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + "STUB_DYNAMIC_PROVIDER": "codex", + "STUB_DYNAMIC_UUID": exact_uuid, + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "SHPOOL_SESSION_NAME": "s20200102-050607-2000000", + } + ) + run([SP, "repair", "worker"], env=env) + stamps = [ + json.loads(line) + for line in self.fixture.origin_log.read_text(encoding="utf-8").splitlines() + ] + self.assertEqual("human", stamps[-1][1]) + + def test_repair_refuses_a_session_with_no_conversation_to_move(self) -> None: + self.fixture.inventory.write_text( + json.dumps(inventory_document(unknown_session_row("halfstarted"))), + encoding="utf-8", + ) + refused = run([SP, "repair", "halfstarted"], env=self.fixture.env(), check=False) + self.assertNotEqual(0, refused.returncode) + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_restore_is_not_blocked_by_an_unrelated_unknown_session(self) -> None: + """A half-started session must not be able to block recovery. + + Recovery previously demanded a perfect inventory, so any session that + had not yet published a provider identity -- a shell opened seconds + ago, or one sitting on a provider's startup prompt -- made every + restore refuse. That turned an unrelated row into an outage during the + exact moment recovery is needed. The duplicate-conversation guard is + what protects this operation, and it is covered separately by + test_restore_same_provider_duplicate_skips_null_shell_then_refuses_outside_uuid. + """ + exact_uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + self.fixture.inventory.write_text( + json.dumps(inventory_document(unknown_session_row())), + encoding="utf-8", + ) + env = self.fixture.env() + env.update( + { + "SESSION_KIT_BACKGROUND": "1", + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + "STUB_DYNAMIC_PROVIDER": "codex", + "STUB_DYNAMIC_AFTER_SESSIONS": "0", + "STUB_DYNAMIC_UUID": exact_uuid, + "STUB_DYNAMIC_CWD": str(self.fixture.project), + } + ) + restored = run( + [SP, "restore-exact", "codex", exact_uuid, self.fixture.project], + env=env, + ) + self.assertNotIn("strict live snapshot unavailable", restored.stderr) + self.assertTrue(self.fixture.shpool_log.exists()) + shpool_id = restored.stdout.strip().splitlines()[-1] + self.assertRegex(shpool_id, r"^s[0-9]{8}-[0-9]{6}-[0-9]+$") + + def test_stale_expected_namespace_forces_allocator_to_next_exact_id(self) -> None: + fake_bin = self.fixture.base / "allocator-bin" fake_bin.mkdir() - provider_log = self.fixture.base / "provider-launch.log" + stale_content = b"stale-sidecar-reservation\n" write_executable( - fake_bin / "claude", - "#!/usr/bin/env bash\nprintf launched > \"$PROVIDER_LAUNCH_LOG\"\n", + fake_bin / "date", + """#!/usr/bin/env bash +if [[ ${1:-} == +%Y%m%d-%H%M%S ]]; then + mkdir -p "$SESSION_KIT_START_DIR" + owner_pid=$(awk '{print $4}' "/proc/$PPID/stat") + printf 'stale-sidecar-reservation\\n' > "$SESSION_KIT_START_DIR/s20260728-010203-$owner_pid.expected" + printf '20260728-010203\\n' + exit 0 +fi +exec /bin/date "$@" +""", ) env = self.fixture.env() env.update( { - "SHPOOL_SESSION_NAME": "stale-session", - "SHPOOL_JOURNAL": "", + "SESSION_KIT_BACKGROUND": "1", + "STUB_DYNAMIC_PROVIDER": "shell", + "STUB_DYNAMIC_CWD": str(self.fixture.project), "PATH": f"{fake_bin}:{os.environ['PATH']}", - "PROVIDER_LAUNCH_LOG": str(provider_log), } ) - sourced = run( + created = run([SP, "new", "shell", "fixture"], env=env) + shpool_id = created.stdout.strip().splitlines()[-1] + self.assertRegex(shpool_id, r"^s20260728-010203-[0-9]+-1$") + reserved = self.fixture.start / f"{shpool_id.removesuffix('-1')}.expected" + self.assertEqual(stale_content, reserved.read_bytes()) + self.assertFalse((self.fixture.start / shpool_id).exists()) + self.assertFalse((self.fixture.start / f"{shpool_id}.expected").exists()) + + def test_start_writer_refuses_existing_expected_namespace(self) -> None: + self.fixture.start.mkdir() + expected = self.fixture.start / "collision.expected" + expected.write_bytes(b"existing-sidecar\n") + refused = run( [ "bash", "-c", - """ -bash --noprofile --norc -ic ' - shell_start=$(awk "{print \\$22}" /proc/$$/stat) - daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat) - printf "claude\\t%s\\tprevious-boot\\t1\\t%s\\t%s\\t%s\\t%s\\t\\n" \ - "$2" "$$" "$shell_start" "$PPID" "$daemon_start" > "$3/stale-session.expected" - source "$1" -' stale-inner "$1" "$2" "$3" + 'source "$1"; sk_write_start_record collision shell "$2"', + "writer-collision-test", + COMMON, + self.fixture.project, + ], + env=self.fixture.env(), + check=False, + ) + self.assertNotEqual(0, refused.returncode) + self.assertFalse((self.fixture.start / "collision").exists()) + self.assertEqual(b"existing-sidecar\n", expected.read_bytes()) + + def test_launch_records_accept_legacy_and_reject_multiline_or_extra_fields(self) -> None: + self.fixture.start.mkdir() + source_uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + start = self.fixture.start / "launch" + expected = self.fixture.start / "launch.expected" + legacy_start = f"claude\t{self.fixture.project}\t{source_uuid}\n" + legacy_expected = ( + f"claude\t{self.fixture.project}\tfixture-boot\t1700000000001\t" + f"1001\t10010\t10\t100\t{source_uuid}\n" + ) + fork_start = legacy_start.rstrip("\n") + "\tfork\n" + fork_expected = legacy_expected.rstrip("\n") + "\tfork\n" + + def read_record() -> subprocess.CompletedProcess[str]: + return run( + [ + "bash", + "-c", + 'source "$1"; sk_read_start_expectation launch && ' + 'printf "%s\\n" "$SK_EXPECT_LAUNCH_MODE"', + "launch-record-test", + COMMON, + ], + env=self.fixture.env(), + check=False, + ) + + start.write_text(legacy_start, encoding="utf-8") + expected.write_text(legacy_expected, encoding="utf-8") + self.assertEqual("resume\n", read_record().stdout) + + start.write_text(fork_start, encoding="utf-8") + expected.write_text(fork_expected, encoding="utf-8") + self.assertEqual("fork\n", read_record().stdout) + + invalid_pairs = [ + (fork_start.rstrip("\n") + "\textra\n", fork_expected), + (fork_start, fork_expected.rstrip("\n") + "\textra\n"), + (fork_start + "second-line\n", fork_expected), + (fork_start, fork_expected + "second-line\n"), + ] + for index, (main_value, side_value) in enumerate(invalid_pairs): + with self.subTest(index=index): + start.write_text(main_value, encoding="utf-8") + expected.write_text(side_value, encoding="utf-8") + rejected = read_record() + self.assertNotEqual(0, rejected.returncode) + + def test_attach_race_cannot_leave_a_replacement_shell(self) -> None: + row = session_row("target") + self.fixture.inventory.write_text( + json.dumps(inventory_document(row)), encoding="utf-8" + ) + self.fixture.shpool_state.write_text( + json.dumps( + { + "sessions": [ + { + "name": "target", + "status": "Disconnected", + "started_at_unix_ms": row["started_at_unix_ms"], + } + ] + } + ), + encoding="utf-8", + ) + env = self.fixture.env() + env["FAKE_DROP_BEFORE_ATTACH"] = "1" + self.fixture.journals.mkdir() + (self.fixture.journals / "target.raw").write_text( + "old terminal output must not replay automatically\n", encoding="utf-8" + ) + attached = run([SP, "go", "target"], env=env) + self.assertEqual(0, attached.returncode) + self.assertNotIn("old terminal output", attached.stdout) + self.assertEqual([], json.loads(self.fixture.shpool_state.read_text())["sessions"]) + self.assertEqual("attach target\n", self.fixture.shpool_log.read_text()) + + def test_no_journal_sentinel_disables_wrapper_and_consumes_shell_launch(self) -> None: + (self.fixture.home / ".no_shpool_journal").write_text("", encoding="utf-8") + self.fixture.start.mkdir() + record = self.fixture.start / "sentinel-session" + record.write_text( + f"shell\t{self.fixture.project}\t\n", encoding="utf-8" + ) + fake_bin = self.fixture.base / "fake-bin" + fake_bin.mkdir() + script_log = self.fixture.base / "script-wrapper.log" + write_executable( + fake_bin / "script", + "#!/usr/bin/env bash\nprintf called > \"$SCRIPT_WRAPPER_LOG\"\nexit 99\n", + ) + env = self.fixture.env() + env.update( + { + "SHPOOL_SESSION_NAME": "sentinel-session", + "SHPOOL_JOURNAL": "", + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "SCRIPT_WRAPPER_LOG": str(script_log), + } + ) + sourced = run( + [ + "bash", + "-c", + """ +bash --noprofile --norc -ic ' + shell_start=$(awk "{print \\$22}" /proc/$$/stat) + daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat) + printf "shell\\t%s\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\t\\n" \ + "$2" "$$" "$shell_start" "$PPID" "$daemon_start" > "$3/sentinel-session.expected" + source "$1" + printf "journal=%s\\n" "$SHPOOL_JOURNAL" +' journal-disabled-inner "$1" "$2" "$3" +""", + "journal-disabled-test", + REPO / "bashrc/shpool.bashrc", + self.fixture.project, + self.fixture.start, + ], + env=env, + ) + self.assertIn("journal=disabled", sourced.stdout) + self.assertFalse(script_log.exists()) + self.assertFalse(record.exists()) + self.assertFalse((self.fixture.start / "sentinel-session.expected").exists()) + + def test_previous_boot_armed_record_never_executes_provider(self) -> None: + (self.fixture.home / ".no_shpool_journal").write_text("", encoding="utf-8") + self.fixture.start.mkdir() + record = self.fixture.start / "stale-session" + record.write_text( + f"claude\t{self.fixture.project}\t\n", encoding="utf-8" + ) + fake_bin = self.fixture.base / "stale-bin" + fake_bin.mkdir() + provider_log = self.fixture.base / "provider-launch.log" + write_executable( + fake_bin / "claude", + "#!/usr/bin/env bash\nprintf launched > \"$PROVIDER_LAUNCH_LOG\"\n", + ) + env = self.fixture.env() + env.update( + { + "SHPOOL_SESSION_NAME": "stale-session", + "SHPOOL_JOURNAL": "", + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "PROVIDER_LAUNCH_LOG": str(provider_log), + } + ) + sourced = run( + [ + "bash", + "-c", + """ +bash --noprofile --norc -ic ' + shell_start=$(awk "{print \\$22}" /proc/$$/stat) + daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat) + printf "claude\\t%s\\tprevious-boot\\t1\\t%s\\t%s\\t%s\\t%s\\t\\n" \ + "$2" "$$" "$shell_start" "$PPID" "$daemon_start" > "$3/stale-session.expected" + source "$1" +' stale-inner "$1" "$2" "$3" """, "stale-launch-test", REPO / "bashrc/shpool.bashrc", @@ -1281,9 +2416,13 @@ def test_fork_launch_mode_executes_only_provider_fork_primitives(self) -> None: "claude": f"--resume {source_uuid} --fork-session\n", # Codex is launched with its startup upgrade prompt suppressed: a # managed session has nobody to press a key, and the prompt would - # hold the session at "setup incomplete" forever. + # hold the session at "pending" forever. + # ...and with the kit-owned tab title, which is how the kit owns + # the tab name on the Codex side (K3) without editing the + # person's own ~/.codex/config.toml. "codex": ( "-c check_for_update_on_startup=false " + '-c tui.terminal_title=["activity", "thread"] ' f"--no-alt-screen fork {source_uuid}\n" ), } @@ -1350,7 +2489,11 @@ def test_unarmed_creation_failures_are_quarantined(self) -> None: [SP, "new", "shell", "fixture"], env=attach_env, check=False ) self.assertNotEqual(0, failed_attach.returncode) - self.assertIn("quarantined", failed_attach.stderr) + # The reason a maintainer needs lives in the quarantine file name + # checked below; the person gets one fact and one way forward. + self.assertIn( + "did not confirm the new session", failed_attach.stderr + ) failed_dir = self.fixture.start / "failed" self.assertEqual(1, len(list(failed_dir.glob("*.attach-failed.start")))) self.assertFalse( @@ -1360,231 +2503,1045 @@ def test_unarmed_creation_failures_are_quarantined(self) -> None: if path.name != "failed" ) ) - - generation_env = self.fixture.env() - generation_env.update( + + generation_env = self.fixture.env() + generation_env.update( + { + "SESSION_KIT_BACKGROUND": "1", + "STUB_DYNAMIC_PROVIDER": "shell", + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "STUB_DYNAMIC_STARTED": "1", + } + ) + failed_generation = run( + [SP, "new", "shell", "fixture"], env=generation_env, check=False + ) + self.assertNotEqual(0, failed_generation.returncode) + self.assertIn( + "could not confirm it", failed_generation.stderr + ) + self.assertEqual(1, len(list(failed_dir.glob("*.generation-unproven.start")))) + + def test_generation_sidecar_publish_failure_quarantines_main_record(self) -> None: + fake_bin = self.fixture.base / "arming-bin" + fake_bin.mkdir() + write_executable( + fake_bin / "mv", + """#!/usr/bin/env bash +destination=${@: -1} +if [[ $destination == *.expected ]]; then exit 9; fi +exec /bin/mv "$@" +""", + ) + env = self.fixture.env() + env.update( + { + "SESSION_KIT_BACKGROUND": "1", + "STUB_DYNAMIC_PROVIDER": "shell", + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "PATH": f"{fake_bin}:{os.environ['PATH']}", + } + ) + failed = run([SP, "new", "shell", "fixture"], env=env, check=False) + self.assertNotEqual(0, failed.returncode) + self.assertIn("could not record how it started", failed.stderr) + failed_dir = self.fixture.start / "failed" + self.assertEqual(1, len(list(failed_dir.glob("*.arming-failed.start")))) + self.assertFalse(any(self.fixture.start.glob("s*"))) + + def test_restore_uuid_requires_canonical_shape_and_normalizes_case(self) -> None: + invalid = run( + [SP, "restore-exact", "claude", "not-a-uuid", self.fixture.project], + env=self.fixture.env(), + check=False, + ) + self.assertNotEqual(0, invalid.returncode) + self.assertIn("requires a conversation UUID", invalid.stderr) + + uppercase = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE" + env = self.fixture.env() + env.update( + { + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + "STUB_DYNAMIC_PROVIDER": "shell", + "STUB_DYNAMIC_CWD": str(self.fixture.project), + } + ) + failed = run( + [SP, "restore-exact", "claude", uppercase, self.fixture.project], + env=env, + check=False, + ) + self.assertNotEqual(0, failed.returncode) + state = json.loads(self.fixture.shpool_state.read_text()) + shpool_id = state["sessions"][0]["name"] + self.assertTrue( + (self.fixture.start / shpool_id) + .read_text() + .endswith(uppercase.lower() + "\tresume\n") + ) + self.assertTrue( + (self.fixture.start / f"{shpool_id}.expected") + .read_text() + .endswith("\t" + uppercase.lower() + "\tresume\n") + ) + + def test_close_revalidates_exact_identity_and_refuses_changed_target(self) -> None: + row = session_row("target") + self.fixture.inventory.write_text( + json.dumps(inventory_document(row)), encoding="utf-8" + ) + self.fixture.shpool_state.write_text( + json.dumps( + { + "sessions": [ + { + "name": "target", + "status": "Disconnected", + "started_at_unix_ms": row["started_at_unix_ms"], + } + ] + } + ), + encoding="utf-8", + ) + changed = self.fixture.base / "changed.json" + changed.write_text( + json.dumps( + inventory_document( + session_row( + "target", + uuid="00000000-0000-4000-8000-000000000999", + ) + ) + ), + encoding="utf-8", + ) + env = self.fixture.env() + env["SESSION_KIT_CONFIRM_ID"] = "target" + env["STUB_SECOND_INVENTORY"] = str(changed) + refused = run([SP, "close", "1"], env=env, check=False) + self.assertNotEqual(0, refused.returncode) + self.assertIn("target changed", refused.stderr) + self.assertFalse(self.fixture.shpool_log.exists()) + + self.fixture.snapshot_count.unlink() + env.pop("STUB_SECOND_INVENTORY") + self.fixture.start.mkdir(exist_ok=True) + retained = self.fixture.start / "target" + retained_expected = self.fixture.start / "target.expected" + retained.write_text("retained-start\n", encoding="utf-8") + retained_expected.write_text("retained-expected\n", encoding="utf-8") + closed = run([SP, "close", "target"], env=env) + self.assertIn("Closed Codex fixture", closed.stdout) + self.assertNotIn("target", closed.stdout.splitlines()[-1]) + self.assertEqual("attach-exit target\n", self.fixture.shpool_log.read_text()) + self.assertFalse(retained.exists()) + self.assertFalse(retained_expected.exists()) + archived = list((self.fixture.start / "failed").glob("target.*.closed.*")) + self.assertEqual(2, len(archived)) + + def test_target_revalidation_uses_guard_with_unrelated_unknown_session(self) -> None: + target = session_row("target") + self.fixture.inventory.write_text( + json.dumps(inventory_document(target, unknown_session_row())), + encoding="utf-8", + ) + self.fixture.shpool_state.write_text( + json.dumps( + { + "sessions": [ + { + "name": "target", + "status": "Disconnected", + "started_at_unix_ms": target["started_at_unix_ms"], + } + ] + } + ), + encoding="utf-8", + ) + env = self.fixture.env() + env["SESSION_KIT_CONFIRM_ID"] = "target" + closed = run([SP, "close", "target"], env=env) + self.assertIn("Closed Codex fixture", closed.stdout) + self.assertNotIn("target", closed.stdout.splitlines()[-1]) + self.assertEqual("attach-exit target\n", self.fixture.shpool_log.read_text()) + + def test_close_records_that_the_conversation_was_ended_on_purpose(self) -> None: + """`sp close` is a person's verb exactly as the picker's `k` is. + + Without a tombstone the crash queue cannot tell the two apart, and a + conversation somebody deliberately ended comes back as unclaimed + recovery work the next time the snapshot notices its session gone. + """ + target = session_row("target") + self.fixture.inventory.write_text( + json.dumps(inventory_document(target, unknown_session_row())), + encoding="utf-8", + ) + self.fixture.shpool_state.write_text( + json.dumps( + { + "sessions": [ + { + "name": "target", + "status": "Disconnected", + "started_at_unix_ms": target["started_at_unix_ms"], + } + ] + } + ), + encoding="utf-8", + ) + env = self.fixture.env() + env["SESSION_KIT_CONFIRM_ID"] = "target" + closed = run([SP, "close", "target"], env=env) + self.assertIn("Closed Codex fixture", closed.stdout) + recorded = [ + json.loads(line) + for line in self.fixture.close_intent_log.read_text( + encoding="utf-8" + ).splitlines() + if line.strip() + ] + self.assertEqual( + [["close-intent", "record", "codex", target["identity"]["uuid"]]], + recorded, + ) + + def test_failed_close_ledger_message_does_not_promise_a_recovery_offer( + self, + ) -> None: + target = session_row("target") + self.fixture.inventory.write_text( + json.dumps(inventory_document(target, unknown_session_row())), + encoding="utf-8", + ) + self.fixture.shpool_state.write_text( + json.dumps( + { + "sessions": [ + { + "name": "target", + "status": "Disconnected", + "started_at_unix_ms": target["started_at_unix_ms"], + } + ] + } + ), + encoding="utf-8", + ) + env = self.fixture.env() + env.update( + { + "SESSION_KIT_CONFIRM_ID": "target", + "STUB_CLOSE_INTENT_FAIL": "1", + } + ) + + closed = run([SP, "close", "target"], env=env) + + self.assertIn("Closed Codex fixture", closed.stdout) + self.assertIn("the provider conversation remains on disk", closed.stderr) + self.assertIn( + "repair the Closed sessions ledger before relying on `sp recover`", + closed.stderr, + ) + self.assertNotIn("still offered by `sp recover`", closed.stderr) + picker_source = (REPO / "lib/sh/sp_picker.sh").read_text(encoding="utf-8") + self.assertIn("the provider conversation remains on disk", picker_source) + self.assertNotIn("still offered by `sp recover`", picker_source) + + def test_guard_failure_refuses_target_without_kill(self) -> None: + target = session_row("target") + self.fixture.inventory.write_text( + json.dumps(inventory_document(target)), encoding="utf-8" + ) + env = self.fixture.env() + env.update( + { + "SESSION_KIT_CONFIRM_ID": "target", + "STUB_GUARD_FAIL": "1", + } + ) + refused = run([SP, "close", "target"], env=env, check=False) + self.assertNotEqual(0, refused.returncode) + self.assertIn("target changed", refused.stderr) + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_prompt_waiting_cache_recomputes_state_root_after_source_cleanup(self) -> None: + xdg_state = self.fixture.base / "prompt-state" + cache = xdg_state / "session-kit" / "waiting-count" + cache.parent.mkdir(parents=True) + cache.write_text("2\n", encoding="utf-8") + env = { + **os.environ, + "HOME": str(self.fixture.home), + "XDG_STATE_HOME": str(xdg_state), + "SHPOOL_SESSION_NAME": "main", + "SHPOOL_JOURNAL": "disabled", + } + prompted = run( + [ + "bash", + "--noprofile", + "--norc", + "-ic", + 'source "$1"; stat() { date +%s; }; __sk_waiting', + "prompt-state-test", + BASHRC, + ], + env=env, + ) + self.assertIn("●2", prompted.stdout) + + def test_unsafe_raw_id_is_visible_but_refused_before_mutation(self) -> None: + unsafe = session_row("main-template") + unsafe["mutation_allowed"] = False + unsafe["mutation_rejection_reason"] = "template" + self.fixture.inventory.write_text( + json.dumps(inventory_document(unsafe)), encoding="utf-8" + ) + self.fixture.shpool_state.write_text( + json.dumps( + { + "sessions": [ + { + "name": "main-template", + "status": "Disconnected", + "started_at_unix_ms": unsafe["started_at_unix_ms"], + } + ] + } + ), + encoding="utf-8", + ) + env = self.fixture.env() + env["SESSION_KIT_CONFIRM_ID"] = "main-template" + refused = run([SP, "close", "1"], env=env, check=False) + self.assertNotEqual(0, refused.returncode) + self.assertIn("display-only and cannot be mutated", refused.stderr) + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_reaper_requires_two_observations_never_kills_and_preserves_active_inode(self) -> None: + proc_root = self.fixture.base / "proc" + proc_root.mkdir() + self._write_proc(proc_root, 10, 1, "shpool", b"shpool\0daemon\0") + self._write_proc( + proc_root, + 100, + 10, + "bash", + b"bash\0", + b"SHPOOL_SESSION_NAME=old\0", + ) + self._write_proc( + proc_root, + 101, + 10, + "bash", + b"bash\0", + b"SHPOOL_SESSION_NAME=active\0", + ) + old_ms = int((time.time() - 9 * 86400) * 1000) + self.fixture.shpool_state.write_text( + json.dumps( + { + "sessions": [ + { + "name": "old", + "status": "Disconnected", + "started_at_unix_ms": old_ms - 10_000, + "last_disconnected_at_unix_ms": old_ms, + }, + { + "name": "active", + "status": "Attached", + "started_at_unix_ms": old_ms, + }, + ] + } + ), + encoding="utf-8", + ) + self.fixture.journals.mkdir() + active = self.fixture.journals / "active.raw" + active.write_bytes(b"\x00control\x1b[31m snowman=\xe2\x98\x83\n") + before = (active.stat().st_ino, hashlib.sha256(active.read_bytes()).hexdigest()) + guard_row = session_row("old", started=old_ms - 10_000, provider="shell") + guard_row["shpool_status"] = "Disconnected" + guard_row["shpool_shell"] = { + "pid": 100, + "process_start_ticks": 10_000, + } + guard = inventory_document(guard_row) + guard["daemon_generation"]["process_start_ticks"] = 1_000 + self.fixture.inventory.write_text(json.dumps(guard), encoding="utf-8") + env = self.fixture.env() + env.update( + { + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_PROC_ROOT": str(proc_root), + "SESSION_KIT_DAEMON_PID": "10", + "SESSION_KIT_REAPER_SENTINEL": str(self.fixture.base / "not-disabled"), + } + ) + first = run([REAPER], env=env) + self.assertIn("candidates=0 actions=0", first.stdout) + second = run([REAPER], env=env) + self.assertIn("candidates=1 actions=0", second.stdout) + candidates = json.loads( + (self.fixture.state / "prune-candidates.json").read_text() + ) + self.assertEqual(["old"], [x["shpool_id"] for x in candidates["candidates"]]) + after = (active.stat().st_ino, hashlib.sha256(active.read_bytes()).hexdigest()) + self.assertEqual(before, after) + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_prune_refuses_foreign_namesake_proof_and_operator_idle_control_closes(self) -> None: + proc_root = self.fixture.base / "bound-reaper-proc" + proc_root.mkdir() + self._write_proc(proc_root, 10, 1, "shpool", b"/foreign/shpool\0daemon\0") + self._write_proc( + proc_root, + 100, + 10, + "bash", + b"bash\0", + b"SHPOOL_SESSION_NAME=main\0", + ) + self._write_proc(proc_root, 20, 1, "shpool", b"/operator/shpool\0daemon\0") + self._write_proc( + proc_root, + 300, + 20, + "bash", + b"bash\0", + b"SHPOOL_SESSION_NAME=main\0", + ) + self._write_proc(proc_root, 400, 300, "codex", b"codex\0") + old_ms = int((time.time() - 9 * 86400) * 1000) + started = old_ms - 1_000 + self.fixture.shpool_state.write_text( + json.dumps( + { + "sessions": [ + { + "name": "main", + "status": "Disconnected", + "started_at_unix_ms": started, + "last_disconnected_at_unix_ms": old_ms, + } + ] + } + ), + encoding="utf-8", + ) + operator = session_row("main", started=started, provider="codex") + operator["shpool_status"] = "Disconnected" + operator["shpool_shell"] = { + "pid": 300, + "process_start_ticks": 30_000, + } + document = inventory_document(operator) + document["daemon_generation"] = { + "pid": 20, + "process_start_ticks": 2_000, + } + self.fixture.inventory.write_text(json.dumps(document), encoding="utf-8") + foreign = session_row("main", started=started, provider="shell") + foreign["shpool_status"] = "Disconnected" + foreign["shpool_shell"] = { + "pid": 100, + "process_start_ticks": 10_000, + } + foreign_document = inventory_document(foreign) + foreign_document["daemon_generation"] = { + "pid": 10, + "process_start_ticks": 1_000, + } + foreign_guard = self.fixture.base / "foreign-guard.json" + operator_guard = self.fixture.base / "operator-guard.json" + foreign_guard.write_text(json.dumps(foreign_document), encoding="utf-8") + operator_guard.write_text(json.dumps(document), encoding="utf-8") + switching_core = self.fixture.base / "switching-inventory" + write_executable( + switching_core, + """#!/usr/bin/env python3 +import os,pathlib,sys +args=sys.argv[1:] +if args and args[0] == "snapshot": + try: parent=pathlib.Path(f"/proc/{os.getppid()}/cmdline").read_bytes() + except OSError: raise SystemExit(2) + key="FOREIGN_GUARD" if b"shpool_reaper" in parent else "OPERATOR_GUARD" + print(pathlib.Path(os.environ[key]).read_text(),end="") + raise SystemExit(0) +os.execv(os.environ["ORIGINAL_INVENTORY_CORE"],[os.environ["ORIGINAL_INVENTORY_CORE"],*args]) +""", + ) + path_bin = self.fixture.base / "bound-reaper-bin" + path_bin.mkdir() + (path_bin / "shpool").symlink_to(self.fixture.fake_shpool) + env = self.fixture.env() + env.update( { - "SESSION_KIT_BACKGROUND": "1", - "STUB_DYNAMIC_PROVIDER": "shell", - "STUB_DYNAMIC_CWD": str(self.fixture.project), - "STUB_DYNAMIC_STARTED": "1", + "HOME": str(self.fixture.home), + "XDG_STATE_HOME": str(self.fixture.base / "xdg-state"), + "XDG_DATA_HOME": str(self.fixture.base / "xdg-data"), + "XDG_CONFIG_HOME": str(self.fixture.base / "xdg-config"), + "XDG_CACHE_HOME": str(self.fixture.base / "xdg-cache"), + "XDG_RUNTIME_DIR": str(self.fixture.base / "xdg-runtime"), + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_TEST_PLATFORM": "Linux", + "SESSION_KIT_PROC_ROOT": str(proc_root), + "SESSION_KIT_DAEMON_PID": "10", + "SESSION_KIT_REAPER_SENTINEL": str(self.fixture.base / "enabled"), + "SESSION_KIT_CONFIRM_ID": "main", + "SESSION_KIT_INVENTORY_CORE": str(switching_core), + "FOREIGN_GUARD": str(foreign_guard), + "OPERATOR_GUARD": str(operator_guard), + "ORIGINAL_INVENTORY_CORE": str(self.fixture.fake_core), + "PATH": f"{path_bin}:/usr/bin:/bin", } ) - failed_generation = run( - [SP, "new", "shell", "fixture"], env=generation_env, check=False + + seed = run([REAPER], env=env) + self.assertIn("candidates=0 actions=0", seed.stdout) + refused = run([SP, "prune"], env=env, check=False) + self.assertNotEqual(0, refused.returncode) + self.assertIn("changed or is no longer empty", refused.stderr) + self.assertFalse(self.fixture.shpool_log.exists()) + self.assertEqual(1, len(json.loads(self.fixture.shpool_state.read_text())["sessions"])) + + # Control: the same operator-bound shell is now genuinely empty. Two + # observations must still nominate it and the actual prune must close. + (proc_root / "400/comm").unlink() + (proc_root / "400/cmdline").unlink() + (proc_root / "400/environ").unlink() + (proc_root / "400/stat").unlink() + (proc_root / "400").rmdir() + env["SESSION_KIT_DAEMON_PID"] = "20" + env["SESSION_KIT_INVENTORY_CORE"] = str(self.fixture.fake_core) + env["FAKE_KILL_PROC_PID"] = "300" + exact_signal_log = self.fixture.base / "exact-signal.log" + env["SESSION_KIT_TEST_EXACT_SIGNAL"] = "remove" + env["SESSION_KIT_TEST_EXACT_SIGNAL_LOG"] = str(exact_signal_log) + env["FAKE_REQUIRE_EXACT_SIGNAL"] = str(exact_signal_log) + first = run([REAPER], env=env) + second = run([REAPER], env=env) + self.assertIn("candidates=0 actions=0", first.stdout) + self.assertIn("candidates=1 actions=0", second.stdout) + closed = run([SP, "prune"], env=env) + self.assertIn("Closed", closed.stdout) + self.assertEqual( + [], + [ + row["name"] + for row in json.loads(self.fixture.shpool_state.read_text())["sessions"] + ], ) - self.assertNotEqual(0, failed_generation.returncode) - self.assertIn("unarmed launch record quarantined", failed_generation.stderr) - self.assertEqual(1, len(list(failed_dir.glob("*.generation-unproven.start")))) + self.assertEqual("300\t30000\t15\n", exact_signal_log.read_text()) + self.assertEqual("attach-exit main\n", self.fixture.shpool_log.read_text()) - def test_generation_sidecar_publish_failure_quarantines_main_record(self) -> None: - fake_bin = self.fixture.base / "arming-bin" - fake_bin.mkdir() + def test_prune_refuses_a_socket_holder_flip_during_final_raw_list(self) -> None: + """A namesake answer cannot be combined with the older daemon tree.""" + proc_root = self.fixture.base / "socket-flip-proc" + runtime = self.fixture.base / "socket-flip-runtime" + (proc_root / "net").mkdir(parents=True) + (runtime / "shpool").mkdir(parents=True) + self._write_proc(proc_root, 10, 1, "shpool", b"/operator/shpool\0daemon\0") + self._write_proc( + proc_root, 100, 10, "bash", b"bash\0", b"SHPOOL_SESSION_NAME=main\0" + ) + self._write_proc(proc_root, 20, 1, "shpool", b"/foreign/shpool\0daemon\0") + self._write_proc( + proc_root, 300, 20, "bash", b"bash\0", b"SHPOOL_SESSION_NAME=main\0" + ) + socket_path = runtime / "shpool" / "shpool.socket" + (proc_root / "net" / "unix").write_text( + "Num RefCount Protocol Flags Type St Inode Path\n" + "0000000000000000: 00000002 00000000 00010000 0001 01 " + f"11065 {socket_path}\n", + encoding="utf-8", + ) + for pid in (10, 20): + (proc_root / str(pid) / "fd").mkdir() + (proc_root / "10/fd/3").symlink_to("socket:[11065]") + (proc_root / "20/fd/3").symlink_to("/dev/null") + + old_ms = int((time.time() - 9 * 86400) * 1000) + started = old_ms - 1_000 + self.fixture.shpool_state.write_text( + json.dumps( + {"sessions": [{ + "name": "main", + "status": "Disconnected", + "started_at_unix_ms": started, + "last_disconnected_at_unix_ms": old_ms, + }]} + ), + encoding="utf-8", + ) + row = session_row("main", started=started, provider="shell") + row["shpool_status"] = "Disconnected" + row["shpool_shell"] = {"pid": 100, "process_start_ticks": 10_000} + guard = inventory_document(row) + guard["daemon_generation"] = {"pid": 10, "process_start_ticks": 1_000} + self.fixture.inventory.write_text(json.dumps(guard), encoding="utf-8") + env = self.fixture.env() + env.update({ + "HOME": str(self.fixture.home), + "XDG_RUNTIME_DIR": str(runtime), + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_TEST_PLATFORM": "Linux", + "SESSION_KIT_PROC_ROOT": str(proc_root), + "SESSION_KIT_REAPER_SENTINEL": str(self.fixture.base / "enabled"), + "SESSION_KIT_CONFIRM_ID": "main", + }) + self.assertIn("candidates=0", run([REAPER], env=env).stdout) + self.assertIn("candidates=1", run([REAPER], env=env).stdout) + + kill_log = self.fixture.base / "socket-flip-kill.log" + switching = self.fixture.base / "socket-flip-shpool" write_executable( - fake_bin / "mv", - """#!/usr/bin/env bash -destination=${@: -1} -if [[ $destination == *.expected ]]; then exit 9; fi -exec /bin/mv "$@" + switching, + """#!/usr/bin/env python3 +import os,pathlib,sys +root=pathlib.Path(os.environ["SESSION_KIT_PROC_ROOT"]) +args=sys.argv[1:] +if args == ["list","--json"]: + (root/"10/fd/3").unlink() + (root/"10/fd/3").symlink_to("/dev/null") + (root/"20/fd/3").unlink() + (root/"20/fd/3").symlink_to("socket:[11065]") + print(pathlib.Path(os.environ["FAKE_SHPOOL_STATE"]).read_text(),end="") + raise SystemExit(0) +if len(args) == 2 and args[0] == "kill": + pathlib.Path(os.environ["FLIP_KILL_LOG"]).write_text(args[1]+"\\n") + raise SystemExit(0) +if args and args[0] == "daemon": raise SystemExit(70) +raise SystemExit(2) """, ) + env.update({ + "SESSION_KIT_SHPOOL_CMD": str(switching), + "FLIP_KILL_LOG": str(kill_log), + }) + result = run([SP, "prune"], env=env, check=False) + self.assertNotEqual(0, result.returncode) + self.assertIn("socket-holder change", result.stderr) + self.assertNotIn("Closed", result.stdout) + self.assertFalse(kill_log.exists()) + self.assertEqual( + ["main"], + [item["name"] for item in json.loads(self.fixture.shpool_state.read_text())["sessions"]], + ) + + def test_prune_does_not_report_close_while_exact_shell_survives(self) -> None: + proc_root = self.fixture.base / "surviving-shell-proc" + proc_root.mkdir() + self._write_proc(proc_root, 10, 1, "shpool", b"shpool\0daemon\0") + self._write_proc( + proc_root, 100, 10, "bash", b"bash\0", b"SHPOOL_SESSION_NAME=main\0" + ) + old_ms = int((time.time() - 9 * 86400) * 1000) + started = old_ms - 1_000 + self.fixture.shpool_state.write_text( + json.dumps({"sessions": [{ + "name": "main", + "status": "Disconnected", + "started_at_unix_ms": started, + "last_disconnected_at_unix_ms": old_ms, + }]}), + encoding="utf-8", + ) + row = session_row("main", started=started, provider="shell") + row["shpool_status"] = "Disconnected" + row["shpool_shell"] = {"pid": 100, "process_start_ticks": 10_000} + guard = inventory_document(row) + guard["daemon_generation"] = {"pid": 10, "process_start_ticks": 1_000} + self.fixture.inventory.write_text(json.dumps(guard), encoding="utf-8") env = self.fixture.env() - env.update( - { - "SESSION_KIT_BACKGROUND": "1", - "STUB_DYNAMIC_PROVIDER": "shell", - "STUB_DYNAMIC_CWD": str(self.fixture.project), - "PATH": f"{fake_bin}:{os.environ['PATH']}", - } + env.update({ + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_TEST_PLATFORM": "Linux", + "SESSION_KIT_PROC_ROOT": str(proc_root), + "SESSION_KIT_DAEMON_PID": "10", + "SESSION_KIT_REAPER_SENTINEL": str(self.fixture.base / "enabled"), + "SESSION_KIT_CONFIRM_ID": "main", + }) + self.assertIn("candidates=0", run([REAPER], env=env).stdout) + self.assertIn("candidates=1", run([REAPER], env=env).stdout) + result = run([SP, "prune"], env=env, check=False) + self.assertNotEqual(0, result.returncode) + self.assertIn("verified shell PID 100 start 10000 survives", result.stderr) + self.assertNotIn("Closed", result.stdout) + self.assertTrue((proc_root / "100/stat").is_file()) + self.assertEqual( + ["main"], + [ + row["name"] + for row in json.loads(self.fixture.shpool_state.read_text())["sessions"] + ], ) - failed = run([SP, "new", "shell", "fixture"], env=env, check=False) - self.assertNotEqual(0, failed.returncode) - self.assertIn("arming-failed", failed.stderr) - failed_dir = self.fixture.start / "failed" - self.assertEqual(1, len(list(failed_dir.glob("*.arming-failed.start")))) - self.assertFalse(any(self.fixture.start.glob("s*"))) + self.assertFalse(self.fixture.closed_log.exists()) - def test_restore_uuid_requires_canonical_shape_and_normalizes_case(self) -> None: - invalid = run( - [SP, "restore-exact", "claude", "not-a-uuid", self.fixture.project], - env=self.fixture.env(), - check=False, + def test_prune_skips_name_cleanup_when_holder_changes_after_exact_signal( + self, + ) -> None: + proc_root = self.fixture.base / "post-signal-holder-proc" + runtime = self.fixture.base / "post-signal-holder-runtime" + (proc_root / "net").mkdir(parents=True) + (runtime / "shpool").mkdir(parents=True) + self._write_proc( + proc_root, 10, 1, "shpool", b"shpool\0daemon\0", start_ticks=1_000 ) - self.assertNotEqual(0, invalid.returncode) - self.assertIn("exact conversation UUID", invalid.stderr) + self._write_proc( + proc_root, + 100, + 10, + "bash", + b"bash\0", + b"SHPOOL_SESSION_NAME=main\0", + start_ticks=10_000, + ) + self._write_proc( + proc_root, 20, 1, "shpool", b"shpool\0daemon\0", start_ticks=2_000 + ) + self._write_proc( + proc_root, + 300, + 20, + "bash", + b"bash\0", + b"SHPOOL_SESSION_NAME=main\0", + start_ticks=30_000, + ) + self._write_proc( + proc_root, 400, 300, "codex", b"codex\0", start_ticks=40_000 + ) + socket_path = runtime / "shpool" / "shpool.socket" + (proc_root / "net" / "unix").write_text( + "Num RefCount Protocol Flags Type St Inode Path\n" + "0000000000000000: 00000002 00000000 00010000 0001 01 " + f"11065 {socket_path}\n", + encoding="utf-8", + ) + for pid in (10, 20): + (proc_root / str(pid) / "fd").mkdir() + (proc_root / "10/fd/3").symlink_to("socket:[11065]") + (proc_root / "20/fd/3").symlink_to("/dev/null") - uppercase = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE" + old_ms = int((time.time() - 9 * 86400) * 1000) + started = old_ms - 1_000 + self.fixture.shpool_state.write_text( + json.dumps({"sessions": [{ + "name": "main", + "status": "Disconnected", + "started_at_unix_ms": started, + "last_disconnected_at_unix_ms": old_ms, + }]}), + encoding="utf-8", + ) + row = session_row("main", started=started, provider="shell") + row["shpool_status"] = "Disconnected" + row["shpool_shell"] = {"pid": 100, "process_start_ticks": 10_000} + guard = inventory_document(row) + guard["daemon_generation"] = {"pid": 10, "process_start_ticks": 1_000} + self.fixture.inventory.write_text(json.dumps(guard), encoding="utf-8") + + switching_core = self.fixture.base / "post-signal-switching-core" + write_executable( + switching_core, + """#!/usr/bin/env python3 +import os,pathlib,subprocess,sys +args=sys.argv[1:] +real=os.environ["ORIGINAL_INVENTORY_CORE"] +if args[:2] == ["platform","terminate-exact-process"]: + completed=subprocess.run([real,*args],text=True,capture_output=True) + sys.stdout.write(completed.stdout); sys.stderr.write(completed.stderr) + if completed.returncode == 0: + root=pathlib.Path(os.environ["SESSION_KIT_PROC_ROOT"]) + (root/"10/fd/3").unlink(); (root/"10/fd/3").symlink_to("/dev/null") + (root/"20/fd/3").unlink(); (root/"20/fd/3").symlink_to("socket:[11065]") + raise SystemExit(completed.returncode) +if args[:1] == ["platform"]: + os.execv(real,[real,*args]) +stub=os.environ["STUB_INVENTORY_CORE"] +os.execv(stub,[stub,*args]) +""", + ) + signal_log = self.fixture.base / "post-signal-exact.log" env = self.fixture.env() env.update( { - "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", - "STUB_DYNAMIC_PROVIDER": "shell", - "STUB_DYNAMIC_CWD": str(self.fixture.project), + "HOME": str(self.fixture.home), + "XDG_RUNTIME_DIR": str(runtime), + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_TEST_PLATFORM": "Linux", + "SESSION_KIT_PROC_ROOT": str(proc_root), + "SESSION_KIT_REAPER_SENTINEL": str(self.fixture.base / "enabled"), + "SESSION_KIT_CONFIRM_ID": "main", + "SESSION_KIT_TEST_EXACT_SIGNAL": "remove", + "SESSION_KIT_TEST_EXACT_SIGNAL_LOG": str(signal_log), + "SESSION_KIT_INVENTORY_CORE": str(switching_core), + "ORIGINAL_INVENTORY_CORE": str(REPO / "lib" / "session_inventory.py"), + "STUB_INVENTORY_CORE": str(self.fixture.fake_core), } ) - failed = run( - [SP, "restore-exact", "claude", uppercase, self.fixture.project], - env=env, - check=False, - ) - self.assertNotEqual(0, failed.returncode) - state = json.loads(self.fixture.shpool_state.read_text()) - shpool_id = state["sessions"][0]["name"] - self.assertTrue( - (self.fixture.start / shpool_id) - .read_text() - .endswith(uppercase.lower() + "\tresume\n") - ) - self.assertTrue( - (self.fixture.start / f"{shpool_id}.expected") - .read_text() - .endswith("\t" + uppercase.lower() + "\tresume\n") + self.assertIn("candidates=0", run([REAPER], env=env).stdout) + self.assertIn("candidates=1", run([REAPER], env=env).stdout) + closed = run([SP, "prune"], env=env, check=False) + self.assertNotEqual(0, closed.returncode) + self.assertNotIn("Closed", closed.stdout) + self.assertIn("manager entry main", closed.stderr) + self.assertEqual("100\t10000\t15\n", signal_log.read_text()) + self.assertTrue((proc_root / "300/stat").is_file()) + self.assertTrue((proc_root / "400/stat").is_file()) + self.assertFalse(self.fixture.shpool_log.exists()) + self.assertEqual( + ["main"], + [row["name"] for row in json.loads(self.fixture.shpool_state.read_text())["sessions"]], ) - def test_close_revalidates_exact_identity_and_refuses_changed_target(self) -> None: - row = session_row("target") - self.fixture.inventory.write_text( - json.dumps(inventory_document(row)), encoding="utf-8" + def test_reaper_report_refuses_a_shell_outside_the_bound_guard_row(self) -> None: + proc_root = self.fixture.base / "unbound-report-proc" + proc_root.mkdir() + self._write_proc(proc_root, 10, 1, "shpool", b"shpool\0daemon\0") + self._write_proc( + proc_root, + 100, + 10, + "bash", + b"bash\0", + b"SHPOOL_SESSION_NAME=main\0", ) + old_ms = int((time.time() - 9 * 86400) * 1000) + started = old_ms - 1_000 self.fixture.shpool_state.write_text( json.dumps( { "sessions": [ { - "name": "target", + "name": "main", "status": "Disconnected", - "started_at_unix_ms": row["started_at_unix_ms"], + "started_at_unix_ms": started, + "last_disconnected_at_unix_ms": old_ms, } ] } ), encoding="utf-8", ) - changed = self.fixture.base / "changed.json" - changed.write_text( - json.dumps( - inventory_document( - session_row( - "target", - uuid="00000000-0000-4000-8000-000000000999", - ) - ) - ), - encoding="utf-8", - ) + row = session_row("main", started=started, provider="shell") + row["shpool_status"] = "Disconnected" + row["shpool_shell"] = { + "pid": 101, + "process_start_ticks": 10_100, + } + guard = inventory_document(row) + guard["daemon_generation"]["process_start_ticks"] = 1_000 + self.fixture.inventory.write_text(json.dumps(guard), encoding="utf-8") + path_bin = self.fixture.base / "unbound-report-bin" + path_bin.mkdir() + (path_bin / "shpool").symlink_to(self.fixture.fake_shpool) env = self.fixture.env() - env["SESSION_KIT_CONFIRM_ID"] = "target" - env["STUB_SECOND_INVENTORY"] = str(changed) - refused = run([SP, "close", "1"], env=env, check=False) + env.update( + { + "HOME": str(self.fixture.home), + "XDG_STATE_HOME": str(self.fixture.base / "report-xdg-state"), + "XDG_DATA_HOME": str(self.fixture.base / "report-xdg-data"), + "XDG_CONFIG_HOME": str(self.fixture.base / "report-xdg-config"), + "XDG_CACHE_HOME": str(self.fixture.base / "report-xdg-cache"), + "XDG_RUNTIME_DIR": str(self.fixture.base / "report-xdg-runtime"), + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_TEST_PLATFORM": "Linux", + "SESSION_KIT_PROC_ROOT": str(proc_root), + "SESSION_KIT_DAEMON_PID": "10", + "SESSION_KIT_REAPER_SENTINEL": str(self.fixture.base / "enabled"), + "PATH": f"{path_bin}:/usr/bin:/bin", + } + ) + + refused = run([REAPER], env=env, check=False) self.assertNotEqual(0, refused.returncode) - self.assertIn("target changed", refused.stderr) + self.assertIn("invalid inventory or process map", refused.stderr) + self.assertFalse((self.fixture.state / "prune-candidates.json").exists()) self.assertFalse(self.fixture.shpool_log.exists()) - self.fixture.snapshot_count.unlink() - env.pop("STUB_SECOND_INVENTORY") - closed = run([SP, "close", "target"], env=env) - self.assertIn("Closed exact session target", closed.stdout) - self.assertEqual("kill target\n", self.fixture.shpool_log.read_text()) - - def test_target_revalidation_uses_guard_with_unrelated_unknown_session(self) -> None: - target = session_row("target") - self.fixture.inventory.write_text( - json.dumps(inventory_document(target, unknown_session_row())), - encoding="utf-8", + def test_the_prune_heading_states_the_window_it_actually_used(self) -> None: + """`sp prune` inherits the caller's environment, headings and all.""" + session = "s20260811-120000-6" + proc_root = self.fixture.base / "window-proc" + proc_root.mkdir() + self._write_proc(proc_root, 10, 1, "shpool", b"shpool\0daemon\0") + self._write_proc( + proc_root, + 100, + 10, + "bash", + b"bash\0", + f"SHPOOL_SESSION_NAME={session}\0".encode(), ) + recent_ms = int((time.time() - 2 * 86400) * 1000) self.fixture.shpool_state.write_text( json.dumps( { "sessions": [ { - "name": "target", + "name": session, "status": "Disconnected", - "started_at_unix_ms": target["started_at_unix_ms"], + "started_at_unix_ms": recent_ms - 10_000, + "last_disconnected_at_unix_ms": recent_ms, } ] } ), encoding="utf-8", ) - env = self.fixture.env() - env["SESSION_KIT_CONFIRM_ID"] = "target" - closed = run([SP, "close", "target"], env=env) - self.assertIn("Closed exact session target", closed.stdout) - self.assertEqual("kill target\n", self.fixture.shpool_log.read_text()) - - def test_guard_failure_refuses_target_without_kill(self) -> None: - target = session_row("target") - self.fixture.inventory.write_text( - json.dumps(inventory_document(target)), encoding="utf-8" + guard_row = session_row( + session, + started=recent_ms - 10_000, + provider="shell", ) + guard_row["shpool_status"] = "Disconnected" + guard_row["shpool_shell"] = { + "pid": 100, + "process_start_ticks": 10_000, + } + guard = inventory_document(guard_row) + guard["daemon_generation"]["process_start_ticks"] = 1_000 + self.fixture.inventory.write_text(json.dumps(guard), encoding="utf-8") env = self.fixture.env() env.update( { - "SESSION_KIT_CONFIRM_ID": "target", - "STUB_GUARD_FAIL": "1", - } - ) - refused = run([SP, "close", "target"], env=env, check=False) - self.assertNotEqual(0, refused.returncode) - self.assertIn("target changed", refused.stderr) - self.assertFalse(self.fixture.shpool_log.exists()) - - def test_prompt_waiting_cache_recomputes_state_root_after_source_cleanup(self) -> None: - xdg_state = self.fixture.base / "prompt-state" - cache = xdg_state / "session-kit" / "waiting-count" - cache.parent.mkdir(parents=True) - cache.write_text("2\n", encoding="utf-8") - env = { - **os.environ, - "HOME": str(self.fixture.home), - "XDG_STATE_HOME": str(xdg_state), - "SHPOOL_SESSION_NAME": "main", - "SHPOOL_JOURNAL": "disabled", - } - prompted = run( - [ - "bash", - "--noprofile", - "--norc", - "-ic", - 'source "$1"; stat() { date +%s; }; __sk_waiting', - "prompt-state-test", - BASHRC, - ], - env=env, + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_PROC_ROOT": str(proc_root), + "SESSION_KIT_DAEMON_PID": "10", + "SESSION_KIT_REAPER_SENTINEL": str(self.fixture.base / "not-disabled"), + "SESSION_KIT_PRUNE_DAYS": "1", + "SESSION_KIT_CONFIRM_ID": session, + } ) - self.assertIn("●2", prompted.stdout) + run([REAPER], env=env) + run([REAPER], env=env) + document = json.loads( + (self.fixture.state / "prune-candidates.json").read_text() + ) + self.assertEqual(1, document["max_age_days"]) + self.assertEqual([session], [x["shpool_id"] for x in document["candidates"]]) + listed = run([SP, "prune"], env=env, check=False) + combined = listed.stdout + listed.stderr + self.assertIn("Idle and empty for 1 day", combined) + self.assertNotIn("seven-day", combined) - def test_unsafe_raw_id_is_visible_but_refused_before_mutation(self) -> None: - unsafe = session_row("main-template") - unsafe["mutation_allowed"] = False - unsafe["mutation_rejection_reason"] = "template" - self.fixture.inventory.write_text( - json.dumps(inventory_document(unsafe)), encoding="utf-8" + def test_kept_terminal_is_never_nominated_and_never_pruned(self) -> None: + session = "s20260811-120000-4" + proc_root = self.fixture.base / "keep-proc" + proc_root.mkdir() + self._write_proc(proc_root, 10, 1, "shpool", b"shpool\0daemon\0") + self._write_proc( + proc_root, + 100, + 10, + "bash", + b"bash\0", + f"SHPOOL_SESSION_NAME={session}\0".encode(), ) + old_ms = int((time.time() - 9 * 86400) * 1000) self.fixture.shpool_state.write_text( json.dumps( { "sessions": [ { - "name": "main-template", + "name": session, "status": "Disconnected", - "started_at_unix_ms": unsafe["started_at_unix_ms"], + "started_at_unix_ms": old_ms - 10_000, + "last_disconnected_at_unix_ms": old_ms, } ] } ), encoding="utf-8", ) + guard_row = session_row( + session, + started=old_ms - 10_000, + provider="shell", + ) + guard_row["shpool_status"] = "Disconnected" + guard_row["shpool_shell"] = { + "pid": 100, + "process_start_ticks": 10_000, + } + guard = inventory_document(guard_row) + guard["daemon_generation"]["process_start_ticks"] = 1_000 + self.fixture.inventory.write_text(json.dumps(guard), encoding="utf-8") env = self.fixture.env() - env["SESSION_KIT_CONFIRM_ID"] = "main-template" - refused = run([SP, "close", "1"], env=env, check=False) + env.update( + { + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_PROC_ROOT": str(proc_root), + "SESSION_KIT_DAEMON_PID": "10", + "SESSION_KIT_REAPER_SENTINEL": str(self.fixture.base / "not-disabled"), + } + ) + run([REAPER], env=env) + nominated = run([REAPER], env=env) + self.assertIn("candidates=1 actions=0 kept=0", nominated.stdout) + + lifecycle.record_provider_exit( + self.fixture.state, + session_id=session, + boot_id="00000000-0000-4000-8000-00000000feed", + shell_pid=100, + shell_start_ticks=10_000, + provider="claude", + exit_code=0, + input_tracking=True, + ) + lifecycle.update_state( + self.fixture.state, + session_id=session, + boot_id="00000000-0000-4000-8000-00000000feed", + shell_pid=100, + shell_start_ticks=10_000, + event="keep", + keep=True, + ) + + kept = run([REAPER], env=env) + self.assertIn("candidates=0 actions=0 kept=1", kept.stdout) + candidates = json.loads( + (self.fixture.state / "prune-candidates.json").read_text() + ) + self.assertEqual([], candidates["candidates"]) + + candidate = self.fixture.base / "kept-candidate.json" + candidate.write_text( + json.dumps( + { + "shpool_id": session, + "started_at_unix_ms": old_ms - 10_000, + "last_disconnected_at_unix_ms": old_ms, + "shell_pid": 100, + "shell_start_ticks": 10_000, + } + ), + encoding="utf-8", + ) + candidate.chmod(0o600) + refused = run( + [REAPER, "--verify-candidate", candidate], env=env, check=False + ) self.assertNotEqual(0, refused.returncode) - self.assertIn("display-only and cannot be mutated", refused.stderr) + self.assertIn("marked keep", refused.stderr) self.assertFalse(self.fixture.shpool_log.exists()) - def test_reaper_requires_two_observations_never_kills_and_preserves_active_inode(self) -> None: - proc_root = self.fixture.base / "proc" + def test_shell_used_after_provider_exit_is_never_nominated(self) -> None: + session = "s20260811-120000-5" + proc_root = self.fixture.base / "used-proc" proc_root.mkdir() self._write_proc(proc_root, 10, 1, "shpool", b"shpool\0daemon\0") self._write_proc( @@ -1593,15 +3550,7 @@ def test_reaper_requires_two_observations_never_kills_and_preserves_active_inode 10, "bash", b"bash\0", - b"SHPOOL_SESSION_NAME=old\0", - ) - self._write_proc( - proc_root, - 101, - 10, - "bash", - b"bash\0", - b"SHPOOL_SESSION_NAME=active\0", + f"SHPOOL_SESSION_NAME={session}\0".encode(), ) old_ms = int((time.time() - 9 * 86400) * 1000) self.fixture.shpool_state.write_text( @@ -1609,44 +3558,59 @@ def test_reaper_requires_two_observations_never_kills_and_preserves_active_inode { "sessions": [ { - "name": "old", + "name": session, "status": "Disconnected", "started_at_unix_ms": old_ms - 10_000, "last_disconnected_at_unix_ms": old_ms, - }, - { - "name": "active", - "status": "Attached", - "started_at_unix_ms": old_ms, - }, + } ] } ), encoding="utf-8", ) - self.fixture.journals.mkdir() - active = self.fixture.journals / "active.raw" - active.write_bytes(b"\x00control\x1b[31m snowman=\xe2\x98\x83\n") - before = (active.stat().st_ino, hashlib.sha256(active.read_bytes()).hexdigest()) + guard_row = session_row( + session, + started=old_ms - 10_000, + provider="shell", + ) + guard_row["shpool_status"] = "Disconnected" + guard_row["shpool_shell"] = { + "pid": 100, + "process_start_ticks": 10_000, + } + guard = inventory_document(guard_row) + guard["daemon_generation"]["process_start_ticks"] = 1_000 + self.fixture.inventory.write_text(json.dumps(guard), encoding="utf-8") env = self.fixture.env() env.update( { + "SESSION_KIT_TESTING": "1", "SESSION_KIT_PROC_ROOT": str(proc_root), "SESSION_KIT_DAEMON_PID": "10", "SESSION_KIT_REAPER_SENTINEL": str(self.fixture.base / "not-disabled"), } ) - first = run([REAPER], env=env) - self.assertIn("candidates=0 actions=0", first.stdout) - second = run([REAPER], env=env) - self.assertIn("candidates=1 actions=0", second.stdout) - candidates = json.loads( - (self.fixture.state / "prune-candidates.json").read_text() + run([REAPER], env=env) + lifecycle.record_provider_exit( + self.fixture.state, + session_id=session, + boot_id="00000000-0000-4000-8000-00000000feed", + shell_pid=100, + shell_start_ticks=10_000, + provider="codex", + exit_code=0, + input_tracking=True, ) - self.assertEqual(["old"], [x["shpool_id"] for x in candidates["candidates"]]) - after = (active.stat().st_ino, hashlib.sha256(active.read_bytes()).hexdigest()) - self.assertEqual(before, after) - self.assertFalse(self.fixture.shpool_log.exists()) + lifecycle.update_state( + self.fixture.state, + session_id=session, + boot_id="00000000-0000-4000-8000-00000000feed", + shell_pid=100, + shell_start_ticks=10_000, + event="user-input", + ) + used = run([REAPER], env=env) + self.assertIn("candidates=0 actions=0 kept=1", used.stdout) def test_verify_candidate_requires_exact_tuple_and_unchanged_empty_tree(self) -> None: proc_root = self.fixture.base / "verify-proc" @@ -1676,9 +3640,19 @@ def test_verify_candidate_requires_exact_tuple_and_unchanged_empty_tree(self) -> encoding="utf-8", ) candidate.chmod(0o600) + guard_row = session_row("main", started=started, provider="shell") + guard_row["shpool_status"] = "Disconnected" + guard_row["shpool_shell"] = { + "pid": 100, + "process_start_ticks": 10_000, + } + guard = inventory_document(guard_row) + guard["daemon_generation"]["process_start_ticks"] = 1_000 + self.fixture.inventory.write_text(json.dumps(guard), encoding="utf-8") env = self.fixture.env() env.update( { + "SESSION_KIT_TESTING": "1", "SESSION_KIT_PROC_ROOT": str(proc_root), "SESSION_KIT_DAEMON_PID": "10", "SESSION_KIT_REAPER_SENTINEL": str(self.fixture.base / "enabled"), @@ -1708,7 +3682,10 @@ def set_state( set_state("Disconnected", started) verified = run([REAPER, "--verify-candidate", candidate], env=env) - self.assertIn("candidate verified exact", verified.stdout) + self.assertIn( + "session-kit: this session is idle, empty, and safe to close.", + verified.stdout, + ) set_state("Attached", started) attached = run( @@ -1805,9 +3782,17 @@ def test_stale_prune_candidate_fails_final_verifier_and_never_kills(self) -> Non ), encoding="utf-8", ) + guard = json.loads(self.fixture.inventory.read_text()) + guard["daemon_generation"]["process_start_ticks"] = 1_000 + guard["sessions"][0]["shpool_shell"] = { + "pid": 100, + "process_start_ticks": 10_000, + } + self.fixture.inventory.write_text(json.dumps(guard), encoding="utf-8") env = self.fixture.env() env.update( { + "SESSION_KIT_TESTING": "1", "SESSION_KIT_PROC_ROOT": str(proc_root), "SESSION_KIT_DAEMON_PID": "10", "SESSION_KIT_REAPER_SENTINEL": str(self.fixture.base / "enabled"), @@ -1870,10 +3855,16 @@ def _write_proc( comm: str, cmdline: bytes, environ: bytes = b"", + start_ticks: int | None = None, ) -> None: directory = root / str(pid) directory.mkdir() - fields = ["S", str(ppid), *(["0"] * 17), str(pid * 100)] + fields = [ + "S", + str(ppid), + *(["0"] * 17), + str(pid * 100 if start_ticks is None else start_ticks), + ] (directory / "stat").write_text( f"{pid} ({comm}) {' '.join(fields)}\n", encoding="utf-8" ) @@ -1881,6 +3872,152 @@ def _write_proc( (directory / "cmdline").write_bytes(cmdline) (directory / "environ").write_bytes(environ) + def _bound_kill_fixture(self, row: dict) -> dict[str, str]: + """Synthetic holder/shell generations for a successful raw kill.""" + proc_root = self.fixture.base / f"kill-proc-{row['shpool_id_raw']}" + proc_root.mkdir() + self._write_proc( + proc_root, 10, 1, "shpool", b"shpool\0daemon\0", start_ticks=100 + ) + shell = row["shpool_shell"] + self._write_proc( + proc_root, + shell["pid"], + 10, + "bash", + b"bash\0", + f"SHPOOL_SESSION_NAME={row['shpool_id_raw']}\0".encode(), + start_ticks=shell["process_start_ticks"], + ) + return { + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_TEST_PLATFORM": "Linux", + "SESSION_KIT_PROC_ROOT": str(proc_root), + "SESSION_KIT_DAEMON_PID": "10", + "FAKE_KILL_PROC_PID": str(shell["pid"]), + "SESSION_KIT_TEST_EXACT_SIGNAL": "remove", + "SESSION_KIT_TEST_EXACT_SIGNAL_LOG": str( + self.fixture.base / f"exact-signal-{row['shpool_id_raw']}.log" + ), + "STUB_DYNAMIC_SKIP_NAME_AFTER_SIGNAL": row["shpool_id_raw"], + } + + def test_exact_process_term_then_kill_stays_on_one_generation(self) -> None: + proc_root = self.fixture.base / "exact-process-proc" + proc_root.mkdir() + self._write_proc( + proc_root, 700, 1, "bash", b"bash\0", start_ticks=70_000 + ) + signal_log = self.fixture.base / "exact-process-signals.log" + env = self.fixture.env() + env.update( + { + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_TEST_PLATFORM": "Linux", + "SESSION_KIT_PROC_ROOT": str(proc_root), + "SESSION_KIT_TEST_EXACT_SIGNAL": "survive", + "SESSION_KIT_TEST_EXACT_SIGNAL_LOG": str(signal_log), + } + ) + result = run( + [ + REPO / "lib" / "session_inventory.py", + "platform", + "terminate-exact-process", + "700", + "70000", + ], + env=env, + check=False, + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("survives TERM and KILL", result.stderr) + self.assertEqual( + ["700\t70000\t15", "700\t70000\t9"], + signal_log.read_text().splitlines(), + ) + + def test_exact_process_reuse_refuses_before_any_signal(self) -> None: + proc_root = self.fixture.base / "recycled-process-proc" + proc_root.mkdir() + self._write_proc( + proc_root, 700, 1, "bash", b"bash\0", start_ticks=99_999 + ) + signal_log = self.fixture.base / "recycled-process-signals.log" + env = self.fixture.env() + env.update( + { + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_TEST_PLATFORM": "Linux", + "SESSION_KIT_PROC_ROOT": str(proc_root), + "SESSION_KIT_TEST_EXACT_SIGNAL": "remove", + "SESSION_KIT_TEST_EXACT_SIGNAL_LOG": str(signal_log), + } + ) + result = run( + [ + REPO / "lib" / "session_inventory.py", + "platform", + "terminate-exact-process", + "700", + "70000", + ], + env=env, + ) + self.assertEqual("already-gone\n", result.stdout) + self.assertFalse(signal_log.exists()) + + def test_exact_process_treats_same_generation_zombie_as_gone(self) -> None: + """A parent that delays wait(2) must not turn a real close into refusal.""" + parent = subprocess.Popen( + [ + sys.executable, + "-c", + """ +import os, pathlib, sys, time +child = os.fork() +if child == 0: + time.sleep(300) + os._exit(0) +stat = pathlib.Path(f\"/proc/{child}/stat\").read_text() +start = int(stat.rsplit(\")\", 1)[1].split()[19]) +print(child, start, flush=True) +sys.stdin.read(1) +os.waitpid(child, 0) +""", + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + assert parent.stdout is not None + pid, start = parent.stdout.readline().split() + result = run( + [ + REPO / "lib" / "session_inventory.py", + "platform", + "terminate-exact-process", + pid, + start, + ], + env=self.fixture.env(), + check=False, + ) + state = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()[0] + self.assertEqual("Z", state) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("terminated\n", result.stdout) + finally: + if parent.stdin is not None: + parent.stdin.write("x") + parent.stdin.flush() + parent.wait(timeout=10) + for stream in (parent.stdin, parent.stdout, parent.stderr): + if stream is not None: + stream.close() + class PickerProofTests(unittest.TestCase): def setUp(self) -> None: @@ -1909,6 +4046,34 @@ def _prime(self, row: dict) -> Path: ) return write_picker_proof(self.fixture.base / "picker-proof.json", row) + def _live_idle_codex(self, status: str) -> tuple[dict, subprocess.Popen]: + executable = self.fixture.base / "codex" + executable.symlink_to("/bin/sleep") + process = subprocess.Popen([executable, "60"]) + deadline = time.monotonic() + 2 + stat_text = "" + while time.monotonic() < deadline: + try: + stat_text = Path(f"/proc/{process.pid}/stat").read_text() + break + except OSError: + time.sleep(0.01) + self.assertTrue(stat_text) + start = int(stat_text.rsplit(")", 1)[1].split()[19]) + live = session_row("main2", status=status) + live["identity"]["pid"] = process.pid + live["identity"]["process_start_ticks"] = start + live["agent_status"] = "idle" + live["recent_output_age_seconds"] = 300 + return live, process + + def _mark_title_pending(self) -> Path: + marker_root = self.fixture.state / "provider-untitled" + marker_root.mkdir(exist_ok=True) + marker = marker_root / "main2" + marker.touch() + return marker + def test_picker_open_uses_exact_proof_and_releases_lock_before_attach(self) -> None: proof = self._prime(session_row("main2")) env = self.fixture.env() @@ -1917,6 +4082,102 @@ def test_picker_open_uses_exact_proof_and_releases_lock_before_attach(self) -> N self.assertEqual("", opened.stdout) self.assertEqual("attach main2\n", self.fixture.shpool_log.read_text()) + def test_picker_model_change_records_requested_and_returned_events(self) -> None: + row, process = self._live_idle_codex("Disconnected") + row["model"] = "gpt-old" + project = self.fixture.base / "project" + project.mkdir(exist_ok=True) + row["cwd"] = str(project) + row["recovery"]["cwd"] = str(project) + proof = self._prime(row) + process.terminate() + process.wait(timeout=2) + try: + env = self.fixture.env() + env["STUB_PROCESS_IS_TRUE"] = "1" + env["FAKE_DROP_INVENTORY_ROW"] = str(self.fixture.inventory) + env["FAKE_DROP_EXACT_SESSION"] = "main2" + env["STUB_PROCESS_TABLE"] = json.dumps( + { + "processes": [ + { + "pid": row["shpool_shell"]["pid"], + "ppid": 1, + "start_ticks": row["shpool_shell"]["process_start_ticks"], + "cmdline": ["bash"], + }, + { + "pid": row["identity"]["pid"], + "ppid": row["shpool_shell"]["pid"], + "start_ticks": row["identity"]["process_start_ticks"], + "cmdline": ["codex", "--model", "gpt-live"], + }, + ] + } + ) + completed = run( + [SP, "picker-change-model", proof, "gpt-old"], + env=env, + check=False, + ) + # Last-reply evidence cannot decline an explicit correction: an + # in-session model change may have happened since that reply. + self.assertEqual(1, completed.returncode) + self.assertIn("Moving", completed.stdout) + self.assertNotIn("Nothing changed", completed.stdout) + self.assertIn("could not confirm", completed.stderr) + self.assertTrue( + self.fixture.shpool_log.read_text(encoding="utf-8").startswith( + "attach-exit main2\nattach " + ) + ) + records = [ + json.loads(line) + for line in (self.fixture.state / "action-events.jsonl") + .read_text(encoding="utf-8") + .splitlines() + ] + model_events = [ + (item["action"], item["outcome"]) + for item in records + if item.get("action") == "model_change" + ] + self.assertEqual( + [("model_change", "requested"), ("model_change", "failed")], + model_events, + ) + finally: + if process.poll() is None: + process.terminate() + process.wait(timeout=2) + + def test_account_switch_refuses_working_subagents_and_kill_switch(self) -> None: + cases = ( + ("working", [], False, "working"), + ("idle", [{"status": "working"}], False, "working or its state is unproven"), + ("idle", [], True, "disabled"), + ) + for status, subagents, disabled, message in cases: + with self.subTest(status=status, subagents=bool(subagents), disabled=disabled): + row = session_row("main2", status="Attached") + row["agent_status"] = status + row["subagents"] = subagents + row["active_subagent_count"] = len(subagents) + proof = self._prime(row) + sentinel = self.fixture.state / "account-switching-off" + if disabled: + sentinel.touch(mode=0o600) + else: + sentinel.unlink(missing_ok=True) + refused = run( + [SP, "picker-account-switch", proof, "work"], + env=self.fixture.env(), + check=False, + ) + self.assertNotEqual(0, refused.returncode) + self.assertIn(message, refused.stderr) + self.assertFalse((self.fixture.state / "account-switches").exists()) + def test_picker_takeover_confirms_exact_id_and_uses_force_attach(self) -> None: proof = self._prime(session_row("main2", status="Attached")) env = self.fixture.env() @@ -1930,10 +4191,209 @@ def test_picker_takeover_confirms_exact_id_and_uses_force_attach(self) -> None: self.assertIn("Move session to this window", moved.stdout) # The confirmation names the session instead of demanding that its ID # be retyped. - self.assertIn("main2", moved.stdout) + # The confirmation names the session, never its ID: the exact ID + # still travels as the argument in SESSION_KIT_CONFIRM_ID. + self.assertIn("Codex fixture", moved.stdout) + self.assertNotIn("main2", moved.stdout) + # And it names nothing else: a bracketed confirm code beside an + # action nothing asks about reads as a step still owed. + self.assertNotIn("[confirm", moved.stdout) self.assertNotIn("Type the exact ID", moved.stdout) self.assertEqual("attach main2\n", self.fixture.shpool_log.read_text()) + def test_attached_takeover_never_restarts_pending_title_automatically(self) -> None: + row, process = self._live_idle_codex("Attached") + proof = self._prime(row) + marker = self._mark_title_pending() + env = self.fixture.env() + env.update( + { + "SESSION_KIT_CONFIRM_ID": "main2", + "STUB_CODEX_BOUNCE_TITLE": "Release Notes", + } + ) + try: + moved = run([SP, "picker-takeover", proof], env=env) + self.assertEqual(0, moved.returncode) + self.assertIsNone(process.poll()) + self.assertTrue(marker.exists()) + finally: + process.terminate() + process.wait(timeout=2) + + def test_explicit_pending_title_refresh_restarts_only_idle_exact_codex(self) -> None: + row, process = self._live_idle_codex("Attached") + proof = self._prime(row) + marker = self._mark_title_pending() + env = self.fixture.env() + env["STUB_CODEX_BOUNCE_TITLE"] = "Release Notes" + try: + refreshed = run([SP, "picker-title-refresh", proof], env=env) + process.wait(timeout=2) + self.assertEqual(0, refreshed.returncode) + self.assertIn("Restarted the idle Codex provider", refreshed.stdout) + self.assertFalse(marker.exists()) + bounce = self.fixture.state / "provider-bounce" / "main2" + self.assertEqual( + row["identity"]["uuid"], + bounce.read_text(encoding="utf-8").splitlines()[0], + ) + finally: + if process.poll() is None: + process.terminate() + process.wait(timeout=2) + + def test_explicit_pending_title_refresh_restarts_awaiting_reply_codex(self) -> None: + # "needs your reply" is a stable between-turn wait state: research + # sessions spend their whole life in it, so an idle-only rule left + # their bars unnamed forever. The bounce must treat it as TERM-safe. + row, process = self._live_idle_codex("Attached") + row["agent_status"] = "needs your reply" + proof = self._prime(row) + marker = self._mark_title_pending() + env = self.fixture.env() + env["STUB_CODEX_BOUNCE_TITLE"] = "Release Notes" + try: + refreshed = run([SP, "picker-title-refresh", proof], env=env) + process.wait(timeout=2) + self.assertEqual(0, refreshed.returncode) + self.assertFalse(marker.exists()) + bounce = self.fixture.state / "provider-bounce" / "main2" + self.assertEqual( + row["identity"]["uuid"], + bounce.read_text(encoding="utf-8").splitlines()[0], + ) + finally: + if process.poll() is None: + process.terminate() + process.wait(timeout=2) + + def _live_named_provider( + self, executable: str, status: str + ) -> tuple[dict, subprocess.Popen]: + binary = self.fixture.base / executable + binary.symlink_to("/bin/sleep") + process = subprocess.Popen([binary, "60"]) + deadline = time.monotonic() + 2 + stat_text = "" + while time.monotonic() < deadline: + try: + stat_text = Path(f"/proc/{process.pid}/stat").read_text() + break + except OSError: + time.sleep(0.01) + self.assertTrue(stat_text) + start = int(stat_text.rsplit(")", 1)[1].split()[19]) + live = session_row("main2", provider=executable, status=status) + live["identity"]["pid"] = process.pid + live["identity"]["process_start_ticks"] = start + live["agent_status"] = "idle" + live["recent_output_age_seconds"] = 300 + return live, process + + def test_explicit_pending_title_refresh_restarts_named_claude(self) -> None: + # The Claude window renames only at SessionStart or the next prompt; + # a session named after boot with no prompt since can only take its + # name through a restart-as-resume. + row, process = self._live_named_provider("claude", "Attached") + proof = self._prime(row) + marker = self._mark_title_pending() + env = self.fixture.env() + env["STUB_CLAUDE_BOUNCE_TITLE"] = "Headliner Question" + try: + refreshed = run([SP, "picker-title-refresh", proof], env=env) + process.wait(timeout=2) + self.assertEqual(0, refreshed.returncode) + self.assertIn("Claude provider", refreshed.stdout) + self.assertFalse(marker.exists()) + bounce = self.fixture.state / "provider-bounce" / "main2" + self.assertEqual( + row["identity"]["uuid"], + bounce.read_text(encoding="utf-8").splitlines()[0], + ) + finally: + if process.poll() is None: + process.terminate() + process.wait(timeout=2) + + def test_claude_bounce_clear_verdict_drops_marker_without_restart(self) -> None: + # Exit 3 from claude-bounce-title = a prompt followed the intent, so + # the live window already renamed; the marker is dropped and the + # provider is never touched. + row, process = self._live_named_provider("claude", "Attached") + proof = self._prime(row) + marker = self._mark_title_pending() + env = self.fixture.env() + env["STUB_CLAUDE_BOUNCE_CLEAR"] = "1" + try: + refused = run( + [SP, "picker-title-refresh", proof], env=env, check=False + ) + self.assertEqual(74, refused.returncode) + self.assertIsNone(process.poll()) + self.assertFalse(marker.exists()) + self.assertFalse( + (self.fixture.state / "provider-bounce" / "main2").exists() + ) + finally: + process.terminate() + process.wait(timeout=2) + + def test_app_server_title_refresh_restarts_only_remote_tui(self) -> None: + row, app_server = self._live_idle_codex("Attached") + executable = self.fixture.base / "codex" + remote_tui = subprocess.Popen([executable, "60"]) + deadline = time.monotonic() + 2 + remote_start = None + while time.monotonic() < deadline: + try: + stat_text = Path(f"/proc/{remote_tui.pid}/stat").read_text() + remote_start = int(stat_text.rsplit(")", 1)[1].split()[19]) + break + except OSError: + time.sleep(0.01) + self.assertIsNotNone(remote_start) + proof = self._prime(row) + marker = self._mark_title_pending() + env = self.fixture.env() + env.update( + { + "STUB_CODEX_BOUNCE_TITLE": "Release Notes", + "STUB_REFRESH_PID": str(remote_tui.pid), + "STUB_REFRESH_START": str(remote_start), + } + ) + try: + refreshed = run([SP, "picker-title-refresh", proof], env=env) + remote_tui.wait(timeout=2) + self.assertEqual(0, refreshed.returncode) + self.assertIsNone(app_server.poll()) + self.assertFalse(marker.exists()) + finally: + for process in (remote_tui, app_server): + if process.poll() is None: + process.terminate() + process.wait(timeout=2) + + def test_explicit_pending_title_refresh_refuses_working_provider(self) -> None: + row, process = self._live_idle_codex("Attached") + row["agent_status"] = "working" + proof = self._prime(row) + marker = self._mark_title_pending() + env = self.fixture.env() + env["STUB_CODEX_BOUNCE_TITLE"] = "Release Notes" + try: + refused = run( + [SP, "picker-title-refresh", proof], env=env, check=False + ) + self.assertEqual(74, refused.returncode) + self.assertIsNone(process.poll()) + self.assertTrue(marker.exists()) + self.assertIn("not proven idle", refused.stderr) + finally: + process.terminate() + process.wait(timeout=2) + def test_picker_open_maps_attach_failure_without_calling_session_dead(self) -> None: proof = self._prime(session_row("main2")) env = self.fixture.env() @@ -2149,8 +4609,10 @@ def test_unknown_uuidless_picker_takeover_and_close_are_proof_bound(self) -> Non self.fixture.shpool_log.unlink() closed = run([SP, "picker-close", proof], env=env) - self.assertIn("Closed exact session main9", closed.stdout) - self.assertEqual("kill main9\n", self.fixture.shpool_log.read_text()) + self.assertIn("Closed Unknown exact shell", closed.stdout) + self.assertEqual( + "attach-exit main9\n", self.fixture.shpool_log.read_text() + ) def test_unknown_provider_resolution_between_proofs_refuses_close(self) -> None: row = unknown_session_row("main9") @@ -2181,18 +4643,77 @@ def test_unknown_provider_resolution_between_proofs_refuses_close(self) -> None: self.assertIn("nothing closed", refused.stderr) self.assertFalse(self.fixture.shpool_log.exists()) - def test_picker_close_holds_lock_through_exact_name_kill(self) -> None: - proof = self._prime(session_row("main2", status="Attached")) + def test_picker_close_holds_lock_through_exact_shell_termination(self) -> None: + proof = self._prime(session_row("main2", status="Attached")) + env = self.fixture.env() + env.update( + { + "SESSION_KIT_CONFIRM_ID": "main2", + "FAKE_EXPECT_CREATE_LOCK": "locked", + } + ) + closed = run([SP, "picker-close", proof], env=env) + self.assertIn("Closed Codex fixture", closed.stdout) + self.assertEqual( + "attach-exit main2\n", self.fixture.shpool_log.read_text() + ) + + def test_picker_close_finishes_manager_entry_when_shell_died_first(self) -> None: + row = session_row("main2", status="Attached") + proof = self._prime(row) + dead = dict(row) + dead.update( + provider="unknown", + display_provider="unknown", + shpool_status="Disconnected", + availability="ready", + shpool_shell=None, + terminal_number=None, + mutation_allowed=False, + mutation_rejection_reason="missing-shell-generation", + identity={ + "uuid": None, + "pid": None, + "process_start_ticks": None, + "provenance": "none", + "confidence": "unknown", + }, + ) + dead_inventory = self.fixture.base / "dead-before-close.json" + dead_inventory.write_text( + json.dumps(inventory_document(dead)), encoding="utf-8" + ) + env = self.fixture.env() + env.update( + { + "SESSION_KIT_CONFIRM_ID": "main2", + "STUB_SECOND_INVENTORY": str(dead_inventory), + "STUB_EXACT_ALREADY_GONE": "1", + } + ) + + closed = run([SP, "picker-close", proof], env=env) + + self.assertEqual(0, closed.returncode, closed.stderr) + self.assertIn("Closed Codex fixture", closed.stdout) + self.assertEqual([], json.loads(self.fixture.shpool_state.read_text())["sessions"]) + self.assertEqual("attach-exit main2\n", self.fixture.shpool_log.read_text()) + + def test_picker_close_names_manager_entry_when_finalization_fails(self) -> None: + proof = self._prime(session_row("main2", status="Disconnected")) env = self.fixture.env() env.update( { "SESSION_KIT_CONFIRM_ID": "main2", - "FAKE_EXPECT_CREATE_LOCK": "locked", + "FAKE_ATTACH_FAIL": "1", } ) - closed = run([SP, "picker-close", proof], env=env) - self.assertIn("Closed exact session main2", closed.stdout) - self.assertEqual("kill main2\n", self.fixture.shpool_log.read_text()) + + failed = run([SP, "picker-close", proof], env=env, check=False) + + self.assertNotEqual(0, failed.returncode) + self.assertNotIn("Closed Codex fixture", failed.stdout) + self.assertIn("manager entry main2 remains", failed.stderr) def test_picker_close_without_exact_confirmation_never_kills(self) -> None: proof = self._prime(session_row("main2", status="Attached")) @@ -2202,7 +4723,7 @@ def test_picker_close_without_exact_confirmation_never_kills(self) -> None: check=False, ) self.assertNotEqual(0, refused.returncode) - self.assertIn("Close cancelled", refused.stdout) + self.assertIn("Nothing changed.", refused.stdout) self.assertFalse(self.fixture.shpool_log.exists()) def test_proof_file_and_schema_fail_closed(self) -> None: @@ -2284,7 +4805,7 @@ def test_changed_fingerprint_before_or_after_confirmation_never_kills(self) -> N self.assertIn("nothing closed", after.stderr) self.assertFalse(self.fixture.shpool_log.exists()) - def test_close_waits_for_creation_lock_before_final_proof_and_kill(self) -> None: + def test_close_waits_for_creation_lock_before_final_proof_and_signal(self) -> None: proof = self._prime(session_row("main2", status="Attached")) env = {**os.environ, **self.fixture.env()} env["SESSION_KIT_CONFIRM_ID"] = "main2" @@ -2304,7 +4825,9 @@ def test_close_waits_for_creation_lock_before_final_proof_and_kill(self) -> None fcntl.flock(held, fcntl.LOCK_UN) stdout, stderr = proc.communicate(timeout=5) self.assertEqual(0, proc.returncode, (stdout, stderr)) - self.assertEqual("kill main2\n", self.fixture.shpool_log.read_text()) + self.assertEqual( + "attach-exit main2\n", self.fixture.shpool_log.read_text() + ) def test_picker_history_requires_current_proof_and_preserves_journal(self) -> None: row = session_row("main2") @@ -2342,7 +4865,9 @@ def test_picker_name_uses_shared_alias_api_and_reset_preserves_config(self) -> N [SP, "picker-name", proof, " New\u001b title "], env=env, ) - self.assertIn("Named exact codex session main2", named.stdout) + self.assertIn("Named the exact codex session", named.stdout) + # A provider title can carry control bytes; none may reach the screen. + self.assertNotIn("\x1b", named.stdout) self.assertNotIn('"aliases"', named.stdout) stored = json.loads(self.fixture.config.read_text()) self.assertEqual(1, stored["schema_version"]) @@ -2366,7 +4891,7 @@ def test_picker_name_uses_shared_alias_api_and_reset_preserves_config(self) -> N ) reset = run([SP, "picker-name-reset", proof], env=env) - self.assertIn("Reset local name for exact codex session main2", reset.stdout) + self.assertIn("Reset the local name for the exact codex session", reset.stdout) self.assertNotIn('"aliases"', reset.stdout) stored = json.loads(self.fixture.config.read_text()) self.assertNotIn(f"codex:{row['identity']['uuid']}", stored["aliases"]) @@ -2415,15 +4940,15 @@ def test_public_name_and_reset_revalidate_exact_identity(self) -> None: row = session_row("main2", provider="claude") self._prime(row) env = self.fixture.env() - named = run([SP, "name", "main2", "Lyrics audit"], env=env) - self.assertIn("Named exact claude session main2", named.stdout) + named = run([SP, "name", "main2", "Config audit"], env=env) + self.assertIn("Named the Claude session", named.stdout) stored = json.loads(self.fixture.config.read_text()) self.assertEqual( - "Lyrics audit", stored["aliases"][f"claude:{row['identity']['uuid']}"] + "Config audit", stored["aliases"][f"claude:{row['identity']['uuid']}"] ) reset = run([SP, "name", "reset", "main2"], env=env) - self.assertIn("Reset local name for exact claude session main2", reset.stdout) + self.assertIn("Reset the local name for the Claude session", reset.stdout) self.assertNotIn( f"claude:{row['identity']['uuid']}", json.loads(self.fixture.config.read_text())["aliases"], @@ -2445,13 +4970,62 @@ def test_public_name_and_reset_revalidate_exact_identity(self) -> None: self.assertNotEqual(0, refused.returncode) self.assertIn("target changed", refused.stderr) + def test_public_name_uses_generation_bound_exited_provider_identity(self) -> None: + historical_uuid = "00000000-0000-4000-8000-000000000111" + row = session_row("main2", provider="shell", uuid="") + row.update( + { + "agent_status": "provider exited", + "display_provider": "claude", + "exited_provider": "claude", + "exited_identity": { + "confidence": "historical-exact", + "uuid": historical_uuid, + }, + "recovery": { + "available": True, + "provider": "claude", + "uuid": historical_uuid, + }, + } + ) + self._prime(row) + env = self.fixture.env() + named = run([SP, "name", "main2", "Exited Claude"], env=env) + self.assertIn("Named the Claude session", named.stdout) + stored = json.loads(self.fixture.config.read_text()) + self.assertEqual( + "Exited Claude", stored["aliases"][f"claude:{historical_uuid}"] + ) + + changed_uuid = "00000000-0000-4000-8000-000000000222" + changed = json.loads(json.dumps(row)) + changed["exited_identity"]["uuid"] = changed_uuid + changed["recovery"]["uuid"] = changed_uuid + changed_file = self.fixture.base / "changed-exited-name.json" + changed_file.write_text( + json.dumps(inventory_document(changed)), encoding="utf-8" + ) + if self.fixture.snapshot_count.exists(): + self.fixture.snapshot_count.unlink() + env["STUB_SECOND_INVENTORY"] = str(changed_file) + before = self.fixture.config.read_bytes() + refused = run( + [SP, "name", "main2", "Wrong exited target"], + env=env, + check=False, + ) + self.assertNotEqual(0, refused.returncode) + self.assertIn("target changed", refused.stderr) + self.assertEqual(before, self.fixture.config.read_bytes()) + def test_public_numeric_selector_uses_terminal_number_not_internal_row(self) -> None: row = session_row("main2", row=1, provider="claude") row["terminal_number"] = 27 self._prime(row) env = self.fixture.env() named = run([SP, "name", "27", "Stable selector"], env=env) - self.assertIn("Named exact claude session main2", named.stdout) + self.assertIn("Named the Claude session", named.stdout) stored = json.loads(self.fixture.config.read_text()) self.assertEqual( "Stable selector", @@ -2464,7 +5038,8 @@ def test_public_numeric_selector_uses_terminal_number_not_internal_row(self) -> check=False, ) self.assertNotEqual(0, refused.returncode) - self.assertIn("no unique session matches that selection", refused.stderr) + self.assertIn("no session matches that selector", refused.stderr) + self.assertEqual(2, refused.returncode) def test_picker_fork_proves_a_distinct_uuid_and_keeps_source_active(self) -> None: source_uuid = "00000000-0000-4000-8000-000000000001" @@ -2484,7 +5059,11 @@ def test_picker_fork_proves_a_distinct_uuid_and_keeps_source_active(self) -> Non } ) forked = run([SP, "picker-fork", proof], env=env) - self.assertIn(f"as {fork_uuid}", forked.stdout) + # A person is told what was forked, never which conversations were + # involved; the exact proof lives in the 0600 launch record. + self.assertIn("into a separate", forked.stdout) + self.assertNotIn(fork_uuid, forked.stdout) + self.assertNotIn(source_uuid, forked.stdout) state = json.loads(self.fixture.shpool_state.read_text()) self.assertEqual(2, len(state["sessions"])) self.assertTrue(any(item["name"] == "main2" for item in state["sessions"])) @@ -2494,6 +5073,101 @@ def test_picker_fork_proves_a_distinct_uuid_and_keeps_source_active(self) -> Non self.assertEqual(f"attach {generated[0]}\n", self.fixture.shpool_log.read_text()) self.assertFalse(any(self.fixture.start.glob(f"{generated[0]}*"))) + def fork_stamps(self) -> list[list[str]]: + """Origins recorded by a fork. A path that stamps nothing reads [].""" + if not self.fixture.origin_log.exists(): + return [] + return [ + json.loads(line)[1:] + for line in self.fixture.origin_log.read_text(encoding="utf-8").splitlines() + if line + ] + + def test_picker_fork_allocates_again_after_a_stale_name_collision(self) -> None: + source_uuid = "00000000-0000-4000-8000-000000000001" + fork_uuid = "00000000-0000-4000-8000-000000000002" + row = session_row("main2", provider="claude", uuid=source_uuid) + row["cwd"] = str(self.fixture.project) + proof = self._prime(row) + marker = self.fixture.base / "fork-name-collision" + env = self.fixture.env() + env.update( + { + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + "STUB_DYNAMIC_PROVIDER": "claude", + "STUB_DYNAMIC_AFTER_SESSIONS": "2", + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "STUB_DYNAMIC_UUID": fork_uuid, + "STUB_DYNAMIC_UUID_OVERRIDES": json.dumps({"main2": source_uuid}), + "FAKE_CREATE_COLLISION_ONCE": str(marker), + "STUB_DYNAMIC_SKIP_NAME_FILE": str(marker), + } + ) + forked = run([SP, "picker-fork", proof], env=env, check=False) + self.assertEqual(0, forked.returncode, forked.stderr) + collided = marker.read_text(encoding="utf-8") + self.assertIn(f"stale shpool entry {collided}", forked.stderr) + log = self.fixture.shpool_log.read_text(encoding="utf-8").splitlines() + self.assertEqual(f"collision {collided}", log[0]) + self.assertTrue(log[1].startswith("attach ")) + self.assertNotEqual(f"attach {collided}", log[1]) + created = log[1].removeprefix("attach ") + state = json.loads(self.fixture.shpool_state.read_text(encoding="utf-8")) + self.assertEqual( + {"main2", collided, created}, + {row["name"] for row in state["sessions"]}, + ) + + def test_picker_fork_says_who_opened_it_instead_of_staying_silent(self) -> None: + """Fork is the third door that creates a session. + + It wrote a launch record and attached without ever stamping an origin, + so a fork sat in the person's list because nothing said otherwise -- + including one asked for from inside a session, which is an agent's. + """ + source_uuid = "00000000-0000-4000-8000-000000000001" + row = session_row("main2", provider="claude", uuid=source_uuid) + row["cwd"] = str(self.fixture.project) + proof = self._prime(row) + env = self.fixture.env() + env.update( + { + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + "STUB_DYNAMIC_PROVIDER": "claude", + "STUB_DYNAMIC_AFTER_SESSIONS": "1", + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "STUB_DYNAMIC_UUID": "00000000-0000-4000-8000-000000000002", + "STUB_DYNAMIC_UUID_OVERRIDES": json.dumps({"main2": source_uuid}), + "SHPOOL_SESSION_NAME": "s20200102-050607-2000000", + } + ) + + run([SP, "picker-fork", proof], env=env) + + self.assertEqual([["machine"]], self.fork_stamps()) + + def test_picker_fork_from_the_picker_is_still_the_persons(self) -> None: + """The same door, opened the way the operator opens it.""" + source_uuid = "00000000-0000-4000-8000-000000000001" + row = session_row("main2", provider="claude", uuid=source_uuid) + row["cwd"] = str(self.fixture.project) + proof = self._prime(row) + env = self.fixture.env() + env.update( + { + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + "STUB_DYNAMIC_PROVIDER": "claude", + "STUB_DYNAMIC_AFTER_SESSIONS": "1", + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "STUB_DYNAMIC_UUID": "00000000-0000-4000-8000-000000000002", + "STUB_DYNAMIC_UUID_OVERRIDES": json.dumps({"main2": source_uuid}), + } + ) + + run([SP, "picker-fork", proof], env=env) + + self.assertEqual([["human"]], self.fork_stamps()) + def test_queued_picker_fork_rechecks_release_marker_under_creation_lock(self) -> None: source_uuid = "00000000-0000-4000-8000-000000000001" fork_uuid = "00000000-0000-4000-8000-000000000002" @@ -2563,7 +5237,11 @@ def test_picker_fork_allows_same_uuid_active_under_other_provider(self) -> None: } ) forked = run([SP, "picker-fork", proof], env=env) - self.assertIn(f"as {fork_uuid}", forked.stdout) + # A person is told what was forked, never which conversations were + # involved; the exact proof lives in the 0600 launch record. + self.assertIn("into a separate", forked.stdout) + self.assertNotIn(fork_uuid, forked.stdout) + self.assertNotIn(source_uuid, forked.stdout) state = json.loads(self.fixture.shpool_state.read_text()) self.assertEqual(2, len(state["sessions"])) @@ -2599,7 +5277,7 @@ def test_picker_fork_refuses_same_provider_duplicate_fork_uuid(self) -> None: ) refused = run([SP, "picker-fork", proof], env=env, check=False) self.assertNotEqual(0, refused.returncode) - self.assertIn("distinct exact claude fork was not proven", refused.stderr) + self.assertIn("distinct exact claude conversation was not proven", refused.stderr) state = json.loads(self.fixture.shpool_state.read_text()) self.assertEqual(2, len(state["sessions"])) generated = [ @@ -2658,7 +5336,7 @@ def test_picker_fork_uuid_collision_retains_source_and_new_session(self) -> None ) refused = run([SP, "picker-fork", proof], env=env, check=False) self.assertNotEqual(0, refused.returncode) - self.assertIn("distinct exact codex fork was not proven", refused.stderr) + self.assertIn("distinct exact codex conversation was not proven", refused.stderr) state = json.loads(self.fixture.shpool_state.read_text()) self.assertEqual(2, len(state["sessions"])) generated = [item["name"] for item in state["sessions"] if item["name"] != "main2"] @@ -2679,6 +5357,62 @@ def test_picker_fork_uuid_collision_retains_source_and_new_session(self) -> None ) ) + def test_picker_open_arms_bracketed_paste_on_a_real_terminal(self) -> None: + """The attach handover re-arms bracketed paste before shpool runs. + + shpool's restore replays screen contents only, never input modes, so + a reopened window lost bracketed paste: terminal paste protection + prompted on every multi-line paste and each newline submitted the + composer early. The picker path must emit the mode when stdout is a + terminal; the piped runs elsewhere in this file prove it stays silent + without one. + """ + import pty + import select + import time + + proof = self._prime(session_row("main2")) + env = os.environ.copy() + env.update(self.fixture.env()) + env["FAKE_EXPECT_CREATE_LOCK"] = "unlocked" + pid, descriptor = pty.fork() + if pid == 0: + # A child that cannot exec must never return: it is a forked copy + # of the test runner, and returning resumes the suite from here. + try: + os.execvpe(str(SP), [str(SP), "picker-open", str(proof)], env) + finally: + os._exit(127) + output = bytearray() + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + ready, _, _ = select.select([descriptor], [], [], 0.1) + if ready: + try: + chunk = os.read(descriptor, 65536) + except OSError: + break + if not chunk: + break + output.extend(chunk) + done, _ = os.waitpid(pid, os.WNOHANG) + if done == pid: + while True: + ready, _, _ = select.select([descriptor], [], [], 0) + if not ready: + break + try: + chunk = os.read(descriptor, 65536) + except OSError: + break + if not chunk: + break + output.extend(chunk) + break + os.close(descriptor) + self.assertIn(b"\x1b[?2004h", bytes(output)) + self.assertEqual("attach main2\n", self.fixture.shpool_log.read_text()) + class JournalHistoryTests(unittest.TestCase): def setUp(self) -> None: @@ -2711,7 +5445,7 @@ def test_zero_two_twenty_and_150mb_journals_remain_byte_exact(self) -> None: def test_control_sequences_and_multibyte_history_remain_byte_exact(self) -> None: payload = ( b"\x00\x01\x1b[2J\x1b[?1049h" - + "Lyrics: café, 東京, ☃\n".encode("utf-8") + + "Sample: café, 東京, ☃\n".encode("utf-8") + bytes(range(32)) ) journal = self.fixture.journals / "history.raw" @@ -2753,29 +5487,362 @@ def _history_hash(self) -> str: return proc.stdout.split()[0] -class ConfirmExactDrainTests(unittest.TestCase): - def test_confirm_drains_the_enter_after_the_keypress(self) -> None: - """y-then-Enter must not leave a newline for the caller's next read. +class HistoryRecallScreenTests(unittest.TestCase): + """The changed recall screens, driven through a real terminal.""" - The leftover Enter was consumed by the picker's menu read as an empty - choice, which means "go to a regular terminal" — killing a session - silently ended the picker every time. - """ + def setUp(self) -> None: + self.fixture = CommandFixture() + self.fixture.journals.mkdir() + self.fixture.archives.mkdir() + self.fixture.recovery.mkdir() + self.data = self.fixture.base / "data" + self.data.mkdir() + + def tearDown(self) -> None: + self.fixture.close() + + def env(self) -> dict[str, str]: + return { + **self.fixture.env(), + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_DATA_DIR": str(self.data), + "SESSION_KIT_HISTORY_SEARCH_TOOL": str( + REPO / "lib/sessionkit_inventory/history_search.py" + ), + "SESSION_KIT_HISTORY_INVENTORY": str(self.fixture.inventory), + "SESSION_KIT_TRANSCRIPT_TEXT_TOOL": str( + REPO / "lib/sessionkit_inventory/transcript_text.py" + ), + } + + def _pty(self, *argv: str, env: dict[str, str] | None = None) -> str: + import pty + import select + + child_env = os.environ.copy() + child_env.update(env or self.env()) + pid, descriptor = pty.fork() + if pid == 0: + try: + os.execvpe(str(SP), [str(SP), *argv], child_env) + finally: + os._exit(127) + output = bytearray() + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + ready, _, _ = select.select([descriptor], [], [], 0.1) + if ready: + try: + chunk = os.read(descriptor, 65536) + except OSError: + break + if not chunk: + break + output.extend(chunk) + done, _ = os.waitpid(pid, os.WNOHANG) + if done == pid: + break + else: + os.kill(pid, 9) + self.fail(f"PTY command did not finish: {argv!r}") + try: + os.waitpid(pid, 0) + except ChildProcessError: + pass + os.close(descriptor) + return output.decode("utf-8", "replace").replace("\r\n", "\n") + + def test_find_screen_uses_names_counts_and_the_exact_empty_state(self) -> None: + row = session_row("main2") + row["title"] = "Orphaned Record" + self.fixture.inventory.write_text( + json.dumps(inventory_document(row)), encoding="utf-8" + ) + history = self.fixture.journals / "main2.rendered.txt" + history.write_text("needle once\nneedle twice\n", encoding="utf-8") + shown = self._pty("find", "needle") + self.assertIn("Matches: 2", shown) + self.assertIn("Orphaned Record · 2 matches", shown) + self.assertNotIn(str(history), shown) + self.assertNotIn("main2", shown) + + empty = run([SP, "find", "absent"], env=self.env()) + self.assertEqual("Matches: none.\n", empty.stdout) + self.assertEqual("", empty.stderr) + + def test_find_searches_a_dash_leading_term_without_argparse_output(self) -> None: + history = self.fixture.journals / "main2.rendered.txt" + history.write_text("alpha --force beta\n", encoding="utf-8") + + shown = run([SP, "find", "--force"], env=self.env(), check=False) + + self.assertEqual(0, shown.returncode) + self.assertIn("Matches: 1\n", shown.stdout) + self.assertIn("alpha --force beta", shown.stdout) + self.assertEqual("", shown.stderr) + + def test_raw_fallback_keeps_stdout_exact_and_notices_on_stderr(self) -> None: + self.fixture.inventory.write_text( + json.dumps(inventory_document(session_row("history"))), encoding="utf-8" + ) + (self.fixture.journals / "history.raw").write_text( + "remembered output\n", encoding="utf-8" + ) + shown = self._pty("history", "history") + self.assertIn("This session has no clean recording", shown) + self.assertIn("remembered output", shown) + self.assertNotIn("session-kit:", shown) + + piped = run([SP, "history", "history"], env=self.env()) + self.assertEqual("remembered output\n", piped.stdout) + self.assertEqual( + "This session has no clean recording; showing the raw capture.\n", + piped.stderr, + ) + + def test_transcript_fallback_is_readable_on_a_real_terminal(self) -> None: + uuid = "00000000-0000-4000-8000-000000000001" + self.fixture.inventory.write_text( + json.dumps(inventory_document(session_row("history", uuid=uuid))), + encoding="utf-8", + ) + rollout = self.fixture.home / ".codex/sessions/2026/08/13" + rollout.mkdir(parents=True) + (rollout / f"rollout-2026-08-13T00-00-00-{uuid}.jsonl").write_text( + json.dumps({ + "type": "response_item", + "payload": {"type": "message", "role": "user", "content": [ + {"type": "input_text", "text": "Recall this request"} + ]}, + }) + "\n", + encoding="utf-8", + ) + shown = self._pty("history", "history") + self.assertIn("This session was never recorded", shown) + self.assertIn("Recall this request", shown) + self.assertNotIn("session-kit:", shown) + + def test_stale_sidecar_notice_needs_no_phantom_marker(self) -> None: + self.fixture.inventory.write_text( + json.dumps(inventory_document(session_row("history"))), encoding="utf-8" + ) + journal = self.fixture.journals / "history" + journal.mkdir() + capture = journal / "segment-000001.raw" + capture.write_text("unsafe raw capture\n", encoding="utf-8") + sidecar = journal / "rendered.txt" + sidecar.write_text("readable history\n", encoding="utf-8") + os.utime(sidecar, (1, 1)) + os.utime(capture, (4, 4)) + renderer = self.fixture.base / "successful-renderer" + write_executable(renderer, "#!/usr/bin/env python3\nraise SystemExit(0)\n") + env = {**self.env(), "SESSION_KIT_JOURNAL_RENDER_TOOL": str(renderer)} + shown = self._pty("history", "history", env=env) + self.assertIn("History rendering needs attention", shown) + self.assertIn("readable history", shown) + self.assertNotIn("unsafe raw capture", shown) + self.assertNotIn("session-kit:", shown) + + redirected = run([SP, "history", "history"], env=env) + self.assertEqual("readable history\n", redirected.stdout) + self.assertEqual( + "History rendering needs attention; showing the last readable version.\n", + redirected.stderr, + ) + + def test_stale_sidecar_notice_follows_pager_exit(self) -> None: + self.fixture.inventory.write_text( + json.dumps(inventory_document(session_row("history"))), encoding="utf-8" + ) + journal = self.fixture.journals / "history" + journal.mkdir() + capture = journal / "segment-000001.raw" + capture.write_text("raw capture\n", encoding="utf-8") + sidecar = journal / "rendered.txt" + sidecar.write_text("readable history\n", encoding="utf-8") + os.utime(sidecar, (1, 1)) + os.utime(capture, (4, 4)) + renderer = self.fixture.base / "successful-renderer" + write_executable(renderer, "#!/usr/bin/env python3\nraise SystemExit(0)\n") + fake_bin = self.fixture.base / "pager-bin" + fake_bin.mkdir() + write_executable( + fake_bin / "less", + "#!/usr/bin/env bash\ncat\nprintf 'PAGER EXIT\\n'\n", + ) + env = { + **self.env(), + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "SESSION_KIT_NONINTERACTIVE": "0", + "SESSION_KIT_JOURNAL_RENDER_TOOL": str(renderer), + } + shown = self._pty("history", "history", env=env) + self.assertIn("readable history", shown) + self.assertLess( + shown.index("History rendering needs attention"), + shown.index("readable history"), + ) + self.assertLess( + shown.index("PAGER EXIT"), + shown.rindex("History rendering needs attention"), + ) + self.assertEqual(2, shown.count("History rendering needs attention")) + + def test_capture_append_inside_grace_period_does_not_false_alarm(self) -> None: + self.fixture.inventory.write_text( + json.dumps(inventory_document(session_row("history"))), encoding="utf-8" + ) + journal = self.fixture.journals / "history" + journal.mkdir() + capture = journal / "segment-000001.raw" + capture.write_text("new live capture bytes\n", encoding="utf-8") + sidecar = journal / "rendered.txt" + sidecar.write_text("freshly rendered history\n", encoding="utf-8") + os.utime(sidecar, ns=(2_000_000_000, 2_000_000_000)) + os.utime(capture, ns=(2_000_600_000, 2_000_600_000)) + renderer = self.fixture.base / "successful-renderer-with-live-race" + write_executable(renderer, "#!/usr/bin/env python3\nraise SystemExit(0)\n") + env = {**self.env(), "SESSION_KIT_JOURNAL_RENDER_TOOL": str(renderer)} + + redirected = run([SP, "history", "history"], env=env) + + self.assertEqual("freshly rendered history\n", redirected.stdout) + self.assertEqual("", redirected.stderr) + + def test_fresh_sidecar_does_not_require_gnu_date(self) -> None: + self.fixture.inventory.write_text( + json.dumps(inventory_document(session_row("history"))), encoding="utf-8" + ) + journal = self.fixture.journals / "history" + journal.mkdir() + capture = journal / "segment-000001.raw" + capture.write_text("captured history\n", encoding="utf-8") + sidecar = journal / "rendered.txt" + sidecar.write_text("portable rendered history\n", encoding="utf-8") + os.utime(capture, ns=(1_000_000_000, 1_000_000_000)) + os.utime(sidecar, ns=(2_000_000_000, 2_000_000_000)) + renderer = self.fixture.base / "successful-portable-renderer" + write_executable(renderer, "#!/usr/bin/env python3\nraise SystemExit(0)\n") + fake_bin = self.fixture.base / "bsd-date-bin" + fake_bin.mkdir() + write_executable( + fake_bin / "date", + "#!/usr/bin/env bash\n" + "[[ ${1:-} != -r ]] || exit 1\n" + "exec /usr/bin/date \"$@\"\n", + ) + env = { + **self.env(), + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "SESSION_KIT_JOURNAL_RENDER_TOOL": str(renderer), + } + + redirected = run([SP, "history", "history"], env=env) + + self.assertEqual("portable rendered history\n", redirected.stdout) + self.assertEqual("", redirected.stderr) + + def test_failed_flush_notices_even_when_sidecar_is_not_older(self) -> None: + self.fixture.inventory.write_text( + json.dumps(inventory_document(session_row("history"))), encoding="utf-8" + ) + journal = self.fixture.journals / "history" + journal.mkdir() + capture = journal / "segment-000001.raw" + capture.write_text("raw capture\n", encoding="utf-8") + sidecar = journal / "rendered.txt" + sidecar.write_text("last readable history\n", encoding="utf-8") + os.utime(capture, (1, 1)) + os.utime(sidecar, (2, 2)) + renderer = self.fixture.base / "failed-renderer-current-sidecar" + write_executable(renderer, "#!/usr/bin/env python3\nraise SystemExit(1)\n") + env = {**self.env(), "SESSION_KIT_JOURNAL_RENDER_TOOL": str(renderer)} + + redirected = run([SP, "history", "history"], env=env) + + self.assertEqual("last readable history\n", redirected.stdout) + self.assertEqual( + "History rendering needs attention; showing the last readable version.\n", + redirected.stderr, + ) + + +class LockWaitTests(unittest.TestCase): + """A wedged holder must not stop the machine. + + `flock -x` with no bound meant one command stuck inside the daemon held + every attach, create and close behind it for as long as it stayed stuck. + """ + + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".lockwait-", dir=REPO) + self.base = Path(self.temp.name) + self.lock = self.base / "create.lock" + + def tearDown(self) -> None: + self.temp.cleanup() + + def _acquire(self, seconds: str) -> subprocess.CompletedProcess: + command = ( + f"source {COMMON}; exec 9>{self.lock}; sk_lock_acquire 9 {self.lock}" + ) + return subprocess.run( + ["bash", "-c", command], + env={ + **os.environ, + "HOME": str(self.base), + "SESSION_KIT_STATE_DIR": str(self.base / "state"), + "SESSION_KIT_LOCK_WAIT_SECONDS": seconds, + }, + text=True, + capture_output=True, + check=False, + ) + + def test_a_held_lock_refuses_within_the_bound_and_says_so(self) -> None: + with self.lock.open("a+") as held: + fcntl.flock(held, fcntl.LOCK_EX) + started = time.monotonic() + refused = self._acquire("1") + elapsed = time.monotonic() - started + self.assertNotEqual(0, refused.returncode) + self.assertIn("create.lock", refused.stderr) + self.assertIn("nothing changed", refused.stderr) + self.assertLess(elapsed, 20) + + def test_a_free_lock_is_still_taken(self) -> None: + self.assertEqual(0, self._acquire("5").returncode) + + +class ConfirmExactNeverAsksTests(unittest.TestCase): + """Nothing is asked, anywhere (operator decision, permanent). + + A person at a terminal is told what is about to happen and it happens. + Automation, which has nobody to tell, still has to name the exact session + it is acting on. + """ + + def _drive(self, typed: bytes) -> str: import pty import select import time script = ( "source bin/session_kit_common\n" - 'sk_confirm_exact "Close" "id1" "Title" "codex" && echo CONFIRMED\n' + 'sk_confirm_exact "Closing" "id1" "Title" "codex" "19" && echo CONFIRMED\n' "IFS= read -r next\n" 'echo "NEXT=[$next]"\n' ) pid, descriptor = pty.fork() if pid == 0: - os.chdir(REPO) - os.execvp("bash", ["bash", "-c", script]) - os.write(descriptor, b"y\nPROBE\n") + # A child that cannot exec must never return: it is a forked copy + # of the test runner, and returning resumes the suite from here. + try: + os.chdir(REPO) + os.execvp("bash", ["bash", "-c", script]) + finally: + os._exit(127) + os.write(descriptor, typed) output = bytearray() deadline = time.monotonic() + 10 while time.monotonic() < deadline: @@ -2803,10 +5870,66 @@ def test_confirm_drains_the_enter_after_the_keypress(self) -> None: output.extend(chunk) break os.close(descriptor) - text = output.decode("utf-8", "replace") + return output.decode("utf-8", "replace") + + def test_a_person_is_told_what_happens_and_is_never_asked(self) -> None: + """No question, no bracketed letters, and nothing typed is consumed. + + The action names the session and proceeds; the next read still sees + the person's own input, because nothing was eaten by a prompt. + """ + text = self._drive(b"PROBE\n") + self.assertNotIn("Confirm", text) + self.assertNotIn("[y/N]", text) + self.assertIn("Closing session 19 (Title).", text) self.assertIn("CONFIRMED", text) self.assertIn("NEXT=[PROBE]", text) + def test_a_pipe_is_not_a_person(self) -> None: + """Without a terminal the scripted contract applies: only the exact + SESSION_KIT_CONFIRM_ID confirms; piped keystrokes never do.""" + script = ( + "source bin/session_kit_common\n" + 'sk_confirm_exact "Closing" "id1" "Title" "codex" && echo CONFIRMED\n' + 'SESSION_KIT_CONFIRM_ID=id1 ' + 'sk_confirm_exact "Closing" "id1" "Title" "codex" && echo SCRIPTED\n' + ) + completed = subprocess.run( + ["bash", "-c", script], + cwd=REPO, + input="y\ny\n", + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + self.assertNotIn("CONFIRMED", completed.stdout) + self.assertIn("SCRIPTED", completed.stdout) + + def test_the_wrong_scripted_id_says_nothing_and_acts_on_nothing(self) -> None: + script = ( + "source bin/session_kit_common\n" + "SESSION_KIT_CONFIRM_ID=other " + 'sk_confirm_exact "Closing" "id1" "Title" "codex" "19" || echo REFUSED\n' + ) + completed = subprocess.run( + ["bash", "-c", script], + cwd=REPO, + text=True, + # The scripted contract IS the no-terminal one: sk_confirm_exact + # consults SESSION_KIT_CONFIRM_ID only when `-t 0` is false. An + # inherited stdin made that condition the launcher's to decide -- + # through a pipe the child had no terminal and refused, on a + # terminal it took the person branch and announced the close -- + # so pin the absence the assertion is about. + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + self.assertIn("REFUSED", completed.stdout) + self.assertNotIn("Closing", completed.stdout) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py index 4b24305..3905782 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,20 +8,18 @@ REPO = Path(__file__).resolve().parents[1] -def bounded_restore_settings(path: Path) -> dict[str, int]: +def restore_settings(path: Path) -> dict[str, object]: text = path.read_text(encoding="utf-8") - patterns = { - "restore_lines": ( - r"(?m)^session_restore_mode\s*=\s*" - r"\{\s*lines\s*=\s*([0-9]+)\s*\}\s*$" - ), + modes = re.findall(r"(?m)^session_restore_mode\s*=\s*(.+?)\s*$", text) + if len(modes) != 1: + raise ValueError(f"{path}: expected one session_restore_mode assignment") + values: dict[str, object] = {"mode": modes[0]} + for name, pattern in { "output_spool_lines": r"(?m)^output_spool_lines\s*=\s*([0-9]+)\s*$", "vt100_output_spool_width": ( r"(?m)^vt100_output_spool_width\s*=\s*([0-9]+)\s*$" ), - } - values: dict[str, int] = {} - for name, pattern in patterns.items(): + }.items(): matches = re.findall(pattern, text) if len(matches) != 1: raise ValueError(f"{path}: expected one {name} assignment") @@ -30,17 +28,21 @@ def bounded_restore_settings(path: Path) -> dict[str, int]: class ShpoolExampleConfigTests(unittest.TestCase): - def test_bounded_restore_contract(self) -> None: + def test_simple_restore_contract(self) -> None: + """Reattach must never replay spooled TUI frames. + + Replaying the vt100 spool interleaves cells from different frames of + a cell-diffing TUI (Claude Code, Codex), the braided corruption seen + live on 2026-08-12. "simple" emits nothing and lets the program + redraw itself; clean reading lives in `sp history`. The spool bounds + stay finite so daemon memory is bounded. + """ for relative in ("config/shpool.example.toml", "shpool/config.toml"): with self.subTest(relative=relative): - config = bounded_restore_settings(REPO / relative) - self.assertEqual(500, config["restore_lines"]) + config = restore_settings(REPO / relative) + self.assertEqual('"simple"', config["mode"]) self.assertEqual(1000, config["output_spool_lines"]) self.assertEqual(200, config["vt100_output_spool_width"]) - self.assertLessEqual( - config["restore_lines"], - config["output_spool_lines"], - ) if __name__ == "__main__": diff --git a/tests/test_coordination_broker.py b/tests/test_coordination_broker.py new file mode 100644 index 0000000..a47fe27 --- /dev/null +++ b/tests/test_coordination_broker.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path + + +# The broker and its message core live in a separate repository, not in this +# one. Point SESSION_KIT_COORDINATION_ROOT at a checkout that has them to run +# these tests; with it unset they skip. No default path is written here on +# purpose: a checkout location belongs to whoever made the checkout, so this +# file names no machine and the suite is green on a clean install. +COORDINATION_ROOT = os.environ.get("SESSION_KIT_COORDINATION_ROOT", "").strip() +BROKER = Path(COORDINATION_ROOT, ".claude/coordination/provider_broker.py") +CORE = Path(COORDINATION_ROOT, ".claude/coordination/coord_messages.py") +COORDINATION_AVAILABLE = bool(COORDINATION_ROOT) and BROKER.is_file() and CORE.is_file() +COORDINATION_SKIP_REASON = ( + "no coordination checkout: set SESSION_KIT_COORDINATION_ROOT to a directory " + "holding .claude/coordination/provider_broker.py and coord_messages.py" +) +SHPOOL_BASHRC = Path(__file__).resolve().parents[1] / "bashrc/shpool.bashrc" + + +def read_exact(connection: socket.socket, length: int) -> bytes: + data = bytearray() + while len(data) < length: + chunk = connection.recv(length - len(data)) + if not chunk: + raise EOFError + data.extend(chunk) + return bytes(data) + + +def read_client_frame(connection: socket.socket) -> dict[str, object]: + first, second = read_exact(connection, 2) + if first & 0x0F != 1 or not second & 0x80: + raise ValueError("expected masked text frame") + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", read_exact(connection, 2))[0] + elif length == 127: + length = struct.unpack("!Q", read_exact(connection, 8))[0] + mask = read_exact(connection, 4) + payload = read_exact(connection, length) + decoded = bytes(value ^ mask[index % 4] for index, value in enumerate(payload)) + return json.loads(decoded) + + +def send_server_frame(connection: socket.socket, payload: dict[str, object]) -> None: + encoded = json.dumps(payload, separators=(",", ":")).encode() + if len(encoded) < 126: + header = bytes((0x81, len(encoded))) + else: + header = bytes((0x81, 126)) + struct.pack("!H", len(encoded)) + connection.sendall(header + encoded) + + +class FakeAppServer(threading.Thread): + def __init__(self, path: Path, thread_id: str): + super().__init__(daemon=True) + self.path = path + self.thread_id = thread_id + self.turn_input = "" + self.ready = threading.Event() + self.finished = threading.Event() + self.error: Exception | None = None + + def run(self) -> None: + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + server.bind(os.fspath(self.path)) + server.listen(1) + self.ready.set() + connection, _ = server.accept() + with connection: + request = bytearray() + while b"\r\n\r\n" not in request: + request.extend(connection.recv(4096)) + headers = request.decode("ascii").split("\r\n") + key = next( + line.split(":", 1)[1].strip() + for line in headers + if line.lower().startswith("sec-websocket-key:") + ) + accept = base64.b64encode( + hashlib.sha1( + (key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode() + ).digest() + ).decode() + connection.sendall( + ( + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {accept}\r\n\r\n" + ).encode("ascii") + ) + while not self.finished.is_set(): + message = read_client_frame(connection) + method = message.get("method") + request_id = message.get("id") + if request_id is None: + continue + if method == "initialize": + result: dict[str, object] = {} + elif method == "thread/loaded/list": + result = {"data": [self.thread_id]} + elif method == "thread/read": + result = { + "thread": { + "id": self.thread_id, + "status": {"type": "idle"}, + } + } + elif method == "turn/start": + params = message["params"] + self.turn_input = params["input"][0]["text"] + result = {"turn": {"id": "turn-test", "status": "inProgress"}} + self.finished.set() + else: + raise ValueError(f"unexpected method: {method}") + send_server_frame(connection, {"id": request_id, "result": result}) + except Exception as exc: + self.error = exc + self.finished.set() + finally: + server.close() + + +@unittest.skipUnless(COORDINATION_AVAILABLE, COORDINATION_SKIP_REASON) +class CoordinationBrokerTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) / "project" + self.coordination = self.root / ".claude/coordination" + self.coordination.mkdir(parents=True) + (self.root / ".git").mkdir() + shutil.copy2(CORE, self.coordination / "coord_messages.py") + self.core = self.coordination / "coord_messages.py" + self.thread_id = "019fc000-0000-7000-8000-000000000001" + self.actor = f"codex:root:{self.thread_id}" + self.sender = "claude:test:root:sender" + self.call( + "register", self.actor, "--provider", "codex", "--native-id", + self.thread_id, "--lifecycle", "live", "--role", "root", + ) + self.call( + "register", self.sender, "--provider", "claude", "--native-id", + "sender", "--lifecycle", "live", "--role", "root", + ) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def call(self, *arguments: str) -> dict[str, object]: + result = subprocess.run( + [sys.executable, str(self.core), *arguments], + cwd=self.root, + text=True, + capture_output=True, + check=True, + ) + return json.loads(result.stdout) + + def test_exact_idle_thread_accepts_one_action_and_records_native_enqueue(self) -> None: + sent = self.call( + "send", self.sender, self.actor, "take this handoff", "--class", "action" + ) + socket_path = Path(self.temporary.name) / "app.sock" + server = FakeAppServer(socket_path, self.thread_id) + server.start() + self.assertTrue(server.ready.wait(2)) + broker = subprocess.Popen( + [ + sys.executable, str(BROKER), "--socket", str(socket_path), + "--repo", str(self.root), "--thread", self.thread_id, + "--server-pid", str(os.getpid()), "--poll-seconds", "0.1", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + self.assertTrue(server.finished.wait(3)) + deadline = time.monotonic() + 3 + state = "" + while time.monotonic() < deadline: + state = str(self.call("status", str(sent["message_id"]))["message"]["state"]) + if state == "native_enqueued": + break + time.sleep(0.05) + self.assertEqual("native_enqueued", state) + self.assertIn(str(sent["message_id"]), server.turn_input) + self.assertIn(self.actor, server.turn_input) + self.assertIsNone(server.error) + finally: + broker.terminate() + broker.wait(timeout=3) + assert broker.stdout is not None and broker.stderr is not None + broker.stdout.close() + broker.stderr.close() + + +class CodexAppServerLaunchGateTest(unittest.TestCase): + """Reads only this repository, so it runs on a machine with no broker.""" + + def test_launch_path_is_private_repo_gated_and_direct_fallback_remains(self) -> None: + source = SHPOOL_BASHRC.read_text(encoding="utf-8") + self.assertIn('config.get("codex_app_server") is not True', source) + self.assertIn("os.path.realpath(launch_cwd) != repo_root", source) + self.assertIn('__sk_codex_remote=()', source) + self.assertIn('echo "[session-kit: Codex App Server unavailable; using direct TUI]"', source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_daemon_identity.py b/tests/test_daemon_identity.py new file mode 100644 index 0000000..6d54d83 --- /dev/null +++ b/tests/test_daemon_identity.py @@ -0,0 +1,898 @@ +"""A second session manager on the box must not blind the kit to its own. + +On 15 August a review lane daemonised a sandboxed `shpool` of its own, holding +no sessions. The kit counted session managers by process name, found two, and +from that second on could identify none of the operator's ten live sessions: +every row read "Unresolved provider session", the home screen said +"Sessions: none", and a session they created came up as a bare shell because its +record could not be completed. Nothing was lost and nothing was closed -- the kit +refused rather than guessed -- but a stranger's process should never have been +able to cause it. + +Two answers to that were wrong, and reviewers proved both: + +* Resolving a name among the children of ANY daemon let a foreign daemon's child + take over one of their rows whenever their own shell's environment would not read. + The row then carried a foreign conversation's identity, passed strict + validation, and a close from it would have signalled the wrong process tree. +* Inferring each daemon's socket from its command line and environment is not + authoritative. The pinned shpool ignores `SHPOOL_SOCKET` entirely, and under + systemd socket activation it disregards `--socket` and serves the listener it + inherited, so either string can say the opposite of the truth and the real + daemon can be excluded in favour of a stranger. + +The kernel is asked instead: whichever daemon holds open the inode of the socket +this kit's client connects to is the one answering us. +""" + +from __future__ import annotations + +import os +import json +import subprocess +import sys +import tempfile +import unittest +from contextlib import contextmanager +from pathlib import Path +from unittest import mock + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO / "lib")) + +from sessionkit_inventory import processes as process_inventory # noqa: E402 +from sessionkit_inventory.render import render_picker_page # noqa: E402 +import session_inventory as inventory_core # noqa: E402 + +KIT_DAEMON = 10 +FOREIGN_DAEMON = 20 +INODE = 11065 + + +def daemon(pid: int, *, argv: list[str] | None = None) -> dict: + return { + "pid": pid, + "ppid": 1, + "comm": "shpool", + "cmdline": argv or ["/home/dev/.cargo/bin/shpool", "daemon"], + "start_ticks": pid * 10, + "session_name": "", + } + + +def shell(pid: int, ppid: int, name: str, *, unreadable: bool = False) -> dict: + return { + "pid": pid, + "ppid": ppid, + "comm": "bash", + "cmdline": ["-bash"], + "start_ticks": pid * 10, + "session_name": name, + "environ_unreadable": unreadable, + } + + +LISTENING = "00010000" +CONNECTED = "00000000" + + +@contextmanager +def kernel_says( + holders, + *, + listed: bool = True, + extra_rows=(), + also_holds=(), + unreadable=(), + unreadable_links=(), + raw_tail="", +): + """A fixture /proc where `holders` hold the socket this kit connects to. + + `extra_rows` adds further `/proc/net/unix` lines for the SAME path, which is + a real state: a daemon whose socket file was replaced keeps its row, and + every connected peer repeats the pathname. `also_holds` maps a pid to an + inode it holds, for building a stranger that holds one of those other rows. + """ + with tempfile.TemporaryDirectory(prefix=".proc-dm-", dir=REPO) as raw: + root = Path(raw) + runtime = root / "runtime" + (runtime / "shpool").mkdir(parents=True) + socket_path = runtime / "shpool" / "shpool.socket" + (root / "net").mkdir() + rows = ["Num RefCount Protocol Flags Type St Inode Path"] + for inode, flags in extra_rows: + rows.append( + f"0000000000000000: 00000002 00000000 {flags} 0001 01 " + f"{inode} {socket_path}" + ) + if listed: + rows.append( + f"0000000000000000: 00000002 00000000 {LISTENING} 0001 01 " + f"{INODE} {socket_path}" + ) + (root / "net" / "unix").write_text( + "\n".join(rows) + "\n" + raw_tail, encoding="utf-8" + ) + # Every candidate that can be checked needs a readable fd directory; + # absence is the explicit UNKNOWN state, not a negative holder answer. + for pid in (KIT_DAEMON, FOREIGN_DAEMON): + (root / str(pid) / "fd").mkdir(parents=True) + held = {pid: INODE for pid in holders} + held.update(dict(also_holds)) + for pid, inode in held.items(): + fds = root / str(pid) / "fd" + fds.mkdir(parents=True, exist_ok=True) + os.symlink("/dev/null", fds / "1") + os.symlink(f"socket:[{inode}]", fds / "3") + for pid in unreadable_links: + (root / str(pid) / "fd" / "unreadable").write_text( + "not a symlink", encoding="utf-8" + ) + for pid in unreadable: + fds = root / str(pid) / "fd" + for entry in fds.iterdir(): + entry.unlink() + fds.rmdir() + fds.write_text("not a directory", encoding="utf-8") + with mock.patch.dict( + os.environ, + { + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_PROC_ROOT": str(root), + "XDG_RUNTIME_DIR": str(runtime), + }, + ): + yield root + + +def roots_for(names, table): + return process_inventory.shpool_roots( + names, + table, + is_shpool_daemon=process_inventory._is_shpool_daemon, + ) + + +def generation_for(table): + return process_inventory.daemon_generation( + table, + is_shpool_daemon=process_inventory._is_shpool_daemon, + ) + + +def picker_screen(document: dict) -> str: + """Project through the real Bash view and render the real picker page.""" + with tempfile.TemporaryDirectory(prefix=".picker-dm-", dir=REPO) as raw: + root = Path(raw) + snapshot = root / "snapshot.json" + view = root / "view.json" + snapshot.write_text(json.dumps(document), encoding="utf-8") + command = r''' +set -euo pipefail +new_temp() { NEW_TEMP="$VIEW_TARGET"; } +MODULE_DIR="$REPO_TARGET/lib/sh" +SNAPSHOT="$SNAPSHOT_TARGET" +QUERY= +PICKER_GROUP_MODE=state +PICKER_MACHINE_EXPANDED=0 +SK_PROJECTS_FILE= +source "$REPO_TARGET/lib/sh/shpool_login_view.sh" +build_view +''' + subprocess.run( + ["bash", "-c", command], + check=True, + env={ + **os.environ, + "REPO_TARGET": str(REPO), + "SNAPSHOT_TARGET": str(snapshot), + "VIEW_TARGET": str(view), + }, + ) + projected = json.loads(view.read_text(encoding="utf-8")) + return render_picker_page( + projected, + page=1, + page_size=20, + style_enabled=False, + compact=False, + columns=100, + now_ms=100_000, + ) + + +def live_incident_table() -> tuple[dict, list[str]]: + """The shape measured on a live machine: ten sessions, one test fixture.""" + names = [f"s20200102-0304{index:02d}-{1000000 + index}" for index in range(10)] + table = { + KIT_DAEMON: daemon(KIT_DAEMON), + FOREIGN_DAEMON: daemon( + FOREIGN_DAEMON, + argv=[ + "/home/dev/.cargo/bin/shpool", + "--log-file", + "/home/dev/claude-tmp/lane/testhome/d.log", + "--socket", + "/home/dev/claude-tmp/lane/testhome/shpool.socket", + "daemon", + ], + ), + } + for offset, name in enumerate(names): + pid = 100 + offset + table[pid] = shell(pid, KIT_DAEMON, name) + return table, names + + +class TheMeasuredIncidentTest(unittest.TestCase): + def test_a_test_fixture_beside_the_real_daemon_hides_nothing(self) -> None: + table, names = live_incident_table() + with kernel_says([KIT_DAEMON]): + resolved, diagnostics = roots_for(names, table) + self.assertEqual(len(names), len(resolved), diagnostics) + for offset, name in enumerate(names): + self.assertEqual(100 + offset, resolved[name]) + self.assertEqual([], diagnostics[name]) + self.assertEqual(KIT_DAEMON, generation_for(table)["pid"]) + + def test_the_same_table_without_the_fixture_resolves_identically(self) -> None: + table, names = live_incident_table() + with kernel_says([KIT_DAEMON]): + with_fixture, _ = roots_for(names, table) + del table[FOREIGN_DAEMON] + with kernel_says([KIT_DAEMON]): + without_fixture, _ = roots_for(names, table) + self.assertEqual(without_fixture, with_fixture) + + +class CommandLinesAndEnvironmentsCannotLieTest(unittest.TestCase): + """A reviewer proved argv and environment are not authoritative. + + The pinned shpool never reads `SHPOOL_SOCKET`, and systemd socket activation + makes it ignore `--socket` and serve the listener it inherited. So a real + daemon can carry a foreign-looking command line and a stranger can carry + ours. Only the kernel knows which one is answering us. + """ + + def inverted_table(self) -> tuple[dict, list[str]]: + table, names = live_incident_table() + # Their real daemon names a socket that is not the one it serves. + table[KIT_DAEMON] = daemon( + KIT_DAEMON, + argv=["/usr/bin/shpool", "--socket", "/tmp/not-the-one.socket", "daemon"], + ) + # The stranger names ours, and has a readable namesake child of its own. + table[FOREIGN_DAEMON] = daemon( + FOREIGN_DAEMON, + argv=["/usr/bin/shpool", "--socket", "/run/user/1000/shpool/shpool.socket", + "daemon"], + ) + table[3001] = shell(3001, FOREIGN_DAEMON, names[0]) + return table, names + + def test_the_daemon_holding_our_socket_wins_whatever_argv_claims(self) -> None: + table, names = self.inverted_table() + with kernel_says([KIT_DAEMON]): + resolved, _ = roots_for(names, table) + self.assertEqual(100, resolved[names[0]]) + self.assertEqual(KIT_DAEMON, generation_for(table)["pid"]) + + def test_and_the_stranger_never_wins_by_naming_our_path(self) -> None: + table, names = self.inverted_table() + with kernel_says([KIT_DAEMON]): + resolved, _ = roots_for(names, table) + self.assertNotEqual(3001, resolved.get(names[0])) + + +class ForeignChildIsNeverAdoptedTest(unittest.TestCase): + """The round-1 defect two reviewers found, kept closed.""" + + def reviewer_table(self) -> dict: + return { + KIT_DAEMON: daemon(KIT_DAEMON), + FOREIGN_DAEMON: daemon(FOREIGN_DAEMON), + 1001: shell(1001, KIT_DAEMON, "", unreadable=True), + 3001: shell(3001, FOREIGN_DAEMON, "main"), + } + + def test_a_foreign_child_never_takes_an_unreadable_shells_name(self) -> None: + with kernel_says([KIT_DAEMON]): + resolved, diagnostics = roots_for(["main"], self.reviewer_table()) + self.assertEqual({}, resolved) + self.assertTrue( + any("unreadable environment" in line for line in diagnostics["main"]), + diagnostics, + ) + + def test_not_even_when_the_foreign_child_is_the_only_claimant(self) -> None: + table = self.reviewer_table() + del table[1001] + with kernel_says([KIT_DAEMON]): + self.assertEqual({}, roots_for(["main"], table)[0]) + + def test_a_foreign_grandchild_is_not_adopted_either(self) -> None: + table = self.reviewer_table() + table[3002] = shell(3002, 3001, "main") + with kernel_says([KIT_DAEMON]): + self.assertEqual({}, roots_for(["main"], table)[0]) + + def test_our_own_child_still_wins_beside_a_foreign_namesake(self) -> None: + table = self.reviewer_table() + table[1001] = shell(1001, KIT_DAEMON, "main") + with kernel_says([KIT_DAEMON]): + self.assertEqual({"main": 1001}, roots_for(["main"], table)[0]) + + +class WhenTheKernelCannotAnswerTest(unittest.TestCase): + """Unanswerable falls back to the old rule, never to a guess.""" + + def two_daemons(self) -> tuple[dict, list[str]]: + table, names = live_incident_table() + return table, names + + def test_no_holder_at_all_refuses_every_name(self) -> None: + table, names = self.two_daemons() + with kernel_says([]): + resolved, diagnostics = roots_for(names, table) + self.assertEqual({}, resolved) + self.assertTrue( + any("serving this kit" in line for line in diagnostics[names[0]]), + diagnostics, + ) + + def test_two_holders_refuse_rather_than_pick_one(self) -> None: + table, names = self.two_daemons() + with kernel_says([KIT_DAEMON, FOREIGN_DAEMON]): + self.assertEqual({}, roots_for(names, table)[0]) + self.assertIsNone(generation_for(table)) + + def test_a_socket_missing_from_the_kernel_table_refuses(self) -> None: + table, names = self.two_daemons() + with kernel_says([KIT_DAEMON], listed=False): + self.assertEqual({}, roots_for(names, table)[0]) + + def test_one_daemon_needs_no_kernel_lookup_at_all(self) -> None: + table, names = live_incident_table() + del table[FOREIGN_DAEMON] + with mock.patch.object( + process_inventory, "_listening_inodes", side_effect=AssertionError + ), mock.patch.object( + process_inventory, "_holds_any_socket", side_effect=AssertionError + ), mock.patch.object( + process_inventory, "default_proc_root", side_effect=AssertionError + ): + resolved, _ = roots_for(names, table) + generation = generation_for(table) + self.assertEqual(len(names), len(resolved)) + self.assertEqual(KIT_DAEMON, generation["pid"]) + + def test_no_daemon_at_all_says_so_plainly(self) -> None: + table = {100: shell(100, 1, "main")} + with kernel_says([]): + resolved, diagnostics = roots_for(["main"], table) + self.assertEqual({}, resolved) + self.assertTrue( + any("found 0" in line for line in diagnostics["main"]), diagnostics + ) + self.assertIsNone(generation_for(table)) + + +class StillRefusesWhatIsGenuinelyAmbiguousTest(unittest.TestCase): + def test_two_children_of_our_own_daemon_sharing_a_name_are_refused(self) -> None: + table = { + KIT_DAEMON: daemon(KIT_DAEMON), + 100: shell(100, KIT_DAEMON, "main"), + 101: shell(101, KIT_DAEMON, "main"), + } + with kernel_says([KIT_DAEMON]): + resolved, diagnostics = roots_for(["main"], table) + self.assertEqual({}, resolved) + self.assertTrue( + any("found 2" in line for line in diagnostics["main"]), diagnostics + ) + + def test_a_child_of_no_daemon_is_never_adopted(self) -> None: + table = {KIT_DAEMON: daemon(KIT_DAEMON), 100: shell(100, 999, "main")} + with kernel_says([KIT_DAEMON]): + self.assertEqual({}, roots_for(["main"], table)[0]) + + def test_a_shell_whose_own_argv_says_socket_is_not_a_daemon(self) -> None: + table, names = live_incident_table() + table[900] = { + **shell(900, KIT_DAEMON, "decoy"), + "cmdline": ["-bash", "--socket", "/tmp/x"], + } + with kernel_says([KIT_DAEMON]): + self.assertEqual(len(names), len(roots_for(names, table)[0])) + + +class SocketPathResolutionTest(unittest.TestCase): + def test_the_runtime_directory_is_preferred_and_home_is_the_fallback(self) -> None: + self.assertEqual( + "/run/user/1000/shpool/shpool.socket", + process_inventory._kit_socket_path( + {"XDG_RUNTIME_DIR": "/run/user/1000", "HOME": "/home/dev"} + ), + ) + self.assertEqual( + "/home/dev/.local/run/shpool/shpool.socket", + process_inventory._kit_socket_path({"HOME": "/home/dev"}), + ) + self.assertIsNone(process_inventory._kit_socket_path({})) + + def test_shpool_socket_is_not_consulted_because_shpool_ignores_it(self) -> None: + """Checked against the pinned source and the installed binary.""" + self.assertEqual( + "/run/user/1000/shpool/shpool.socket", + process_inventory._kit_socket_path( + { + "SHPOOL_SOCKET": "/tmp/shpool-does-not-read-this.socket", + "XDG_RUNTIME_DIR": "/run/user/1000", + } + ), + ) + + def test_a_trailing_slash_or_dots_resolve_to_the_same_path(self) -> None: + self.assertEqual( + process_inventory._kit_socket_path({"XDG_RUNTIME_DIR": "/run/user/1000"}), + process_inventory._kit_socket_path( + {"XDG_RUNTIME_DIR": "/run/user/1000/../1000/"} + ), + ) + + +class DuplicatePathRowsTest(unittest.TestCase): + """A pathname is not unique in /proc/net/unix. + + Three lanes found the same hole independently: reading only the first row + for our path let a stranger's inode stand in for ours, and the stranger's + daemon was then selected and its child attached to one of their rows. + """ + + def test_a_stale_listener_on_our_path_refuses_rather_than_guesses(self) -> None: + table, names = live_incident_table() + with kernel_says( + [KIT_DAEMON], + extra_rows=[(99001, LISTENING)], + also_holds={FOREIGN_DAEMON: 99001}, + ): + resolved, diagnostics = roots_for(names, table) + self.assertEqual({}, resolved) + self.assertTrue( + any("serving this kit" in line for line in diagnostics[names[0]]), + diagnostics, + ) + + def test_a_stranger_holding_a_stale_row_is_never_selected(self) -> None: + """The dangerous direction: the stranger must not win outright.""" + table, names = live_incident_table() + table[3001] = shell(3001, FOREIGN_DAEMON, names[0]) + with kernel_says( + [], + extra_rows=[(99001, LISTENING)], + also_holds={FOREIGN_DAEMON: 99001}, + ): + resolved, _ = roots_for(names, table) + self.assertNotEqual(3001, resolved.get(names[0])) + + def test_a_connected_peer_row_is_not_mistaken_for_the_listener(self) -> None: + table, names = live_incident_table() + with kernel_says( + [KIT_DAEMON], + extra_rows=[(99002, CONNECTED)], + also_holds={FOREIGN_DAEMON: 99002}, + ): + resolved, diagnostics = roots_for(names, table) + self.assertEqual(len(names), len(resolved), diagnostics) + self.assertEqual(KIT_DAEMON, generation_for(table)["pid"]) + + def test_two_listeners_on_our_path_refuse_even_with_one_holder(self) -> None: + """Ambiguity in the kernel table is ambiguity, whoever holds what.""" + table, names = live_incident_table() + with kernel_says([KIT_DAEMON], extra_rows=[(99001, LISTENING)]): + self.assertEqual({}, roots_for(names, table)[0]) + + +class AnotherAccountsDaemonTest(unittest.TestCase): + """A second account's shpool is not an unknown; it is not a candidate. + + Found live on a shared host, 2026-08-17: a second Unix account started + its own shpool daemon, and from then on this account could prove nothing. + Its `/proc/<pid>/fd` belongs to that account, so the socket-holder check + answered UNKNOWN, the uniqueness rule refused to name any daemon, and + every session on the board went unprovable, `sp new` produced + "Unresolved provider session" and `sp go`/`sp close` refused. The kernel + had already settled it: a listener under /run/user/<uid> is 0700, so a + daemon owned by another uid can never be the one answering us. + """ + + def foreign_uid(self) -> int: + return os.getuid() + 1 + + def test_another_accounts_daemon_is_not_a_candidate(self) -> None: + table, names = live_incident_table() + table[FOREIGN_DAEMON] = { + **table[FOREIGN_DAEMON], + "uid": self.foreign_uid(), + } + table[KIT_DAEMON] = {**table[KIT_DAEMON], "uid": os.getuid()} + self.assertEqual( + [KIT_DAEMON], + process_inventory._kit_daemons( + table, process_inventory._is_shpool_daemon + ), + ) + + def test_the_board_survives_an_unreadable_foreign_daemon(self) -> None: + """The exact live shape: their fd directory cannot be read at all.""" + table, names = live_incident_table() + table[FOREIGN_DAEMON] = { + **table[FOREIGN_DAEMON], + "uid": self.foreign_uid(), + } + table[KIT_DAEMON] = {**table[KIT_DAEMON], "uid": os.getuid()} + with kernel_says([KIT_DAEMON], unreadable=[FOREIGN_DAEMON]): + resolved, diagnostics = roots_for(names, table) + self.assertEqual(len(names), len(resolved), diagnostics) + self.assertEqual(KIT_DAEMON, generation_for(table)["pid"]) + + def test_an_unreadable_owner_stays_a_candidate(self) -> None: + """No uid on the row is not evidence against the process.""" + table, names = live_incident_table() + table[FOREIGN_DAEMON] = {**table[FOREIGN_DAEMON], "uid": None} + self.assertEqual( + [KIT_DAEMON, FOREIGN_DAEMON], + process_inventory._kit_daemons( + table, process_inventory._is_shpool_daemon + ), + ) + + def test_a_foreign_unreadable_argv_namesake_is_not_a_candidate(self) -> None: + """The other account's daemon whose command line we cannot read.""" + table, names = live_incident_table() + del table[FOREIGN_DAEMON] + table[KIT_DAEMON] = {**table[KIT_DAEMON], "uid": os.getuid()} + table[FOREIGN_DAEMON] = { + "pid": FOREIGN_DAEMON, + "ppid": 1, + "comm": "shpool", + "cmdline": [], + "argv_unreadable": True, + "start_ticks": FOREIGN_DAEMON * 10, + "session_name": "", + "uid": self.foreign_uid(), + } + self.assertEqual( + [KIT_DAEMON], + process_inventory._kit_daemons( + table, process_inventory._is_shpool_daemon + ), + ) + + +class UnknownKernelEvidenceTest(unittest.TestCase): + def assert_refuses(self, table, names) -> None: + self.assertEqual( + [KIT_DAEMON, FOREIGN_DAEMON], + process_inventory._kit_daemons( + table, process_inventory._is_shpool_daemon + ), + ) + self.assertEqual({}, roots_for(names, table)[0]) + self.assertIsNone(generation_for(table)) + + def test_unreadable_real_fd_directory_never_promotes_visible_coholder( + self, + ) -> None: + table, names = live_incident_table() + table[3001] = shell(3001, FOREIGN_DAEMON, names[0]) + with kernel_says( + [KIT_DAEMON, FOREIGN_DAEMON], unreadable=[KIT_DAEMON] + ): + self.assert_refuses(table, names) + self.assertNotEqual(3001, roots_for(names, table)[0].get(names[0])) + + def test_unreadable_fd_entry_is_unknown_not_a_negative(self) -> None: + table, names = live_incident_table() + with kernel_says( + [FOREIGN_DAEMON], unreadable_links=[KIT_DAEMON] + ): + self.assert_refuses(table, names) + + def test_unreadable_kernel_table_is_unknown(self) -> None: + table, names = live_incident_table() + with kernel_says([FOREIGN_DAEMON]) as root: + table_path = root / "net" / "unix" + table_path.unlink() + table_path.mkdir() + self.assertIsNone( + process_inventory._listening_inodes( + process_inventory._kit_socket_path(os.environ), root + ) + ) + self.assert_refuses(table, names) + + def test_partial_kernel_table_after_foreign_listener_refuses(self) -> None: + table, names = live_incident_table() + tail = "0000000000000000: 00000002 00000000 00010000 0001 01 22002" + with kernel_says( + [], + listed=False, + extra_rows=[(99001, LISTENING)], + also_holds={FOREIGN_DAEMON: 99001}, + raw_tail=tail, + ): + self.assert_refuses(table, names) + + def test_unreadable_shpool_argv_blocks_the_single_daemon_fast_path( + self, + ) -> None: + table = { + KIT_DAEMON: { + **daemon(KIT_DAEMON), + "cmdline": [], + "args_available": False, + }, + FOREIGN_DAEMON: daemon(FOREIGN_DAEMON), + 3001: shell(3001, FOREIGN_DAEMON, "main"), + } + with mock.patch.object( + process_inventory, + "_listening_inodes", + side_effect=AssertionError("unknown argv must refuse before selection"), + ): + self.assertEqual( + [KIT_DAEMON, FOREIGN_DAEMON], + process_inventory._kit_daemons( + table, process_inventory._is_shpool_daemon + ), + ) + self.assertEqual({}, roots_for(["main"], table)[0]) + self.assertIsNone(generation_for(table)) + + def test_unreadable_non_shpool_argv_does_not_disable_ordinary_fast_path( + self, + ) -> None: + table = { + KIT_DAEMON: daemon(KIT_DAEMON), + 99: { + "pid": 99, + "ppid": 1, + "comm": "bash", + "cmdline": [], + "args_available": False, + "start_ticks": 990, + }, + } + with mock.patch.object( + process_inventory, + "_listening_inodes", + side_effect=AssertionError("ordinary fast path read the kernel table"), + ): + self.assertEqual( + [KIT_DAEMON], + process_inventory._kit_daemons( + table, process_inventory._is_shpool_daemon + ), + ) + + +class PayloadBoundInventoryTest(unittest.TestCase): + def test_live_payload_observation_proves_the_one_daemon_socket_holder( + self, + ) -> None: + table = {KIT_DAEMON: daemon(KIT_DAEMON)} + original_inodes = process_inventory._listening_inodes + original_holds = process_inventory._holds_any_socket + with kernel_says([KIT_DAEMON]), mock.patch.object( + process_inventory, "_listening_inodes", wraps=original_inodes + ) as inodes, mock.patch.object( + process_inventory, "_holds_any_socket", wraps=original_holds + ) as holders: + self.assertEqual( + {"pid": KIT_DAEMON, "process_start_ticks": 100}, + inventory_core._payload_daemon_identity(table), + ) + self.assertEqual(1, inodes.call_count) + self.assertEqual(1, holders.call_count) + + def test_darwin_one_daemon_binding_keeps_its_native_fast_path(self) -> None: + table = {KIT_DAEMON: daemon(KIT_DAEMON)} + with mock.patch.object( + inventory_core, "_require_supported_platform", return_value="darwin" + ), mock.patch.object( + process_inventory, + "_listening_inodes", + side_effect=AssertionError("Darwin has no proc socket table"), + ): + self.assertEqual( + {"pid": KIT_DAEMON, "process_start_ticks": 100}, + inventory_core._payload_daemon_identity(table), + ) + + def test_exact_payload_binding_selects_only_that_daemon_tree(self) -> None: + table = { + KIT_DAEMON: daemon(KIT_DAEMON), + FOREIGN_DAEMON: daemon(FOREIGN_DAEMON), + 1001: shell(1001, KIT_DAEMON, "main"), + 3001: shell(3001, FOREIGN_DAEMON, "main"), + } + document = inventory_core.build_inventory( + { + "sessions": [ + { + "name": "main", + "status": "Disconnected", + "started_at_unix_ms": 1, + } + ] + }, + [], + table, + {}, + ({}, {}), + {"aliases": {}, "max_proc_nodes": 128, "max_proc_depth": 16}, + now=1_800_000_000, + daemon_binding={"pid": KIT_DAEMON, "process_start_ticks": 100}, + ) + row = document["sessions"][0] + self.assertEqual(KIT_DAEMON, document["daemon_generation"]["pid"]) + self.assertEqual(1001, row["shpool_shell"]["pid"]) + self.assertEqual("shell", row["provider"]) + self.assertIs(True, row["mutation_allowed"]) + self.assertTrue(inventory_core.strict_live_inventory(document)) + + def test_unbound_multi_daemon_payload_is_visible_but_unactionable(self) -> None: + table = { + KIT_DAEMON: daemon(KIT_DAEMON), + FOREIGN_DAEMON: daemon(FOREIGN_DAEMON), + 1001: shell(1001, KIT_DAEMON, "main"), + 3001: shell(3001, FOREIGN_DAEMON, "main"), + 2001: { + "pid": 2001, + "ppid": 1001, + "comm": "claude", + "cmdline": ["claude"], + "start_ticks": 20010, + "cwd": "/operator", + }, + 4001: { + "pid": 4001, + "ppid": 3001, + "comm": "claude", + "cmdline": ["claude"], + "start_ticks": 40010, + "cwd": "/foreign", + }, + } + shpool = { + "sessions": [ + { + "name": "main", + "status": "Disconnected", + "started_at_unix_ms": 1, + } + ] + } + claude = [ + { + "pid": 2001, + "sessionId": "11111111-1111-4111-8111-111111111111", + "cwd": "/operator", + "kind": "interactive", + "name": "Operator conversation", + "status": "busy", + "startedAt": 2, + }, + { + "pid": 4001, + "sessionId": "22222222-2222-4222-8222-222222222222", + "cwd": "/foreign", + "kind": "interactive", + "name": "Foreign conversation", + "status": "idle", + "startedAt": 3, + }, + ] + with kernel_says([FOREIGN_DAEMON]): + document = inventory_core.build_inventory( + shpool, + claude, + table, + {}, + ({}, {}), + {"aliases": {}, "max_proc_nodes": 128, "max_proc_depth": 16}, + now=1_800_000_000, + ) + row = document["sessions"][0] + self.assertEqual("main", row["shpool_id_raw"]) + self.assertEqual("unknown", row["provider"]) + self.assertEqual("Unresolved provider session", row["title"]) + self.assertIsNone(row["shpool_shell"]) + self.assertIsNone(row["identity"]["uuid"]) + self.assertIs(False, row["mutation_allowed"]) + self.assertIsNone(document["daemon_generation"]) + self.assertFalse(inventory_core.strict_live_inventory(document)) + rendered = picker_screen(document) + self.assertIn("1 session · 1 ready · 0 open elsewhere", rendered) + self.assertIn("Unresolved provider session", rendered) + self.assertIn("| UNK |", rendered) + self.assertNotIn("Sessions: none.", rendered) + + +class ChurnedCensusTest(unittest.TestCase): + """A process dying mid-scan must not unresolve the whole board. + + ``scan_process_table`` honestly records a hole whenever any process exits + between readdir and stat. On a busy host some scan is nearly always + churned, so a reader that treats "the census has a hole" as "there is no + daemon" turns routine churn into a fully unresolved picker. The kernel's + socket-holder answer is positive evidence about one inode and stands + regardless of who else died; only conclusions built on absence withdraw. + """ + + @staticmethod + def churned(table: dict) -> process_inventory.ProcessTable: + holed = process_inventory.ProcessTable(table) + holed.complete = False + return holed + + def test_socket_holder_proof_survives_a_churned_census(self) -> None: + table = self.churned({KIT_DAEMON: daemon(KIT_DAEMON)}) + with kernel_says([KIT_DAEMON]): + self.assertEqual( + [KIT_DAEMON], + process_inventory._kit_daemons( + table, + process_inventory._is_shpool_daemon, + require_socket_holder=True, + ), + ) + + def test_payload_identity_survives_a_churned_census(self) -> None: + table = self.churned({KIT_DAEMON: daemon(KIT_DAEMON)}) + with kernel_says([KIT_DAEMON]): + self.assertEqual( + {"pid": KIT_DAEMON, "process_start_ticks": 100}, + inventory_core._payload_daemon_identity(table), + ) + + def test_census_conclusions_still_refuse_on_a_churned_census(self) -> None: + table = self.churned({KIT_DAEMON: daemon(KIT_DAEMON)}) + with mock.patch.object( + process_inventory, + "_listening_inodes", + side_effect=AssertionError( + "census callers must refuse before the kernel" + ), + ): + self.assertEqual( + [], + process_inventory._kit_daemons( + table, process_inventory._is_shpool_daemon + ), + ) + + def test_churned_census_keeps_the_unreadable_fd_refusal(self) -> None: + table = self.churned( + { + KIT_DAEMON: daemon(KIT_DAEMON), + FOREIGN_DAEMON: daemon(FOREIGN_DAEMON), + } + ) + with kernel_says( + [KIT_DAEMON, FOREIGN_DAEMON], unreadable=[KIT_DAEMON] + ): + self.assertEqual( + [], + process_inventory._kit_daemons( + table, + process_inventory._is_shpool_daemon, + require_socket_holder=True, + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_doc_links.py b/tests/test_doc_links.py index 8076a4f..39c87d0 100644 --- a/tests/test_doc_links.py +++ b/tests/test_doc_links.py @@ -11,6 +11,20 @@ class DocumentationLinkTests(unittest.TestCase): + def test_claude_statusline_preservation_and_ledgers_are_documented(self) -> None: + changelog = (REPO / "CHANGELOG.md").read_text(encoding="utf-8") + updates = (REPO / "docs/update-and-rollback.md").read_text(encoding="utf-8") + uninstall = (REPO / "docs/uninstall.md").read_text(encoding="utf-8") + + for text in (changelog, updates): + self.assertIn("--force", text) + self.assertIn("claude-statusline-backups.json", text) + self.assertIn("claude-integration.json", text) + self.assertIn("claude-statusline-backups.json", uninstall) + self.assertIn("claude-integration.json", uninstall) + self.assertIn("restores", uninstall) + self.assertIn("session-kit-quota", uninstall) + def test_repository_local_links_resolve(self) -> None: result = subprocess.run( [str(CHECKER), str(REPO)], @@ -23,6 +37,120 @@ def test_repository_local_links_resolve(self) -> None: self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertIn("documentation link check passed", result.stdout) + def test_a_fragment_naming_no_heading_is_rejected(self) -> None: + """A renamed heading turns every link to it into a link to nowhere.""" + + with tempfile.TemporaryDirectory( + prefix="session-kit-doc-anchors.", + dir=REPO.parent, + ) as temporary: + root = Path(temporary) + (root / "guide.md").write_text("# Real Heading\n", encoding="utf-8") + (root / "README.md").write_text( + "# Top\n" + "[good](guide.md#real-heading)\n" + "[gone](guide.md#renamed-heading)\n" + "[here](#top)\n" + "[nowhere](#never-existed)\n", + encoding="utf-8", + ) + + result = subprocess.run( + [str(CHECKER), str(root)], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("no such heading: guide.md#renamed-heading", result.stdout) + self.assertIn("no such heading: #never-existed", result.stdout) + self.assertNotIn("real-heading", result.stdout) + self.assertNotIn("#top", result.stdout) + + def test_a_heading_inside_a_fence_is_not_a_heading(self) -> None: + """Otherwise a shell comment in an example invents anchors.""" + + with tempfile.TemporaryDirectory( + prefix="session-kit-doc-fences.", + dir=REPO.parent, + ) as temporary: + root = Path(temporary) + (root / "README.md").write_text( + "# Top\n```bash\n# Not A Heading\n```\n[bad](#not-a-heading)\n", + encoding="utf-8", + ) + + result = subprocess.run( + [str(CHECKER), str(root)], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("no such heading: #not-a-heading", result.stdout) + + def test_repeated_headings_get_numbered_anchors(self) -> None: + """GitHub suffixes the second one; a checker that does not would lie.""" + + with tempfile.TemporaryDirectory( + prefix="session-kit-doc-repeats.", + dir=REPO.parent, + ) as temporary: + root = Path(temporary) + (root / "README.md").write_text( + "# Notes\n## Detail\n## Detail\n" + "[first](#detail)\n[second](#detail-1)\n", + encoding="utf-8", + ) + + result = subprocess.run( + [str(CHECKER), str(root)], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_html_navigation_and_images_are_checked(self) -> None: + """The README's nav is <a href>, and every picture on it is <img src>.""" + + with tempfile.TemporaryDirectory( + prefix="session-kit-doc-html.", + dir=REPO.parent, + ) as temporary: + root = Path(temporary) + (root / "shot.png").write_bytes(b"\x89PNG\r\n") + (root / "README.md").write_text( + "# Top\n" + '<p><a href="#top">here</a> · <a href="#gone">nowhere</a></p>\n' + '<img src="shot.png" alt="">\n' + '<img src="missing.png" alt="">\n' + '<img src="https://example.com/x.png" alt="">\n' + '<img src="data:image/svg+xml;base64,AAAA" alt="">\n', + encoding="utf-8", + ) + + result = subprocess.run( + [str(CHECKER), str(root)], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("no such heading: #gone", result.stdout) + self.assertIn("missing target: missing.png", result.stdout) + self.assertNotIn("shot.png", result.stdout) + self.assertNotIn("example.com", result.stdout) + self.assertNotIn("data:image", result.stdout) + def test_missing_and_escaping_links_are_rejected(self) -> None: with tempfile.TemporaryDirectory( prefix="session-kit-doc-links.", diff --git a/tests/test_exit_closes_session.py b/tests/test_exit_closes_session.py new file mode 100644 index 0000000..b85f954 --- /dev/null +++ b/tests/test_exit_closes_session.py @@ -0,0 +1,520 @@ +"""A clean provider exit closes the session; a crash reopens it once. + +Contract (operator rule, 2026-08-11, replacing the stay-alive rule of the same +day): `/exit`, the provider ending with status 0, is the operator saying +"done here". The managed shell ends with it, which ends the shpool session, +frees its terminal number into the ordinary quarantine, and lands the person +back at the picker. Nothing is left behind to reopen, and `/kit` is the verb +for the other intention: leave the conversation running and walk away. + +A CRASH heals itself (D14). The conversation is reopened once, with one line +saying so; a second crash within a minute of that reopen stops there and hands +the window back to the picker with the session still open. No question is +asked on either path, no screen in the kit asks one. + +Every deliberate close, the clean exit and `bye`, records intent, so the +crash queue can tell "somebody closed this" from "this was lost". +tests/test_close_intent.py owns that queue's side of the contract. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from tests.test_lifecycle_shell import ProviderExitShellHarness, UUID + +REPO = Path(__file__).resolve().parents[1] + + +class CleanExitClosesTests(ProviderExitShellHarness): + def test_a_clean_exit_closes_without_a_menu_and_says_so(self) -> None: + completed = self.launch("") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("Codex exited. Closing this session.", completed.stdout) + self.assertNotIn("Provider exited:", completed.stdout) + self.assertNotIn("crashed", completed.stdout) + # The shell really ended rather than returning to the sourcing caller: + # that is what ends the shpool session and frees the number. + self.assertNotIn("SOURCE_RETURNED", completed.stdout) + + def test_a_clean_exit_records_the_intent_to_close(self) -> None: + completed = self.launch("") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertEqual([f"codex:{UUID}"], sorted(self.close_intents())) + + def test_the_shipped_bashrc_no_longer_reads_the_retired_opt_in(self) -> None: + # One policy. A machine that still has the old marker file must not + # get a second behaviour out of it. + from tests.test_lifecycle_shell import BASHRC + + text = BASHRC.read_text(encoding="utf-8") + self.assertNotIn("sk_autoclose_on_clean_exit", text) + self.assertNotIn("sk_keep_exit_menu", text) + + def test_bye_closes_without_a_question_and_without_an_id(self) -> None: + """The line an operator meets most often broke two rules at once. + + It printed the raw shpool id -- which no screen may do -- and asked + `[y/N]`, which no screen may do either. It closes and reports. + """ + from tests.test_lifecycle_shell import BASHRC + + text = BASHRC.read_text(encoding="utf-8") + body = text[text.index(" bye() {") : text.index(" bye() {") + 400] + self.assertIn("Closed this session.", body) + self.assertNotIn("SHPOOL_SESSION_NAME", body) + self.assertNotIn("[y/N]", body) + self.assertNotIn("read -r", body) + self.assertNotIn("Close cancelled", text) + + +class CrashSelfHealsTests(ProviderExitShellHarness): + def test_a_crash_reopens_the_conversation_once_and_says_so(self) -> None: + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("Codex exited with status 3", completed.stdout) + self.assertEqual(1, completed.stdout.count("Codex crashed. Reopened.")) + # A reopen the guard refuses hands the window back in one step, with + # the session still open and still reopenable. No picker is reachable + # in this harness, so the hand-back falls to a shell. + self.assertIn("nothing reopened", completed.stderr) + self.assertIn("Shell opened", completed.stdout) + self.assertIn("SOURCE_RETURNED", completed.stdout) + # Nothing was closed, so nothing is tombstoned. + self.assertEqual([], sorted(self.close_intents())) + + def test_a_closed_crash_comes_back_as_the_conversation_not_as_history( + self, + ) -> None: + """The hard condition on closing a crashed session. + + Closing is only better than the husk if the conversation survives it. + Their session 99 was a Claude session whose provider had exited, and the + close recorded it as a `shell` row with no uuid -- "history only", + scrollback and nothing else, unrestorable as a conversation. Ending a + crashed session into THAT shape would be worse than leaving it open, + because the session that still knew the conversation would be gone. + + So this asserts what a person gets back: the exact conversation, by + uuid, under its real name, marked restorable by the same reader the + picker and `sp recover` use. + """ + # R3: the row it lands in carries the session's REAL name. The shell + # closing itself knows only its own ID, so the name comes from the + # last list the collector wrote -- assert against a session that has + # one, or an empty title passes every "not a placeholder" check. + self.remembered_name("Exit Ruling Work") + self.provider_transcript() + self.reopen_answers(76) + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("the conversation is in Closed sessions", completed.stdout) + + ledger = ( + self.home / ".local/share/session-kit/closed-sessions.jsonl" + ) + self.assertTrue(ledger.is_file(), "no closed-sessions row was written") + rows = [ + json.loads(line) + for line in ledger.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + self.assertEqual(1, len(rows), rows) + row = rows[0] + # The conversation, not a shell husk of it. + self.assertEqual("codex", row["provider"]) + self.assertEqual(UUID, row["uuid"]) + # Its real name, not "the shell session", "v2-35", "Idle shell", or + # the machine-made "Codex in <dir> at <time>". + self.assertEqual("Exit Ruling Work", row["title"]) + self.assertEqual(str(self.project), row["cwd"]) + + # And the reader every restore surface goes through agrees it can be + # brought back, rather than listing it as history. + listed = self.closed_sessions_list() + self.assertEqual(1, len(listed), listed) + self.assertTrue(listed[0]["restorable"], listed[0]) + self.assertEqual(UUID, listed[0]["uuid"]) + self.assertEqual("Exit Ruling Work", listed[0]["title"]) + + def test_a_momentary_record_failure_is_retried_not_made_permanent( + self, + ) -> None: + """One failed write used to strand the row forever. + + The lifecycle record is what makes a row say "provider exited", and + automatic cleanup only ever considers a row that says it. A single + failed attempt therefore left a session that looked like an ordinary + idle shell for good: nothing closed it, and nothing said why. + """ + log = self.provider_exit_record_fails(times=1) + completed = self.launch("") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertEqual(2, len(log.read_text(encoding="utf-8").split())) + # The retry succeeded, so the ordinary clean-exit close still happened. + self.assertIn("Codex exited. Closing this session.", completed.stdout) + self.assertEqual([f"codex:{UUID}"], sorted(self.close_intents())) + + def test_a_record_that_cannot_be_written_says_so_and_hands_back( + self, + ) -> None: + """When both attempts fail, the person is told AND returned. + + This test was named "...and hands back" and never asserted the hand + back. It could not have passed if it had: the call sat ~300 lines + above the function's definition in a file that runs top to bottom, so + the one path meant to stop the shell dropping into a prompt nobody + asked for printed `__sk_leave_after_provider_exit: command not found` + and dropped into a prompt nobody asked for (found in review, + 2026-08-15). A test that asserts a message but not the action is how + that survives a suite. + """ + self.provider_exit_record_fails() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("its record could not be written", completed.stderr) + self.assertIn("Close it from the picker", completed.stdout) + # THE HAND-BACK ITSELF. No picker is reachable in this fixture, so it + # falls to the managed shell -- which is still a hand-back, and still + # says so, unlike a bare prompt. + self.assertIn("Shell opened", completed.stdout) + self.assertNotIn("command not found", completed.stderr) + # Nothing was tombstoned: there was no proof to tombstone. + self.assertEqual([], sorted(self.close_intents())) + + def test_asking_whether_a_close_would_keep_the_conversation_closes_nothing( + self, + ) -> None: + """The question must not be the answer. + + `lifecycle closed` used to do two jobs at once: when the session had + no exact conversation it wrote a history-only `shell` row on the + closed list AND reported that the conversation was not kept. The + crash path calls it to ASK, so asking alone put a row on the Closed + list for a session that is still open and still running -- a close + the person never made, listed under a session they can still type + into. A Codex session started with `sp new` is exactly that shape: + the thread ID is allocated inside the TUI, so the shell never has it. + + Asking now changes nothing at all. + """ + self.without_a_conversation() + self.reopen_answers(76) + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + # It stayed open: the session is still the only thing that knows + # which conversation this was. + self.assertIn("SOURCE_RETURNED", completed.stdout) + ledger = self.home / ".local/share/session-kit/closed-sessions.jsonl" + self.assertFalse( + ledger.exists(), + "asking put a closed row on the list for a session that is still open:" + f" {ledger.read_text(encoding='utf-8') if ledger.exists() else ''}", + ) + self.assertEqual([], sorted(self.close_intents())) + + def test_a_failed_history_only_append_is_reported_not_discarded(self) -> None: + """A shell-history close uses the same checked success choke point.""" + + self.without_a_conversation() + self.ledger_cannot_be_written() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("shell-history row could not be added", completed.stderr) + self.assertNotIn("stays in the recovery list", completed.stderr) + self.assertEqual([], self.ledger_rows()) + self.assertEqual([], sorted(self.close_intents())) + + def test_a_session_kept_open_says_why_in_words(self) -> None: + """Keeping it is a decision, so it is explained, not silent. + + "Back to the picker" left the person with a row that would sit in + the list until the 72-hour reaper and no idea why this one did not + close like the others. + """ + self.without_a_conversation() + self.reopen_answers(76) + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn( + "could not promise the conversation back", + completed.stdout + completed.stderr, + ) + + def test_a_session_marked_keep_is_not_closed_by_a_crash(self) -> None: + """`keep_session` outranks the automatic close, and says so. + + `keep_session` means "automatic cleanup is off for this session", and + the reaper has always obeyed it (reaper.py `_safe_candidate` requires + `provider_exit_keep is False`). Turning the crash hand-back into a + close created a SECOND automatic closer, and it did not obey anything + -- a session the person had deliberately pinned would have ended + because its provider died. It stays, and the reason it stays is the + real one rather than the one that fits the other case. + + `bye` and a clean provider exit still close a kept session: those are + the person asking, and keep was never about them. + """ + log = self.reopen_answers(76) + self.keep_this_session() + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("SOURCE_RETURNED", completed.stdout) + self.assertIn("because you asked to keep it", completed.stdout) + self.assertNotIn("could not promise the conversation back", completed.stdout) + self.assertNotIn("Closing this session", completed.stdout) + # Nothing closed, so nothing tombstoned and nothing on the list. + self.assertEqual([], sorted(self.close_intents())) + self.assertFalse( + (self.home / ".local/share/session-kit/closed-sessions.jsonl").exists() + ) + # It really did reach the close decision rather than stopping earlier. + self.assertEqual(["reopen"], log.read_text(encoding="utf-8").split()) + + def test_a_clean_exit_says_when_the_conversation_cannot_come_back( + self, + ) -> None: + """A close the PERSON asked for still happens, and still says so. + + The automatic close refuses when the conversation cannot be read + back. A clean `/exit` is the person deciding, so it goes ahead -- but + the ledger row it writes will be filtered straight out of Closed + sessions as unrestorable, and letting them walk away believing + otherwise is the same broken promise in a politer voice. + """ + completed = self.launch("") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("Codex exited. Closing this session.", completed.stdout) + self.assertIn("cannot be read back from this machine", completed.stderr) + + def test_a_clean_exit_with_a_readable_conversation_says_nothing_extra( + self, + ) -> None: + """...and stays quiet when the promise is good.""" + self.provider_transcript() + completed = self.launch("") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("Codex exited. Closing this session.", completed.stdout) + self.assertNotIn("cannot be read back", completed.stderr) + self.assertNotIn("Note:", completed.stderr) + + def test_a_record_from_a_previous_shell_of_this_id_closes_nothing( + self, + ) -> None: + """A session id is not an identity; a shell generation is. + + shpool ids get reused. `main2` closes, a new `main2` opens, and the old + lifecycle document survives until a collector pass prunes it. The close + loaded that document by session id alone -- `_prove_lifecycle_caller` + checks the CALLER against /proc and says nothing about the record -- + so a fresh shell could tombstone the previous occupant's conversation + and put its own name on the row. `update_state` had refused exactly + this since it was written; the close had not (found in review, + 2026-08-15). + """ + self.remembered_name("Exit Ruling Work") + self.provider_transcript() + self.reopen_answers(76) + self.lifecycle_record_from_another_generation() + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + # Nothing of the previous generation was touched. + self.assertEqual([], sorted(self.close_intents())) + self.assertEqual([], self.closed_sessions_list()) + self.assertIn("SOURCE_RETURNED", completed.stdout) + + def test_a_reopen_refusal_never_closes_even_when_everything_else_is_ready( + self, + ) -> None: + """A refusal is not evidence that a conversation is finished. + + `lifecycle reopen` refuses for six different reasons, and one of them + is that the trusted inventory says A PROVIDER IS ALREADY RUNNING in + this terminal. The shell used to fold every status that was not 76 + into the close path without reading the reason -- so the code proved a + provider was alive and then ended the session anyway (found in review, + 2026-08-15). + + Everything else here is ready: exact conversation, readable + transcript, real name, writable ledger. The close still must not + happen, because the one missing thing is any evidence the conversation + is over. + """ + self.remembered_name("Exit Ruling Work") + self.provider_transcript() + self.reopen_answers(1) + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + # The session is still here. + self.assertIn("SOURCE_RETURNED", completed.stdout) + self.assertNotIn("Closing this session", completed.stdout) + # Nothing was recorded in either register. + self.assertEqual([], sorted(self.close_intents())) + self.assertEqual([], self.closed_sessions_list()) + # And the person is told which of the two facts is missing. + self.assertIn("a refusal is not proof", completed.stdout) + + def test_a_ledger_that_cannot_be_written_keeps_the_session(self) -> None: + """The bitter one: the fix for the husk carried the husk's own bug. + + `_record_closed_session` catches an append failure and answers + `recorded: false`; the close verb threw that answer away and printed + `recorded: true` regardless. The shell read that as permission, told + the person "the conversation is in Closed sessions", and ended the + session -- while the tombstone it had already written told crash + recovery not to offer the conversation either. Reachable from nowhere + at all, which is the exact outcome this branch exists to prevent + (found in review, 2026-08-15). + + The ledger row is written FIRST now and its result is read. No row + means no tombstone and no close. + """ + self.remembered_name("Exit Ruling Work") + self.provider_transcript() + self.ledger_cannot_be_written() + self.reopen_answers(76) + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + # It stayed, and it did not claim the conversation had been filed. + self.assertIn("SOURCE_RETURNED", completed.stdout) + self.assertNotIn("the conversation is in Closed sessions", completed.stdout) + # NOTHING was tombstoned: a tombstone with no row is the trap. + self.assertEqual([], sorted(self.close_intents())) + self.assertIn("Closed sessions list could not be written", completed.stdout) + + def test_a_conversation_with_no_transcript_on_this_machine_is_not_closed( + self, + ) -> None: + """A valid UUID is not a conversation you can get back. + + The probe proved only that the id was well-formed. The closed-sessions + list drops any row whose transcript this machine cannot read, so + closing on the id alone ended the live session AND produced a row the + person would never see (found in review, 2026-08-15). + """ + self.remembered_name("Exit Ruling Work") + self.reopen_answers(76) + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("SOURCE_RETURNED", completed.stdout) + self.assertEqual([], sorted(self.close_intents())) + self.assertEqual([], self.closed_sessions_list()) + self.assertIn("cannot be read back from this machine", completed.stdout) + + def test_a_transcript_that_exists_but_cannot_be_read_is_not_closed( + self, + ) -> None: + """Locating a file is not reading it. + + A transcript with mode 000 -- a botched chmod, an archive restored + without its bits, a file owned by another account -- was located + happily, reported `restorable: true`, and would have been closed on. + The floor is an actual read now, in the close AND in the list. + """ + self.remembered_name("Exit Ruling Work") + self.provider_transcript(mode=0o000) + self.reopen_answers(76) + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("SOURCE_RETURNED", completed.stdout) + self.assertEqual([], sorted(self.close_intents())) + self.assertIn("cannot be read back from this machine", completed.stdout) + + def test_a_session_that_never_told_the_shell_its_uuid_can_still_close( + self, + ) -> None: + """The husk class that could never be closed at all. + + A provider-exit record with no conversation had no way out. The + shell would not close it, because closing would have lost the + conversation; and the reaper's auto-close refuses any candidate + without a uuid (reaper.py `_safe_candidate`), so the 72-hour + backstop would never reach it either. Every Codex session started + with `sp new` whose provider then died is this shape, and each one + stayed in the list until somebody cleared it by hand. + + The conversation was not actually unknown. The collector proved it + while the provider was live and kept it against exactly this + handoff, bound to this boot, this shell PID and this shell start. + The close reads that record now, so the session ends and the + conversation is restorable by name. + """ + self.without_a_conversation() + self.remembered_name("Exit Ruling Work") + self.provider_transcript() + self.reopen_answers(76) + self.collector_kept_the_exact_conversation() + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + # It closed: the shell ended instead of returning to its caller. + self.assertIn("the conversation is in Closed sessions", completed.stdout) + self.assertNotIn("SOURCE_RETURNED", completed.stdout) + # And it closed as the conversation, under its real name. + self.assertEqual([f"codex:{UUID}"], sorted(self.close_intents())) + listed = self.closed_sessions_list() + self.assertEqual(1, len(listed), listed) + self.assertEqual("codex", listed[0]["provider"]) + self.assertEqual(UUID, listed[0]["uuid"]) + self.assertEqual("Exit Ruling Work", listed[0]["title"]) + self.assertTrue(listed[0]["restorable"], listed[0]) + + def test_no_screen_offers_a_letter_menu_after_a_crash(self) -> None: + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + for banned in ("[r]", "[p]", "[s]", "[k]", "[c]", "Choice:", "Unknown choice"): + self.assertNotIn(banned, completed.stdout, banned) + + def test_a_second_crash_inside_a_minute_stops_at_the_picker(self) -> None: + self.provider_transcript() + log = self.reopen_answers(76) + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertEqual(1, completed.stdout.count("Codex crashed. Reopened.")) + # CONTRACT INVERTED, deliberately (operator ruling, 2026-08-15): "the + # session closes at once and lands in Closed sessions, recoverable, + # under its REAL name." This used to hand the window back with the + # session still alive and assert that NOTHING was tombstoned; the row + # then sat in their list saying "provider exited" until the 72-hour + # reaper. It closes now -- but only when the close keeps the + # conversation, which the next test proves. + self.assertIn( + "Codex crashed twice. Closing this session; the conversation is in" + " Closed sessions.", + completed.stdout, + ) + # Exactly one reopen: the loop stops instead of running forever. + self.assertEqual(["reopen"], log.read_text(encoding="utf-8").split()) + self.assertEqual([f"codex:{UUID}"], sorted(self.close_intents())) + + def test_a_reopened_conversation_that_exits_cleanly_closes_the_session( + self, + ) -> None: + log = self.reopen_answers(0) + self.crashing_provider() + completed = self.launch("") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("Codex crashed. Reopened.", completed.stdout) + self.assertIn("Codex exited. Closing this session.", completed.stdout) + self.assertNotIn("SOURCE_RETURNED", completed.stdout) + self.assertEqual(["reopen"], log.read_text(encoding="utf-8").split()) + # A conversation the operator finished is a deliberate close. + self.assertEqual([f"codex:{UUID}"], sorted(self.close_intents())) + + +if __name__ == "__main__": + import unittest + + unittest.main() diff --git a/tests/test_facade.py b/tests/test_facade.py index 330c7fe..20c3c1c 100644 --- a/tests/test_facade.py +++ b/tests/test_facade.py @@ -13,87 +13,137 @@ from tests.support import REPO +# Two tests below import sessionkit_inventory directly. They passed only +# because some earlier module in the same process had already put lib on the +# shared sys.path -- run this module on its own and both raise +# ModuleNotFoundError. Ordering is not a dependency a test may have, and the +# first sharded run, which gives every module its own process, said so. The +# insert is the same one the modules that import lib already make. +sys.path.insert(0, os.fspath(REPO / "lib")) CORE = REPO / "lib" / "session_inventory.py" REQUIRED_SYMBOLS = { "ABSENT_ALIAS_CONFIG_BACKUP", + "CLAUDE_SESSION_COLORS", + "CODEX_SESSION_COLORS", "CollectionError", "DEFAULT_MAX_PROC_NODES", + "FLEET_STALLS_FRESH_SECONDS", + "FLEET_STALLS_MAX_BYTES", + "FLEET_STALLS_MAX_RECORDS", + "LIFECYCLE_REOPENED_PROVIDER_CRASHED", + "MAX_CODEX_SESSION_INDEX_BYTES", "ROLLOUT_TAIL_BYTES", + "SCHEMA_VERSION", + "SESSION_COLORS", + "StateLock", + "TERMINAL_NUMBER_QUARANTINE_SECONDS", "_DarwinBsdInfo", + "_adopt_launch_colors", + "_alias_command", + "_color_command", + "_cold_inventory", "_display_width", "_empty_terminal_registry", "_json_bytes", "_lifecycle_command", + "_lifecycle_committed_conversation", + "_parse_claude_payload", "_parse_darwin_procargs2", "_parser", - "_alias_command", - "auto_title_from_hook", - "derive_prompt_title", - "_adopt_launch_colors", - "launch_color_for", - "record_launch_color", - "_color_command", + "_payload_daemon_identity", "_plan_token", + "_platform_command", + "_private_alias_parent", "_process_age", "_prove_lifecycle_caller", - "_parse_claude_payload", + "_prove_unchanged_daemon_generation", "_provider_title_info", "_read_terminal_registry", - "_valid_colors", + "_read_terminal_retirements", + "_read_state_json", + "_reconcile_pending_provider_titles", + "_quarantine_orphaned_provider_untitled_markers", "_release_sha", "_require_supported_platform", "_rollout_turn_state", "_sha256", "_state_paths", "_terminal_generation_key", + "_terminal_retirement_payload", + "_valid_colors", "_validate_terminal_registry", "acknowledge_pending", + "adopt_native_rename", "apply_legacy_recovery_manifest", + "apply_provider_title_states", "apply_retained_setup_attributions", "apply_terminal_numbers", "atomic_write_json", "audit_automatic_titles", + "auto_title_from_hook", "automatic_naming_enabled", "build_inventory", "canonical_aliases", "canonical_automatic_titles", + "canonical_colors", + "canonical_name_ownership", + "capture_provider_untitled_generations", + "capture_session_color_generations", + "claim_automatic_name", + "claude_bounce_prepare", + "claude_pending_native_hydrations", "codex_bounce_prepare", - "codex_pending_auto_titles", "codex_open_rollouts", + "codex_pending_auto_titles", + "codex_refresh_target", "collect_live", + "derive_prompt_title", "descendants", + "first_free_color", "flatten_pending", "guard_live_inventory", + "human_named_keys", "index_codex_processes", + "launch_color_for", "list_pending", "load_config", "lookup", "main", - "MAX_CODEX_SESSION_INDEX_BYTES", "migrate_runtime_aliases", "mutate_canonical_alias", "mutate_canonical_automatic_title", + "mutate_canonical_color", + "name_owner", "normalize_automatic_title", "os", + "palette_for_provider", "plan_legacy_recovery_manifest", "propagate_provider_color", "propagate_provider_title", + "publish_view_fields", "prove_self_name_caller", "prune_automatic_titles", "publish_legacy_migration_plan", + "publish_session_colors", "read_claude_ai_title", + "read_claude_transcript_signals", "read_codex_db", "read_codex_session_index", + "read_fleet_stalls", "recent_output_times", + "reconcile_session_colors", + "record_launch_color", + "recovery_manifest", + "recovery_list_payload", "recovery_spec", + "release_automatic_name_claim", "render_inventory", "rollback_legacy_recovery_manifest", "scan_darwin_process_table", "scan_process_table", "self_name_automatic_title", "session_color", - "SESSION_COLORS", "shpool_id_mutation_policy", "shutil", "snapshot", @@ -101,6 +151,7 @@ "strict_live_inventory", "subprocess", "update_recovery_state", + "enqueue_lost_conversations", } SIGNATURES = { @@ -135,15 +186,32 @@ "render", "waiting-count", "lookup", + "detail", "recovery-command", + "validate-worker-model", + # Added by the model gate: the kit refuses to start a session on a + # model this machine would answer with a different one, so both verbs + # are part of the contract now. + "model-availability", + "model-served", "codex-bounce-title", + "claude-bounce-title", "platform", "alias", "color", "automatic-title", + "account", "recovery-pending", + # Added by the one-closed-list work. + "recovery-selectors", + "action-log", + "origin", + "closed-sessions", + "transcript", + "close-intent", "recovery-manifest", "lifecycle", + "worktree", ) @@ -170,9 +238,44 @@ def referenced_facade_symbols() -> set[str]: return referenced +class TestSuiteReachabilityTests(unittest.TestCase): + """A test that cannot run is worse than a test that does not exist. + + It reads as coverage in a review, it passes the file it lives in, and it + reports success for a thing nobody checked. One was written in this repo: + a method indented at class level but placed after the module's + ``__main__`` guard, so discovery never saw it and its author read the + surrounding suite's OK as its own. + """ + + def test_every_test_method_is_reachable_by_discovery(self) -> None: + unreachable: list[str] = [] + for path in sorted((REPO / "tests").glob("test_*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + # Discovery finds a method only through a module-level class. + reachable = { + item.name + for node in tree.body + if isinstance(node, ast.ClassDef) + for item in node.body + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) + and item.name.startswith("test") + } + declared = { + node.name + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name.startswith("test_") + } + unreachable.extend( + f"{path.name}::{name}" for name in sorted(declared - reachable) + ) + self.assertEqual([], unreachable) + + class InventoryFacadeTests(unittest.TestCase): def test_contract_matches_every_current_test_reference(self) -> None: - self.assertEqual(len(REQUIRED_SYMBOLS), 85) + self.assertEqual(len(REQUIRED_SYMBOLS), 129) self.assertEqual(REQUIRED_SYMBOLS, referenced_facade_symbols()) def test_current_tested_symbols_remain_available(self) -> None: diff --git a/tests/test_history_search.py b/tests/test_history_search.py new file mode 100644 index 0000000..c32c971 --- /dev/null +++ b/tests/test_history_search.py @@ -0,0 +1,303 @@ +"""Human output contracts for ``sp find`` history search.""" +from __future__ import annotations + +import gzip +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock + +from tests.support import REPO + +sys.path.insert(0, os.fspath(REPO / "lib")) +from sessionkit_inventory import history_search # noqa: E402 + + +class HistorySearchTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".history-search-", dir=REPO) + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.journal = self.root / "journals" + self.recovery = self.root / "recovery" + self.archive = self.root / "archive" + self.data = self.root / "data" + for path in (self.journal, self.recovery, self.archive, self.data): + path.mkdir() + self.inventory = self.root / "inventory.json" + self.inventory.write_text( + json.dumps({"sessions": [{ + "shpool_id_raw": "main2", "title": "Orphaned Record" + }]}), + encoding="utf-8", + ) + + def render(self, query: str) -> str: + return history_search.render_matches( + query, + journal=self.journal, + recovery=self.recovery, + archive=self.archive, + inventory=self.inventory, + data=self.data, + ) + + def test_empty_state_is_exact(self) -> None: + self.assertEqual("Matches: none.\n", self.render("missing")) + + def test_live_history_uses_the_session_name_and_full_match_count(self) -> None: + sidecar = self.journal / "main2.rendered.txt" + sidecar.write_text("\n".join(["needle"] * 10) + "\n", encoding="utf-8") + shown = self.render("NEEDLE") + self.assertTrue(shown.startswith("Matches: 10\n"), shown) + self.assertIn("Orphaned Record · 10 matches", shown) + self.assertEqual(8, shown.splitlines().count("needle")) + self.assertIn("… (2 more matches)", shown) + self.assertNotIn(os.fspath(sidecar), shown) + self.assertNotIn("main2", shown) + + def test_screen_text_cannot_override_the_inventory_name(self) -> None: + sidecar = self.journal / "main2.rendered.txt" + sidecar.write_text( + "Session title: Fake Screen-Scraped Name\nneedle\n", + encoding="utf-8", + ) + + shown = self.render("needle") + + self.assertIn("Orphaned Record · 1 match", shown) + self.assertNotIn("Fake Screen-Scraped Name", shown) + + def test_clean_sidecar_prevents_duplicate_raw_matches(self) -> None: + session = self.journal / "main2" + session.mkdir() + (session / "segment-000001.raw").write_text("needle raw\n", encoding="utf-8") + (session / "rendered.txt").write_text("needle clean\n", encoding="utf-8") + shown = self.render("needle") + self.assertIn("Matches: 1", shown) + self.assertIn("needle clean", shown) + self.assertNotIn("needle raw", shown) + + def test_recovery_and_archive_names_never_become_paths(self) -> None: + recovered = self.recovery / "saved.raw" + recovered.write_text("remember this phrase\n", encoding="utf-8") + (self.recovery / "current-map.tsv").write_text( + f"lost1\t{recovered}\n", encoding="utf-8" + ) + (self.recovery / "recovery-manifest.json").write_text( + json.dumps({"sessions": {"lost1": { + "title": "Lost Import Fix" + }}}), + encoding="utf-8", + ) + archived = self.archive / "20260812-120000-main9.raw.gz" + with gzip.open(archived, "wt", encoding="utf-8") as handle: + handle.write("Session title: Archived Audit\n") + handle.write("remember archive phrase\n") + shown = self.render("remember") + self.assertIn("Matches: 2", shown) + self.assertIn("Lost Import Fix · 1 match", shown) + self.assertIn("Recorded 2026-08-12 12:00 · 1 match", shown) + self.assertNotIn("Archived Audit", shown) + self.assertNotIn(os.fspath(self.root), shown) + self.assertNotIn("lost1", shown) + self.assertNotIn("main9", shown) + + def test_archives_keep_their_own_identity_and_example_budget(self) -> None: + """A recycled live id cannot name or merge older recordings.""" + for stamp, prompt in ( + ("20260811-110000", "audit missing entry notes"), + ("20260812-120000", "repair search attribution"), + ): + archived = self.archive / f"{stamp}-main2.raw.gz" + with gzip.open(archived, "wt", encoding="utf-8") as handle: + handle.write(f"› {prompt}\n") + handle.write("\n".join(["needle"] * 10) + "\n") + shown = self.render("needle") + self.assertTrue(shown.startswith("Matches: 20\n"), shown) + self.assertIn("Recorded 2026-08-11 11:00, audit missing entry notes", shown) + self.assertIn("Recorded 2026-08-12 12:00, repair search attribution", shown) + self.assertNotIn("Orphaned Record", shown) + self.assertEqual(16, shown.splitlines().count("needle")) + self.assertEqual(2, shown.count("… (2 more matches)")) + + def test_archive_does_not_inherit_a_reused_closed_session_title(self) -> None: + ledger = { + "provider": "shell", "uuid": "", "title": "Someone Else's Work", + "cwd": "/tmp", "closed_at_unix_ms": 1, "origin": "human", + "shpool_id": "main9", "account_alias": "", + } + (self.data / "closed-sessions.jsonl").write_text( + json.dumps(ledger) + "\n", encoding="utf-8" + ) + archived = self.archive / "20260810-090000-main9.raw.gz" + with gzip.open(archived, "wt", encoding="utf-8") as handle: + handle.write("› check this recording\nneedle\n") + shown = self.render("needle") + self.assertIn("Recorded 2026-08-10 09:00, check this recording", shown) + self.assertNotIn("Someone Else's Work", shown) + + def test_empty_manifest_identity_cannot_name_an_unrelated_archive(self) -> None: + (self.recovery / "recovery-manifest.json").write_text( + json.dumps({"sessions": [{"title": "Unrelated Recovery"}]}), + encoding="utf-8", + ) + archived = self.archive / "unstructured.gz" + with gzip.open(archived, "wt", encoding="utf-8") as handle: + handle.write("needle\n") + shown = self.render("needle") + self.assertNotIn("Unrelated Recovery", shown) + self.assertRegex(shown, r"Recorded \d{4}-\d{2}-\d{2} \d{2}:\d{2} · 1 match") + + def test_same_display_title_does_not_merge_recording_identities(self) -> None: + (self.journal / "main2.rendered.txt").write_text( + "needle journal\n", encoding="utf-8" + ) + recovered = self.recovery / "saved.raw" + recovered.write_text("needle recovery\n", encoding="utf-8") + (self.recovery / "current-map.tsv").write_text( + f"lost1\t{recovered}\n", encoding="utf-8" + ) + (self.recovery / "recovery-manifest.json").write_text( + json.dumps({"sessions": {"lost1": {"title": "Orphaned Record"}}}), + encoding="utf-8", + ) + shown = self.render("needle") + self.assertIn("Orphaned Record (recording 1 of 2) · 1 match", shown) + self.assertIn("Orphaned Record (recording 2 of 2) · 1 match", shown) + + def test_flat_and_directory_journals_with_the_same_stem_do_not_merge(self) -> None: + directory = self.journal / "foo" + directory.mkdir() + (directory / "rendered.txt").write_text( + "needle from directory\n", encoding="utf-8" + ) + (self.journal / "foo.rendered.txt").write_text( + "needle from flat sidecar\n", encoding="utf-8" + ) + + shown = self.render("needle") + + self.assertIn("Matches: 2", shown) + self.assertEqual(2, shown.count("· 1 match"), shown) + self.assertIn("(recording 1 of 2)", shown) + self.assertIn("(recording 2 of 2)", shown) + self.assertIn("needle from directory", shown) + self.assertIn("needle from flat sidecar", shown) + + def test_identical_fallback_labels_get_recording_ordinals(self) -> None: + for suffix in ("first", "second"): + archived = self.archive / f"20260812-120000-{suffix}.raw.gz" + with gzip.open(archived, "wt", encoding="utf-8") as handle: + handle.write("Script started on 2026-08-12 12:00:00\n") + handle.write("› inspect the same request\nneedle\n") + + shown = self.render("needle") + + label = "Recorded 2026-08-12 12:00, inspect the same request" + self.assertIn(f"{label} (recording 1 of 2) · 1 match", shown) + self.assertIn(f"{label} (recording 2 of 2) · 1 match", shown) + + def test_one_corrupt_archive_does_not_hide_readable_results(self) -> None: + readable = self.journal / "main2.rendered.txt" + readable.write_text("needle survives\n", encoding="utf-8") + corrupt = self.archive / "20260812-120000-bad.raw.gz" + payload = bytearray(gzip.compress(b"needle corrupt\n" * 2000)) + payload[20] ^= 0xFF + corrupt.write_bytes(payload) + shown = self.render("needle") + self.assertIn("Matches: 1", shown) + self.assertIn("needle survives", shown) + self.assertIn("Notice: 1 recording could not be searched.", shown) + self.assertNotIn("Traceback", shown) + + def test_any_per_file_parse_failure_is_isolated(self) -> None: + first = self.journal / "main2.rendered.txt" + first.write_text("needle survives\n", encoding="utf-8") + failed = self.recovery / "failed.raw" + failed.write_text("needle hidden\n", encoding="utf-8") + real_matching = history_search._matching_lines + + def parse(path, query): + if path == failed: + raise ValueError("bad per-file record") + return real_matching(path, query) + + with mock.patch.object(history_search, "_matching_lines", side_effect=parse): + shown = self.render("needle") + self.assertIn("Matches: 1", shown) + self.assertIn("needle survives", shown) + self.assertNotIn("needle hidden", shown) + self.assertIn("Notice: 1 recording could not be searched.", shown) + + def test_nested_named_sidecar_is_searched(self) -> None: + nested = self.journal / "nested" + nested.mkdir() + (nested / "main2.rendered.txt").write_text( + "needle in nested sidecar\n", encoding="utf-8" + ) + (nested / "other.rendered.txt").write_text( + "needle in other recording\n", encoding="utf-8" + ) + shown = self.render("needle") + self.assertIn("Matches: 2", shown) + self.assertIn("Orphaned Record · 1 match", shown) + self.assertIn("needle in nested sidecar", shown) + self.assertIn("needle in other recording", shown) + self.assertEqual(2, shown.count(" · 1 match"), shown) + + def test_printed_matches_strip_controls_and_have_a_byte_limit(self) -> None: + raw = self.recovery / "unsafe.raw" + raw.write_text( + "needle \x1b[2Jdanger\x00 " + ("é" * 1000) + "\n", + encoding="utf-8", + ) + shown = self.render("needle") + self.assertIn("needle danger", shown) + self.assertNotIn("\x1b", shown) + self.assertNotIn("\x00", shown) + match = next(line for line in shown.splitlines() if line.startswith("needle")) + self.assertLessEqual(len(match.encode("utf-8")), history_search.MAX_MATCH_BYTES) + + def test_sanitization_only_runs_for_lines_that_will_be_printed(self) -> None: + raw = self.recovery / "large.raw" + raw.write_text( + ("ordinary nonmatching screen content\n" * 20_000) + "needle shown\n", + encoding="utf-8", + ) + + with mock.patch.object( + history_search, "_safe_line", wraps=history_search._safe_line + ) as safe_line: + shown = self.render("needle") + + self.assertIn("needle shown", shown) + self.assertEqual(1, safe_line.call_count) + + def test_matching_lines_bound_retained_sample_size(self) -> None: + raw = self.recovery / "huge-match.raw" + raw.write_bytes(b"needle " + (b"x" * (3 * 1024 * 1024)) + b"\n") + + matched = history_search._matching_lines(raw, "needle") + retained_size = sum( + sys.getsizeof(value) + for value in ( + matched, + vars(matched), + matched.count, + matched.shown, + *matched.shown, + matched.prompt, + matched.started, + ) + ) + + self.assertEqual(1, matched.count) + self.assertLess(retained_size, 4 * 1024) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_human_output_ids.py b/tests/test_human_output_ids.py new file mode 100644 index 0000000..b7ab438 --- /dev/null +++ b/tests/test_human_output_ids.py @@ -0,0 +1,679 @@ +"""The invariant: no human-readable surface ever prints a session identifier. + +Identifiers live in 0600 state files, machine-only JSON modes, proofs, and +command arguments. They are never displayed, not in a row, not in a +confirmation, not in an error, not behind a flag. + +This is the enforcement mechanism, not a collection of per-surface checks. +Every human surface is registered in ``SURFACES`` below and rendered against +one fixture whose identifiers are deliberately distinctive hex. A surface that +leaks fails here; a surface nobody registered fails ``test_every_human_surface_is_registered``, +so a new screen cannot join the kit without someone deciding, in writing, +which side of the line it is on. + +Whole identifiers are not the whole test. A truncated identifier is still an +identifier, the title fallback used to render ``Codex a1b2c3d4``, so the +scan flags any eight-hex-character prefix of a fixture identifier, which is +why the fixture UUIDs below start with distinctive hex rather than zeros. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import time +import unittest + +from tests.support import REPO, run + + +CORE = REPO / "lib" / "session_inventory.py" +SP = REPO / "bin" / "sp" +STATUS = REPO / "bin" / "shpool_status" + +# Distinctive hex, so an eight-character prefix of any of these is unmistakable +# in output and cannot collide with a timestamp, a count, or a colour name. +CLAUDE_UUID = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" +CODEX_UUID = "f9e8d7c6-b5a4-4938-a271-6f5e4d3c2b1a" +SHELL_UUID = "c0ffee11-dead-4bee-9fad-1234567890ab" +CLAUDE_SHPOOL = "s20260809-121212-7" +CODEX_SHPOOL = "s20260809-131313-8" +SHELL_SHPOOL = "s20260809-141414-9" + +# Every identifier the fixture contains, in the form it would leak. +SECRETS = ( + CLAUDE_UUID, + CODEX_UUID, + SHELL_UUID, + CLAUDE_SHPOOL, + CODEX_SHPOOL, + SHELL_SHPOOL, + f"claude:{CLAUDE_UUID}", + f"codex:{CODEX_UUID}", +) +MIN_PREFIX = 8 + +# The channels that legitimately carry an identifier, each one a value handed +# to a caller rather than a line shown to a person. Recorded here so the +# decision is written down and a reviewer can check it, not rediscovered. +MACHINE_CHANNELS = { + "lib/sh/sp_core.sh": "history_files returns journal paths to its caller", + "lib/sh/sp_provider_bounce.sh": "writes the pending uuid into a 0600 state file", + "lib/sh/sp_sessions.sh": ( + "start_new under SESSION_KIT_BACKGROUND=1, and restore_exact, return " + "the new session id to the script that asked for it" + ), + "bin/shpool_status": "--json, --strict-json, --guard-json, --lookup", + "lib/session_inventory.py": "every JSON subcommand, including lookup", +} + + +def leaks(text: str) -> list[str]: + """Every fixture identifier, or eight-character prefix of one, in `text`.""" + found: list[str] = [] + for secret in SECRETS: + # Compare on the identifier's own characters, so a UUID broken across + # a line or a column boundary still counts as printed. + for candidate in (secret, secret.replace("-", "")): + if len(candidate) < MIN_PREFIX: + continue + for start in range(0, len(candidate) - MIN_PREFIX + 1): + window = candidate[start : start + MIN_PREFIX] + # Only hex-ish windows are identifier-shaped; a window that is + # all digits could be an innocent timestamp. + if re.fullmatch(r"[0-9]+", window): + continue + if window in text: + found.append(f"{secret} (via {window!r})") + break + else: + continue + break + return found + + +def session( + *, + number: int, + provider: str, + uuid: str | None, + shpool_id: str, + title: str, + availability: str = "ready", + status: str = "idle", + aged_children: list[dict] | None = None, +) -> dict: + identity = ( + { + "uuid": uuid, + "confidence": "exact", + "pid": 2000 + number, + "process_start_ticks": 20_000 + number, + } + if uuid + else {"uuid": None, "confidence": "none"} + ) + return { + "row": number, + "terminal_number": number, + "shpool_id": shpool_id, + "shpool_id_raw": shpool_id, + "display_shpool_id": shpool_id, + "mutation_allowed": True, + "mutation_rejection_reason": None, + "shpool_status": "Disconnected" if availability == "ready" else "Attached", + "shpool_shell": {"pid": 1000 + number, "process_start_ticks": 10_000 + number}, + "started_at_unix_ms": 1_700_000_000_000 + number, + "availability": availability, + "provider": provider, + "display_provider": provider, + "identity": identity, + "title": title, + "display_title": title, + "native_title": "", + "provider_title_state": "ready", + "agent_status": status, + "cwd": "/srv/project", + "needs_you": status == "needs your reply", + "subagents": [], + "aged_children": list(aged_children or ()), + "setup_incomplete": False, + "color": "blue", + "display_color": "blue", + } + + +def fixture_inventory() -> dict: + return { + "schema_version": 1, + "generated_at": "2026-08-09T00:00:00Z", + "source": "live", + "stale": False, + "warnings": [], + "daemon_generation": {"boot_id": "fixture", "pid": 77, "process_start_ticks": 770}, + "sessions": [ + session( + number=2, + provider="claude", + uuid=CLAUDE_UUID, + shpool_id=CLAUDE_SHPOOL, + title="Fleet rebuild", + aged_children=[ + { + "kind": "worker", + "provider": "codex", + "title": "Verifier", + "age_seconds": 2 * 3600 + 11 * 60, + # Machine evidence may remain in the snapshot; detail + # renders only the safe title and age. + "pid": 98765, + "uuid": CODEX_UUID, + } + ], + ), + session( + number=3, + provider="codex", + uuid=CODEX_UUID, + shpool_id=CODEX_SHPOOL, + title="Ledger work", + availability="attached", + status="needs your reply", + ), + # No title and no alias: this is the row whose label used to fall + # back to a truncated UUID. + session( + number=4, + provider="codex", + uuid=SHELL_UUID, + shpool_id=SHELL_SHPOOL, + title="", + ), + ], + "outside_agents": [ + { + "provider": "claude", + "identity": {"uuid": CLAUDE_UUID, "confidence": "exact"}, + "title": "", + "agent_status": "working", + "subagents": [{"provider": "claude", "uuid": CODEX_UUID}], + "cwd": "/srv/project", + } + ], + } + + +class HumanOutputHasNoIdentifiersTests(unittest.TestCase): + """One fixture, every registered human surface, one invariant.""" + + # name -> (argv builder, whether a nonzero exit is acceptable) + SURFACES = ( + "render", + "render_rows", + "detail_by_number", + "detail_unknown", + "waiting_count", + "recovery_manifest_display", + "sp_usage", + "status_usage", + "sp_recover", + "sp_prune", + "sp_color_reconcile", + "picker_needs_you", + ) + SURFACE_TESTS = { + "render": "HumanOutputHasNoIdentifiersTests.test_the_dashboard_and_its_rows_name_sessions_without_identifiers", + "render_rows": "HumanOutputHasNoIdentifiersTests.test_the_dashboard_and_its_rows_name_sessions_without_identifiers", + "detail_by_number": "HumanOutputHasNoIdentifiersTests.test_detail_shows_a_person_everything_except_the_identifiers", + "detail_unknown": "HumanOutputHasNoIdentifiersTests.test_a_selector_that_matches_nothing_says_so_without_echoing_state", + "waiting_count": "HumanOutputHasNoIdentifiersTests.test_the_waiting_count_is_a_number", + "recovery_manifest_display": "HumanOutputHasNoIdentifiersTests.test_the_recovery_display_fields_carry_no_identifier", + "sp_usage": "HumanOutputHasNoIdentifiersTests.test_the_command_help_teaches_selectors_without_printing_one", + "status_usage": "HumanOutputHasNoIdentifiersTests.test_the_command_help_teaches_selectors_without_printing_one", + "sp_recover": "ShellRendererTests.test_the_recovery_list_names_conversations_not_identifiers", + "sp_prune": "ShellRendererTests.test_the_prune_candidate_list_names_candidates", + "sp_color_reconcile": "ShellRendererTests.test_color_reconcile_reports_counts_not_conversations", + "picker_needs_you": "HumanOutputHasNoIdentifiersTests.test_picker_needs_you_redacts_question_and_stall_identifiers", + } + + @classmethod + def setUpClass(cls) -> None: + cls.temporary = tempfile.TemporaryDirectory( + prefix=".human-output-", dir=REPO + ) + base = Path(cls.temporary.name) + cls.snapshot = base / "inventory.json" + cls.snapshot.write_text( + json.dumps(fixture_inventory()), encoding="utf-8" + ) + cls.home = base / "home" + cls.home.mkdir(mode=0o700) + cls.state = base / "state" + cls.state.mkdir(mode=0o700) + # Commands refuse to run without a session manager binary, and a CI + # runner has none. Nothing below asks the manager a question, so a + # stand-in that answers nothing is the whole dependency. + cls.shpool = base / "shpool" + cls.shpool.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") + cls.shpool.chmod(0o755) + + @classmethod + def tearDownClass(cls) -> None: + cls.temporary.cleanup() + + def _core(self, *argv: str) -> str: + environment = os.environ.copy() + environment.update( + { + "HOME": os.fspath(self.home), + "SESSION_KIT_STATE_DIR": os.fspath(self.state), + "SESSION_KIT_INPUT_SNAPSHOT": os.fspath(self.snapshot), + "PYTHONPATH": os.fspath(REPO / "lib"), + "SESSION_KIT_CODEX_AUTOTITLE": "0", + "SESSION_KIT_AUTO_NAME": "0", + } + ) + completed = subprocess.run( + [sys.executable, os.fspath(CORE), *argv], + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + return completed.stdout + completed.stderr + + def _assert_clean(self, surface: str, text: str) -> None: + found = leaks(text) + self.assertEqual( + [], + found, + f"{surface} printed session identifiers: {found}\n--- output ---\n{text}", + ) + + def test_the_dashboard_and_its_rows_name_sessions_without_identifiers( + self, + ) -> None: + for surface, argv in ( + ("render", ("render",)), + ("render_rows", ("render", "--rows")), + ): + with self.subTest(surface=surface): + text = self._core(*argv) + self._assert_clean(surface, text) + # The rows are still useful: numbers and titles survive. + self.assertIn("Fleet rebuild", text) + + def test_detail_shows_a_person_everything_except_the_identifiers(self) -> None: + text = self._core("detail", "2") + self._assert_clean("detail_by_number", text) + self.assertIn("Fleet rebuild", text) + self.assertIn("Claude", text) + self.assertIn("Opened", text) + self.assertIn("Verifier", text) + self.assertIn("2h 11m", text) + self.assertRegex(text, r"Opened\s+Nov 14, 2023 at") + # Last response and Waiting since came from the retired attention + # queue; a detail view now renders from the inventory row alone. + self.assertNotIn("Last response", text) + self.assertNotIn("Waiting since", text) + waiting = self._core("detail", "3") + self._assert_clean("detail_waiting", waiting) + # The collector records the provider's own words; the screen speaks + # the kit's one vocabulary. + self.assertIn("needs you", waiting) + self.assertNotIn("needs your reply", waiting) + # `sp detail` used to be `lookup`, which is the machine mode and does + # carry them. Prove the two are genuinely different surfaces. + machine = self._core("lookup", "2") + self.assertIn(CLAUDE_UUID, machine) + + def test_a_selector_that_matches_nothing_says_so_without_echoing_state( + self, + ) -> None: + self._assert_clean("detail_unknown", self._core("detail", CLAUDE_SHPOOL)) + + def test_the_waiting_count_is_a_number(self) -> None: + self._assert_clean("waiting_count", self._core("waiting-count")) + + def test_the_recovery_display_fields_carry_no_identifier(self) -> None: + library = os.fspath(REPO / "lib") + if library not in sys.path: + sys.path.insert(0, library) + import session_inventory as core + + manifest = core.recovery_manifest(fixture_inventory()) + # Only the fields a screen prints: the manifest itself is machine JSON. + human = "\n".join( + str(entry.get("title", "")) + for entry in manifest.get("sessions", {}).values() + ) + self._assert_clean("recovery_manifest_display", human) + + def test_the_command_help_teaches_selectors_without_printing_one(self) -> None: + for surface, argv in ( + ("sp_usage", [os.fspath(SP), "help"]), + ("status_usage", [os.fspath(STATUS), "--bogus"]), + ): + with self.subTest(surface=surface): + completed = subprocess.run( + argv, + env={**os.environ, "HOME": os.fspath(self.home)}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self._assert_clean(surface, completed.stdout + completed.stderr) + + def test_picker_needs_you_redacts_question_and_stall_identifiers(self) -> None: + """Drive the real PTY screen; a copied renderer cannot prove S7.""" + from tests.test_login import LoginFixture, inventory, row, run_pty + + first = row(SHELL_SHPOOL, number=1, provider="shell") + first["title"] = first["display_title"] = "Import monitor" + second = row("ordinary", number=2, provider="codex") + second["identity"]["uuid"] = CODEX_UUID + second["title"] = second["display_title"] = "Release monitor" + fixture = LoginFixture(inventory(first, second)) + fleet = fixture.home / ".local" / "state" / "fleet" + inbox = fleet / "inbox" + inbox.mkdir(parents=True) + (inbox / "question.json").write_text( + json.dumps( + { + "state": "open", + "asked_at": time.time() - 300, + "header": f"Decision for {CODEX_UUID[:8]}", + "session": {"uuid": CLAUDE_UUID, "title": CLAUDE_UUID[:8]}, + } + ), + encoding="utf-8", + ) + (inbox / "ordinary-numbers.json").write_text( + json.dumps( + { + "state": "open", + "asked_at": time.time() - 120, + "header": "Backfill 20260813 decaface", + "session": {"uuid": "", "title": None}, + } + ), + encoding="utf-8", + ) + (fleet / "stalls.json").write_text( + json.dumps( + { + "generated_at": time.time(), + "stalled": [ + { + "key": SHELL_SHPOOL, + "reason": SHELL_UUID[:8], + "since": time.time() - 600, + }, + { + "key": CODEX_UUID, + "reason": "silent", + "since": time.time() - 900, + }, + ], + } + ), + encoding="utf-8", + ) + try: + code, output = run_pty(fixture, b"a\n\nq\n", columns=140) + self.assertEqual(0, code) + self._assert_clean("picker_needs_you", output) + self.assertIn("Question · Decision for", output) + self.assertIn("Question · Decision for Decision · a session", output) + self.assertIn("Question · Backfill 20260813 decaface · a session", output) + self.assertNotIn("· None ·", output) + self.assertIn( + "Stalled · Import monitor · Managed shell · attention overdue", + output, + ) + self.assertIn("Stalled · Release monitor · Codex · response overdue", output) + finally: + fixture.close() + + def test_every_human_surface_is_registered(self) -> None: + """A new human surface joins this scan or fails this test. + + The registry is the point: adding a screen without deciding whether it + prints identifiers should be impossible to do quietly. `sp` verbs and + `shpool_status` modes are the two doors a person comes through. + """ + covered = set(self.SURFACES) + self.assertEqual(covered, set(self.SURFACE_TESTS)) + for surface, qualified in self.SURFACE_TESTS.items(): + class_name, method_name = qualified.split(".", 1) + owner = globals().get(class_name) + self.assertIsNotNone(owner, f"{surface}: missing test class {class_name}") + self.assertTrue( + hasattr(owner, method_name), + f"{surface}: missing test {qualified}", + ) + + # The `sp` verbs that print to a person, and the mode each one is. + sp_source = (REPO / "bin" / "sp").read_text(encoding="utf-8") + human_verbs = { + "list", + "detail", + "health", + "recover", + "prune", + "find", + "history", + } + for verb in human_verbs: + self.assertIn(f" sp {verb}", sp_source, f"sp {verb} left the usage text") + + # And the machine modes, which are allowed to carry identifiers. + status_source = (REPO / "bin" / "shpool_status").read_text(encoding="utf-8") + for machine_mode in ("--json", "--strict-json", "--guard-json", "--lookup"): + self.assertIn(machine_mode, status_source) + + # Every file granted a machine channel still has to exist; a rename + # that quietly drops one of these notes is itself a review event. + for path in MACHINE_CHANNELS: + self.assertTrue((REPO / path).is_file(), path) + + def test_the_invariant_catches_an_injected_leaking_row(self) -> None: + """Prove the scanner fails, rather than only proving fixtures pass.""" + original = self.snapshot.read_bytes() + leaking = fixture_inventory() + leaking["sessions"][0]["title"] = f"Leak {CLAUDE_UUID}" + leaking["sessions"][0]["display_title"] = f"Leak {CLAUDE_UUID}" + try: + self.snapshot.write_text(json.dumps(leaking), encoding="utf-8") + with self.assertRaises(AssertionError): + self._assert_clean("injected_leak", self._core("render")) + finally: + self.snapshot.write_bytes(original) + + +class ShellRendererTests(unittest.TestCase): + """The renderers `sp` embeds, run as they ship. + + `sp recover`, `sp prune` and `sp color reconcile` each format their screen + inside a python block in `lib/sh/sp_commands.sh`. Extracting the block and + running it is the only way to test the code that actually reaches a + terminal, a rewritten copy in the test would prove nothing about it. + """ + + SOURCE = REPO / "lib" / "sh" / "sp_commands.sh" + + def _block(self, marker: str, opener: str = "<<'PY'", closer: str = "\nPY\n") -> str: + text = self.SOURCE.read_text(encoding="utf-8") + start = text.index(marker) + body_start = text.index(opener, start) + len(opener) + return text[body_start : text.index(closer, body_start)] + + def _run(self, program: str, *argv: str, stdin: str = "") -> str: + completed = subprocess.run( + [sys.executable, "-c", program, *argv], + text=True, + input=stdin, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + return completed.stdout + completed.stderr + + def _assert_clean(self, surface: str, text: str) -> None: + found = leaks(text) + self.assertEqual( + [], + found, + f"{surface} printed session identifiers: {found}\n--- output ---\n{text}", + ) + + def test_the_recovery_list_names_conversations_not_identifiers(self) -> None: + """The closed list is a screen, so it is driven as one. + + `sp recover` now merges two sources, the conversations somebody closed + on purpose and the ones a crash took, and the merge itself carries + identifiers so a restore can use them. What must never carry one is the + list a person reads, so the whole command is run and its output judged. + """ + from tests.test_commands import CommandFixture + + fixture = CommandFixture() + try: + manifest = fixture.state / "recovery-manifest.json" + manifest.write_text( + json.dumps( + { + "sessions": { + CODEX_SHPOOL: { + "provider": "codex", + "title": "", + "uuid": CODEX_UUID, + "cwd": "/srv/project", + "crashed_at_unix_ms": 1_000, + } + } + } + ), + encoding="utf-8", + ) + manifest.chmod(0o600) + # The ledger itself, not a stub: `sp recover` reads the one + # projection now, and that reads the file. + ledger = fixture.home / ".local" / "share" / "session-kit" + ledger.mkdir(parents=True, exist_ok=True) + (ledger / "closed-sessions.jsonl").write_text( + json.dumps( + { + "provider": "claude", + "uuid": CLAUDE_UUID, + "title": "Fleet rebuild", + "title_source": "alias", + "cwd": "/srv/project", + "closed_at_unix_ms": 2_000, + "origin": "human", + "shpool_id": "", + "account_alias": "", + } + ) + + "\n", + encoding="utf-8", + ) + transcripts = fixture.home / ".claude" / "projects" / "-srv-project" + transcripts.mkdir(parents=True, exist_ok=True) + (transcripts / f"{CLAUDE_UUID}.jsonl").write_text( + '{"type":"user"}\n', encoding="utf-8" + ) + env = fixture.env() + shown = run([SP, "recover"], env=env) + output = shown.stdout + shown.stderr + self._assert_clean("sp_recover", output) + self.assertIn("Fleet rebuild", output) + # A crashed session with no title is still named, never blank. + self.assertIn("Codex", output) + finally: + fixture.close() + + def test_the_prune_candidate_list_names_candidates(self) -> None: + program = self._block("printf 'Idle and empty for") + with tempfile.NamedTemporaryFile( + "w", suffix=".json", delete=False, dir=REPO, prefix=".human-output-" + ) as handle: + json.dump( + { + "candidates": [ + {"shpool_id": CLAUDE_SHPOOL, "title": "Fleet rebuild"}, + {"shpool_id": CODEX_SHPOOL, "title": ""}, + ] + }, + handle, + ) + candidates = handle.name + try: + output = self._run(program, candidates) + self._assert_clean("sp_prune", output) + self.assertIn("Fleet rebuild", output) + # A candidate with no title is still named, never left blank. + self.assertIn("empty shell", output) + finally: + Path(candidates).unlink() + + def test_color_reconcile_reports_counts_not_conversations(self) -> None: + text = self.SOURCE.read_text(encoding="utf-8") + # Anchor on the block that actually prints this summary. Another command + # gained an identically shaped `payload | python3 -c '` block ahead of + # this one, and searching from the top of the file then extracted that + # program instead -- which failed to compile and looked like a broken + # renderer rather than a test looking in the wrong place. + marker = text.index("Recolored") + start = text.rindex("printf '%s' \"$payload\" | python3 -c '", 0, marker) + body_start = text.index("'", text.index("python3 -c '", start) + 11) + 1 + program = text[body_start : text.index("\n'\n", body_start)] + output = self._run( + program, + stdin=json.dumps( + { + "moved": { + f"claude:{CLAUDE_UUID}": "blue", + f"codex:{CODEX_UUID}": "lime", + }, + "dropped": [], + } + ), + ) + self._assert_clean("sp_color_reconcile", output) + self.assertIn("Recolored 1 Claude session: blue", output) + self.assertIn("Recolored 1 Codex session: lime", output) + self.assertNotIn("session(s)", output) + + +class NoRevealFlagTests(unittest.TestCase): + """There is no way to ask Session Kit to print an identifier.""" + + def test_no_source_file_offers_an_id_reveal_switch(self) -> None: + banned = ("SESSION_KIT_SHOW_IDS", "sk_show_ids", "--show-ids", "--reveal-ids") + offenders: list[str] = [] + for directory in ("bin", "lib", "docs", "tests"): + for path in sorted((REPO / directory).rglob("*")): + if not path.is_file() or "__pycache__" in path.parts: + continue + if path.name == Path(__file__).name: + continue + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + for word in banned: + if word in text: + offenders.append(f"{path.relative_to(REPO)}: {word}") + self.assertEqual([], offenders) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_idle_state.py b/tests/test_idle_state.py new file mode 100644 index 0000000..22f14d0 --- /dev/null +++ b/tests/test_idle_state.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +from tests.support import REPO + +from lib.sessionkit_inventory import idle_state, labels, transcripts +from lib.sessionkit_inventory import processes as process_inventory +from tests.test_inventory import inventory_core, inventory_fixture + + +UUID = "00000000-0000-4000-8000-000000000001" +MINUTE_MS = 60_000 + + +def waiting_inventory(provider: str = "claude") -> dict: + return { + "sessions": [ + { + "provider": provider, + "identity": {"uuid": UUID}, + "agent_status": "idle", + "needs_you": False, + } + ] + } + + +def signature(*, size: int = 10, mtime_ns: int = 20) -> dict[str, object]: + return {"path": f"/tmp/{UUID}.jsonl", "size": size, "mtime_ns": mtime_ns} + + +class TranscriptSnapshotTests(unittest.TestCase): + def test_claude_snapshot_uses_path_size_and_nanosecond_mtime(self) -> None: + with tempfile.TemporaryDirectory(prefix=".idle-transcript-", dir=REPO) as raw: + home = Path(raw) + transcript = home / ".claude" / "projects" / "fixture" / f"{UUID}.jsonl" + transcript.parent.mkdir(parents=True) + transcript.write_text("one line\n", encoding="utf-8") + os.utime(transcript, ns=(123_000_000_000, 123_456_789_012)) + + found = transcripts.transcript_snapshot( + "claude", UUID, environ={"HOME": os.fspath(home)} + ) + + self.assertEqual(os.fspath(transcript), found["path"]) + self.assertEqual(transcript.stat().st_size, found["size"]) + self.assertEqual(123_456_789_012, found["mtime_ns"]) + + def test_codex_rollout_uses_the_same_stable_snapshot(self) -> None: + with tempfile.TemporaryDirectory(prefix=".idle-rollout-", dir=REPO) as raw: + home = Path(raw) + rollout = ( + home + / ".codex" + / "sessions" + / "2026" + / "08" + / f"rollout-2026-08-16T00-00-00-{UUID}.jsonl" + ) + rollout.parent.mkdir(parents=True) + rollout.write_text("{\"type\":\"session_meta\"}\n", encoding="utf-8") + + found = transcripts.transcript_snapshot( + "codex", UUID, environ={"HOME": os.fspath(home)} + ) + + self.assertEqual(os.fspath(rollout), found["path"]) + self.assertEqual(rollout.stat().st_size, found["size"]) + self.assertEqual(rollout.stat().st_mtime_ns, found["mtime_ns"]) + + def test_missing_or_symlinked_transcript_is_not_idle_evidence(self) -> None: + with tempfile.TemporaryDirectory(prefix=".idle-transcript-", dir=REPO) as raw: + home = Path(raw) + project = home / ".claude" / "projects" / "fixture" + project.mkdir(parents=True) + target = project / "target.jsonl" + target.write_text("output\n", encoding="utf-8") + (project / f"{UUID}.jsonl").symlink_to(target) + + self.assertIsNone( + transcripts.transcript_snapshot( + "claude", UUID, environ={"HOME": os.fspath(home)} + ) + ) + + +class IdleThresholdTests(unittest.TestCase): + def setUp(self) -> None: + scratch = tempfile.TemporaryDirectory(prefix=".idle-window-", dir=REPO) + self.addCleanup(scratch.cleanup) + self.state = Path(scratch.name) + self.path = self.state / idle_state.IDLE_MINUTES_FILE + + def test_missing_file_uses_thirty_minutes_and_number_overrides(self) -> None: + self.assertEqual(30.0, idle_state.idle_minutes(self.state)) + self.path.write_text("75\n", encoding="utf-8") + self.assertEqual(75.0, idle_state.idle_minutes(self.state)) + + def test_invalid_values_disable_idling(self) -> None: + for raw in ("", "0", "-1", "nan", "inf", "half an hour", "9" * 65): + with self.subTest(raw=raw): + self.path.write_text(raw, encoding="utf-8") + self.assertIsNone(idle_state.idle_minutes(self.state)) + + def test_unreadable_threshold_disables_instead_of_using_default(self) -> None: + self.path.write_text("120\n", encoding="utf-8") + original = Path.read_bytes + + def refuse(path: Path) -> bytes: + if path == self.path: + raise PermissionError("operator file is unreadable") + return original(path) + + with mock.patch.object(Path, "read_bytes", refuse): + self.assertIsNone(idle_state.idle_minutes(self.state)) + + current = waiting_inventory() + with mock.patch.object(Path, "read_bytes", refuse): + idle_state.apply_idle_evidence( + current, + waiting_inventory(), + state_dir=self.state, + transcript_snapshot=lambda *_args: signature(), + now_ms=99 * MINUTE_MS, + ) + self.assertFalse(current["sessions"][0][idle_state.IDLE_FIELD]) + + +class TranscriptMovementIdleTests(unittest.TestCase): + def setUp(self) -> None: + scratch = tempfile.TemporaryDirectory(prefix=".idle-clock-", dir=REPO) + self.addCleanup(scratch.cleanup) + self.state = Path(scratch.name) + + def observe(self, current: dict, previous: dict | None, at_minutes: int, seen) -> dict: + return idle_state.apply_idle_evidence( + current, + previous, + state_dir=self.state, + transcript_snapshot=lambda *_args: seen, + now_ms=at_minutes * MINUTE_MS, + ) + + def test_first_observation_arms_and_exactly_thirty_minutes_becomes_idle(self) -> None: + first = self.observe(waiting_inventory(), None, 0, signature()) + self.assertFalse(first["sessions"][0][idle_state.IDLE_FIELD]) + + second = self.observe(waiting_inventory(), first, 30, signature()) + row = second["sessions"][0] + self.assertTrue(row[idle_state.IDLE_FIELD]) + self.assertEqual(labels.IDLE, labels.session_state(row, stall_seconds=2700)) + + def test_claude_and_codex_rows_share_the_same_idle_rule(self) -> None: + outcomes = [] + for provider in ("claude", "codex"): + first = self.observe(waiting_inventory(provider), None, 0, signature()) + second = self.observe(waiting_inventory(provider), first, 30, signature()) + outcomes.append(second["sessions"][0][idle_state.IDLE_FIELD]) + self.assertEqual([True, True], outcomes) + + def test_size_or_mtime_movement_resets_the_clock(self) -> None: + for moved in (signature(size=11), signature(mtime_ns=21)): + with self.subTest(moved=moved): + first = self.observe(waiting_inventory(), None, 0, signature()) + changed = self.observe(waiting_inventory(), first, 30, moved) + self.assertFalse(changed["sessions"][0][idle_state.IDLE_FIELD]) + aged = self.observe(waiting_inventory(), changed, 60, moved) + self.assertTrue(aged["sessions"][0][idle_state.IDLE_FIELD]) + + def test_unreadable_transcript_clears_an_older_idle_answer(self) -> None: + first = self.observe(waiting_inventory(), None, 0, signature()) + idle = self.observe(waiting_inventory(), first, 30, signature()) + unreadable = self.observe(waiting_inventory(), idle, 31, None) + row = unreadable["sessions"][0] + self.assertFalse(row[idle_state.IDLE_FIELD]) + self.assertNotIn("_transcript_idle_evidence", row) + + def test_only_a_state_that_would_need_you_can_become_idle(self) -> None: + cases = ( + ({"agent_status": "working", "needs_you": False}, labels.WORKING), + ({"agent_status": "state unavailable", "needs_you": False}, labels.STATUS_UNAVAILABLE), + ({"setup_incomplete": True, "needs_you": True}, labels.SETUP_INCOMPLETE), + ) + for fields, expected in cases: + with self.subTest(fields=fields): + row = {**fields, idle_state.IDLE_FIELD: True} + self.assertEqual(expected, labels.session_state(row, stall_seconds=2700)) + + def test_non_needs_states_do_not_resolve_a_transcript(self) -> None: + cases = ( + {"agent_status": "working", "needs_you": False}, + { + "agent_status": "idle", + "needs_you": True, + "blocking_question": True, + }, + {"agent_status": "idle", "needs_you": True, "setup_incomplete": True}, + ) + for fields in cases: + with self.subTest(fields=fields): + current = waiting_inventory() + current["sessions"][0].update(fields) + idle_state.apply_idle_evidence( + current, + None, + state_dir=self.state, + transcript_snapshot=lambda *_args: self.fail( + "a non-needs state must not resolve its transcript" + ), + now_ms=0, + ) + self.assertNotIn( + "_transcript_idle_evidence", current["sessions"][0] + ) + + +class ChurnedCensusIdleDifferentialTests(unittest.TestCase): + @staticmethod + def build(*, churned: bool) -> dict: + fixture = list(inventory_fixture(1, providers=("claude",))) + fixture[1][0]["status"] = "idle" + if churned: + table = process_inventory.ProcessTable(fixture[2]) + table.complete = False + fixture[2] = table + return inventory_core.build_inventory( + *fixture, + now=1_800_000_000, + daemon_binding={"pid": 10, "process_start_ticks": 100}, + ) + + def test_idle_evidence_is_identical_after_a_live_census_hole(self) -> None: + with tempfile.TemporaryDirectory(prefix=".idle-churn-", dir=REPO) as raw: + state = Path(raw) + outcomes = [] + for churned in (False, True): + initial = self.build(churned=churned) + idle_state.apply_idle_evidence( + initial, + None, + state_dir=state, + transcript_snapshot=lambda *_args: signature(), + now_ms=0, + ) + current = self.build(churned=churned) + idle_state.apply_idle_evidence( + current, + initial, + state_dir=state, + transcript_snapshot=lambda *_args: signature(), + now_ms=30 * MINUTE_MS, + ) + row = current["sessions"][0] + outcomes.append( + ( + row["provider"], + row["identity"]["uuid"], + row[idle_state.IDLE_FIELD], + labels.session_state(row, stall_seconds=2700), + ) + ) + self.assertEqual(outcomes[0], outcomes[1]) + self.assertEqual(("claude", UUID, True, labels.IDLE), outcomes[0]) + + +class SnapshotIdleWiringTests(unittest.TestCase): + def test_facade_passes_the_live_transcript_reader_into_snapshot_overlay(self) -> None: + with tempfile.TemporaryDirectory(prefix=".idle-wiring-", dir=REPO) as raw: + state = Path(raw) + with mock.patch( + "session_inventory._snapshot.snapshot", + return_value={"sessions": []}, + ) as orchestrator: + inventory_core.snapshot(config={"state_dir": state}) + callback = orchestrator.call_args.kwargs["apply_session_idle_states"] + current = waiting_inventory() + with mock.patch( + "session_inventory._transcripts.transcript_snapshot", + return_value=signature(), + ) as reader: + previous = waiting_inventory() + previous["sessions"][0]["_transcript_idle_evidence"] = { + **signature(), + "last_moved_at_unix_ms": 0, + "observed_at_unix_ms": 0, + "window_seconds": 1800.0, + } + with mock.patch( + "sessionkit_inventory.idle_state.time.time", return_value=1800 + ): + callback(current, previous, state_dir=state) + reader.assert_called_once_with("claude", UUID) + self.assertIn("_transcript_idle_evidence", current["sessions"][0]) + self.assertEqual(labels.IDLE, current["sessions"][0]["state"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_install.py b/tests/test_install.py index d0c4855..e456610 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1,54 +1,168 @@ from __future__ import annotations import base64 +import grp +import hashlib import json import os +import pwd from pathlib import Path +import plistlib +import re +import signal import shutil +import sqlite3 +import importlib.util +import stat import subprocess +import sys import tempfile +import threading +import time import unittest - REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, os.fspath(REPO / "lib")) + +from tests.support import THEME_COLORS, source_tree_ignore # noqa: E402 +from sessionkit_inventory.common import CollectionError # noqa: E402 +from sessionkit_inventory.state_io import StateLock # noqa: E402 + RELEASE_A = "1" * 40 RELEASE_B = "2" * 40 +RELEASE_C = "3" * 40 +# Launchers are compared byte for byte to prove which release a rollback took +# them from. A fixture release built out of this checkout would otherwise carry +# launchers identical to the release under test, and the comparison would hold +# no matter which one the code picked. +LEGACY_LAUNCHER_STAMP = "# Session Kit legacy generation fixture.\n" +# A unit name no release carries, so a fixture release that adds it reproduces +# the one state that breaks a rollback: a running kit whose unit list names a +# file the release it is activating never had. +FIXTURE_UNIT = "session-kit-fixture-sweep.service" + + +def host_group_is_private() -> bool: + """Report whether this host gives the test account a private group. + + A group-writable provider directory is installable only where the group + belongs to one account, which is how a private-group distribution creates + it. Hosts with a shared primary group cannot stage that fixture. + """ + try: + account = pwd.getpwuid(os.geteuid()) + group = grp.getgrgid(os.getegid()) + accounts = pwd.getpwall() + except (KeyError, OSError): # pragma: no cover - directory service failure + return False + return bool( + os.getegid() == account.pw_gid + and group.gr_name == account.pw_name + and not group.gr_mem + and accounts + and all( + other.pw_gid != account.pw_gid or other.pw_name == account.pw_name + for other in accounts + ) + ) class InstallerTests(unittest.TestCase): def setUp(self) -> None: + # GitHub's macOS workspace carries an inherited ACL that denies + # renaming sealed 0555 directories. Real installs live under HOME, so + # keep Darwin fixtures in the normal per-user temporary directory. + # Managed worktrees may expose the checkout's parent read-only. /tmp is + # writable in that environment and also avoids copying a source tree + # into one of its own descendants in tests that stage a release. + isolated_test_parent = os.environ.get("SESSION_KIT_TEST_TMPDIR") + temp_parent = ( + Path(isolated_test_parent) + if isolated_test_parent + else None if sys.platform == "darwin" else Path("/tmp") + ) self.temp = Path( tempfile.mkdtemp( prefix="session-kit-install-test.", - dir=REPO.parent, + dir=temp_parent, ) - ) + ).resolve() self.home = self.temp / "home" self.home.mkdir() self.fake_bin = self.temp / "bin" self.fake_bin.mkdir() - for command in ("shpool", "codex"): + for command in ("shpool", "claude", "codex", "launchctl", "plutil", "sw_vers"): executable = self.fake_bin / command + body = "#!/usr/bin/env bash\nexit 0\n" + if command == "shpool": + body = ( + "#!/usr/bin/env bash\n" + "if [[ ${1:-} == version ]]; then echo 'shpool 0.11.0'; exit 0; fi\n" + "echo 'test shpool: refusing non-version command' >&2\n" + "exit 97\n" + ) + elif command == "claude": + body = "#!/usr/bin/env bash\necho '2.1.221 (Claude Code)'\n" + elif command == "codex": + body = "#!/usr/bin/env bash\necho 'codex-cli 0.145.0'\n" executable.write_text( - "#!/usr/bin/env bash\nexit 0\n", encoding="utf-8" + body, encoding="utf-8" ) executable.chmod(0o755) self.env = os.environ.copy() self.env.update( { "HOME": str(self.home), + # A managed session exports the real profile here, and the + # transcript checks honour it; the fixture must own its + # ambient profile or foreground runs read the real estate. + "CLAUDE_CONFIG_DIR": str(self.home / ".claude"), "PATH": f"{self.fake_bin}:{self.env['PATH']}", "SESSION_KIT_RELEASE_ID": RELEASE_A, + "XDG_CONFIG_HOME": str(self.home / ".config"), + "XDG_DATA_HOME": str(self.home / ".local/share"), + "XDG_STATE_HOME": str(self.home / ".local/state"), + "SESSION_KIT_STATE_DIR": str( + self.home / ".local/state/session-kit" + ), + "SESSION_KIT_CONFIG": str( + self.home / ".config/session-kit/inventory.json" + ), "SESSION_KIT_SYSTEMD_ROOT": str(self.temp / "systemd"), "SESSION_KIT_TESTING": "1", } ) + real_data_home = (Path.home() / ".local/share").resolve() + isolated_paths = ( + Path(self.env["HOME"]), + Path(self.env["XDG_CONFIG_HOME"]), + Path(self.env["XDG_DATA_HOME"]), + Path(self.env["XDG_STATE_HOME"]), + ) + for path in isolated_paths: + self.assertFalse( + path.resolve().is_relative_to(real_data_home), + f"installer test path escapes into operator data: {path}", + ) def tearDown(self) -> None: - for path in self.temp.rglob("*"): - if path.is_dir() and not path.is_symlink(): - path.chmod(path.stat().st_mode | 0o700) - shutil.rmtree(self.temp) + # The tree is walked to restore search permission on directories the + # installer deliberately locked, then removed. A test's own git + # subprocess can still be pruning objects underneath that walk, and + # rglob raises when a directory disappears mid-scan, a cleanup that + # failed the run over work it was about to delete anyway. Both the + # walk and each chmod tolerate a vanished entry. + try: + paths = list(self.temp.rglob("*")) + except OSError: + paths = [] + for path in paths: + try: + if path.is_dir() and not path.is_symlink(): + path.chmod(path.stat().st_mode | 0o700) + except OSError: + continue + shutil.rmtree(self.temp, ignore_errors=True) def run_installer( self, *args: str, check: bool = True @@ -69,13 +183,176 @@ def run_installer( ) return result + def run_installer_on_a_terminal( + self, keystrokes: str, *args: str + ) -> subprocess.CompletedProcess[str]: + """Run the installer with a terminal on stdin, the way a person runs + it. It asks nothing there: it takes the marked defaults and reports + them.""" + import pty + + parent, child = pty.openpty() + try: + process = subprocess.Popen( + [str(REPO / "install.sh"), *args], + cwd=REPO, + env=self.env, + stdin=child, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + os.close(child) + child = -1 + os.write(parent, keystrokes.encode()) + output, _ = process.communicate(timeout=180) + finally: + if child >= 0: + os.close(child) + os.close(parent) + return subprocess.CompletedProcess( + args, process.returncode, output, "" + ) + + def test_installer_asks_nothing_and_reports_the_defaults_it_took(self) -> None: + """The installer never asks. It takes the marked defaults on a + terminal, installs, and states what it chose with the way back.""" + completed = self.run_installer_on_a_terminal("") + self.assertEqual(0, completed.returncode, completed.stdout) + for banned in ("[Y/n]", "[y/N]", "Answer y or n", "cancelled"): + self.assertNotIn(banned, completed.stdout) + self.assertIn( + "Login integration is on. Turn it off with: session-kit disable-login", + completed.stdout, + ) + self.assertIn( + "History recording is off. Turn it on with: " + "session-kit update --journal on", + completed.stdout, + ) + self.assertTrue( + (self.home / ".local/lib/session-kit/current").is_symlink(), + completed.stdout, + ) + self.assertIn( + "session-kit managed integration", + (self.home / ".bashrc").read_text(encoding="utf-8"), + ) + self.assertTrue((self.home / ".no_shpool_journal").is_file()) + + def test_fresh_install_seeds_collection_floor(self) -> None: + self.run_installer("--non-interactive") + + floor = self.home / ".local/state/session-kit/collection-sequence-floor.json" + self.assertEqual( + {"schema_version": 1, "last_collection_start": 1}, + json.loads(floor.read_text(encoding="utf-8")), + ) + self.assertEqual(0o600, stat.S_IMODE(floor.stat().st_mode)) + + def test_install_never_overwrites_existing_collection_floor(self) -> None: + state = self.home / ".local/state/session-kit" + state.mkdir(parents=True, mode=0o700) + floor = state / "collection-sequence-floor.json" + original = b'{ "schema_version" : 1, "last_collection_start" : 41 }\n' + floor.write_bytes(original) + floor.chmod(0o600) + + self.run_installer("--non-interactive") + + self.assertEqual(original, floor.read_bytes()) + + def test_install_refuses_underivable_collection_floor_with_exact_remedy( + self, + ) -> None: + state = self.home / ".local/state/session-kit" + state.mkdir(parents=True, mode=0o700) + inventory = state / "inventory.json" + inventory.write_text('{"sessions":[]}\n', encoding="utf-8") + inventory.chmod(0o600) + command = ( + f"env SESSION_KIT_STATE_DIR={state} python3 " + f"{REPO / 'tools/reset-collection-order.py'}" + ) + expected = ( + "session-kit-release: collection allocation floor cannot be derived " + "from existing state; restart the machine and, before starting a " + "picker, sp, shpool_status, or any Session Kit user service, run " + f"exactly:\n {command}\n" + ) + + refused = self.run_installer("--non-interactive", check=False) + + self.assertNotEqual(0, refused.returncode) + self.assertEqual(expected, refused.stderr) + self.assertFalse((state / "collection-sequence-floor.json").exists()) + self.assertFalse((self.home / ".local/lib/session-kit/current").exists()) + + def test_interrupted_install_journals_provider_config_preimages_and_commits_backups( + self, + ) -> None: + """The operator's provider config is preimaged before anything runs. + + The installer now owns exact Claude registrations, so an interrupted + install must first restore what it found before the retry applies the + release again. This proves the journal captured the real preimage -- + content for the file that was there, `absent` for the one that was not + -- and that the committed backup carries the same record. + """ + claude = self.home / ".claude" + claude.mkdir() + settings = claude / "settings.json" + original = b'{"existing":"claude-setting"}\n' + settings.write_bytes(original) + settings.chmod(0o600) + codex_hooks = self.home / ".codex" / "hooks.json" + + self.env["SESSION_KIT_TEST_FAILPOINT"] = "themes" + self.env["SESSION_KIT_TEST_FAILPOINT_MODE"] = "kill" + failed = self.run_installer("--non-interactive", check=False) + self.assertEqual(-signal.SIGKILL, failed.returncode) + journal_path = self.home / ".local/state/session-kit/lifecycle-transaction.json" + journal = json.loads(journal_path.read_text(encoding="utf-8")) + entries = {item["path"]: item for item in journal["entries"]} + self.assertEqual("file", entries[str(settings)]["kind"]) + self.assertEqual(original, base64.b64decode(entries[str(settings)]["content"])) + self.assertEqual("absent", entries[str(codex_hooks)]["kind"]) + self.assertFalse(codex_hooks.exists()) + + self.env.pop("SESSION_KIT_TEST_FAILPOINT") + self.env.pop("SESSION_KIT_TEST_FAILPOINT_MODE") + recovered = self.run_installer("--non-interactive") + self.assertIn( + "Recovered interrupted Session Kit install transaction", recovered.stdout + ) + self.assertFalse(journal_path.exists()) + restored = json.loads(settings.read_text(encoding="utf-8")) + self.assertEqual("claude-setting", restored["existing"]) + self.assertEqual( + "~/.claude/statusline.sh", restored["statusLine"]["command"] + ) + self.assertFalse(codex_hooks.exists()) + + backups = sorted( + (self.home / ".local/state/session-kit/backups").glob("lifecycle-*.json") + ) + committed = json.loads(backups[-1].read_text(encoding="utf-8")) + committed_entries = {item["path"]: item for item in committed["entries"]} + self.assertEqual( + original, base64.b64decode(committed_entries[str(settings)]["content"]) + ) + self.assertEqual("absent", committed_entries[str(codex_hooks)]["kind"]) + def installed( - self, *args: str, check: bool = True + self, + *args: str, + check: bool = True, + env: dict[str, str] | None = None, ) -> subprocess.CompletedProcess[str]: command = self.home / ".local/bin/session-kit" result = subprocess.run( [str(command), *args], - env=self.env, + env=env if env is not None else self.env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -88,29 +365,323 @@ def installed( ) return result + def clean_source_fixture(self) -> Path: + source = self.temp / "clean-source" + shutil.copytree( + REPO, + source, + ignore=source_tree_ignore(), + ) + subprocess.run(["git", "init", "-q", source], check=True) + subprocess.run(["git", "-C", source, "add", "."], check=True) + subprocess.run( + [ + "git", + "-C", + source, + "-c", + "user.name=Session Kit Tests", + "-c", + "user.email=session-kit-tests@invalid.example", + "commit", + "-q", + "-m", + "fixture", + ], + check=True, + ) + return source + + def changed_claude_source_fixture(self) -> Path: + source = self.clean_source_fixture() + for relative in ( + "config/claude/nameintent_title.sh", + "config/claude/statusline.sh", + ): + path = source / relative + path.write_text( + path.read_text(encoding="utf-8") + "\n# distinct fixture payload\n", + encoding="utf-8", + ) + subprocess.run(["git", "-C", source, "add", "."], check=True) + subprocess.run( + [ + "git", "-C", source, + "-c", "user.name=Session Kit Tests", + "-c", "user.email=session-kit-tests@invalid.example", + "commit", "-q", "--amend", "--no-edit", + ], + check=True, + ) + return source + + def stage_legacy_release(self, source_release: str, target_release: str) -> None: + root = self.home / ".local/lib/session-kit/releases" + target = root / target_release + shutil.copytree(root / source_release, target) + self.unseal_release(target) + metadata = target / "RELEASE.json" + metadata.chmod(0o644) + value = json.loads(metadata.read_text(encoding="utf-8")) + value["commit"] = target_release + metadata.write_text(json.dumps(value) + "\n", encoding="utf-8") + shutil.rmtree(target / "config/claude") + self.seal_release(target) + + def make_release_fence_unaware(self, release_id: str) -> Path: + """Give one retained release the real legacy lock contract. + + Its status command takes only inventory.lock and emits the retained + inventory. The target's StateLock source deliberately contains no + generation-2 path, so rollback must decide from code evidence rather + than from the synthetic release id. + """ + release = self.home / ".local/lib/session-kit/releases" / release_id + self.unseal_release(release) + state_io = release / "lib/sessionkit_inventory/state_io.py" + state_io.chmod(0o644) + state_io.write_text( + '"""Pre-generation-2 StateLock fixture."""\n\n' + '_PUBLISHING_LOCK_NAME = "inventory.lock"\n\n' + "class StateLock:\n" + " pass\n", + encoding="utf-8", + ) + status_command = release / "bin/shpool_status" + status_command.chmod(0o755) + status_command.write_text( + """#!/usr/bin/env python3 +import fcntl +import os +from pathlib import Path +import stat +import sys + +if sys.argv[1:] != ["--json"]: + raise SystemExit(2) +root = Path(os.environ["SESSION_KIT_STATE_DIR"]) +lock = root / "inventory.lock" +flags = os.O_RDWR | os.O_CREAT +if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC +if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW +descriptor = os.open(lock, flags, 0o600) +try: + opened = os.fstat(descriptor) + before = lock.lstat() + if ( + not stat.S_ISREG(opened.st_mode) + or stat.S_IMODE(opened.st_mode) != 0o600 + or opened.st_uid != os.geteuid() + or (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino) + ): + raise SystemExit(1) + fcntl.flock(descriptor, fcntl.LOCK_EX) + after = lock.lstat() + if (opened.st_dev, opened.st_ino) != (after.st_dev, after.st_ino): + raise SystemExit(1) + sys.stdout.write((root / "inventory.json").read_text(encoding="utf-8")) +finally: + os.close(descriptor) +""", + encoding="utf-8", + ) + self.seal_release(release) + return release + + def install_mixed_modern_legacy_history(self) -> None: + self.run_installer("--non-interactive") + self.stage_legacy_release(RELEASE_A, RELEASE_C) + changed = self.changed_claude_source_fixture() + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(changed)) + self.installed("rollback", "--to", RELEASE_A) + self.installed("rollback", "--to", RELEASE_C) + + def helper_launcher_names(self) -> list[str]: + """The helper commands the installer keeps, read from the source.""" + source = (REPO / "bin/session-kit").read_text(encoding="utf-8") + match = re.search(r"^helpers=\((?P<names>[^)]*)\)$", source, re.MULTILINE) + assert match is not None, "bin/session-kit no longer declares helpers" + return match.group("names").split() + + def unseal_release(self, release: Path) -> None: + for directory in sorted( + (path for path in release.rglob("*") if path.is_dir()), reverse=True + ): + directory.chmod(0o755) + release.chmod(0o755) + + def seal_release(self, release: Path) -> None: + """Restore the modes and manifest `write_release_manifest` leaves. + + `verify_release` recomputes every digest and refuses a release whose + directories are writable, so a fixture that edits a sealed release has + to leave it exactly as the installer would have. + """ + manifest = release / "MANIFEST.sha256" + manifest.chmod(0o644) + lines = [ + f"{hashlib.sha256(path.read_bytes()).hexdigest()} " + f"{path.relative_to(release).as_posix()}" + for path in sorted(release.rglob("*")) + if path.is_file() and path != manifest + ] + manifest.write_text("\n".join(lines) + "\n", encoding="utf-8") + for path in sorted(release.rglob("*")): + if path.is_file(): + path.chmod(0o555 if path.stat().st_mode & 0o111 else 0o444) + for directory in sorted( + (path for path in release.rglob("*") if path.is_dir()), reverse=True + ): + directory.chmod(0o555) + release.chmod(0o555) + + def install_legacy_generation(self) -> None: + """Leave the installation an older release left behind. + + The rollback proofs below all begin from an installation created by a + release older than the release-anchored manager. That generation + differed in two ways this installer still has to cope with: nothing + anchors management to a release, and the management command is a copy + of the release's own `bin/session-kit` rather than the stable launcher. + + The fixture reproduces that state instead of installing the old source + tree, which is reachable from no public clone, so the proofs run + everywhere. Every property the later assertions depend on is checked + here, so a future change to the installer cannot quietly turn these + into tests of an ordinary modern installation. + """ + self.run_installer("--non-interactive") + root = self.home / ".local/lib/session-kit" + release = root / "releases" / RELEASE_A + + self.unseal_release(release) + # An older release also carries older launchers. Stamping them keeps + # the byte comparisons that prove which release a launcher came from + # able to tell the two releases apart. + for relative in ("bin/session-kit", "deploy/session-kit-launcher"): + stamped = release / relative + stamped.chmod(0o755) + shebang, _, body = stamped.read_text(encoding="utf-8").partition("\n") + stamped.write_text( + f"{shebang}\n{LEGACY_LAUNCHER_STAMP}{body}", encoding="utf-8" + ) + self.seal_release(release) + + (root / "manager").unlink() + launcher = self.home / ".local/bin/session-kit" + launcher.write_bytes((release / "bin/session-kit").read_bytes()) + launcher.chmod(0o755) + for helper in self.helper_launcher_names(): + helper_path = self.home / ".local/bin" / helper + if not helper_path.exists(): + continue + helper_path.write_bytes( + (release / "deploy/session-kit-launcher").read_bytes() + ) + helper_path.chmod(0o755) + for directory in (self.home / ".claude", self.home / ".codex"): + if directory.is_dir() and not any(directory.iterdir()): + directory.rmdir() + + # The state the rollback proofs are written against, asserted rather + # than assumed. + self.assertFalse((root / "manager").exists()) + self.assertEqual(RELEASE_A, (root / "current").resolve().name) + self.assertEqual( + (release / "bin/session-kit").read_bytes(), launcher.read_bytes() + ) + self.assertNotEqual( + (release / "deploy/session-kit-launcher").read_bytes(), + (REPO / "deploy/session-kit-launcher").read_bytes(), + ) + + def write_systemctl_probe_fixture(self) -> Path: + log = self.temp / "systemctl-probes.log" + systemctl = self.fake_bin / "systemctl" + systemctl.write_text( + "#!/usr/bin/env bash\n" + "printf '%s\\n' \"$*\" >> \"$SESSION_KIT_SYSTEMCTL_LOG\"\n" + "case \"$*\" in\n" + " '--user show-environment') exit \"${SESSION_KIT_DIRECT_RC:-0}\" ;;\n" + " '--user --machine=@.host show-environment') exit \"${SESSION_KIT_MACHINE_RC:-1}\" ;;\n" + " *) exit 64 ;;\n" + "esac\n", + encoding="utf-8", + ) + systemctl.chmod(0o755) + return log + def test_check_is_read_only(self) -> None: result = self.run_installer("--check") - self.assertIn("OK source: installable", result.stdout) + self.assertIn("OK source installable", result.stdout) self.assertEqual(list(self.home.iterdir()), []) + def test_check_reports_direct_and_fallback_systemd_transports(self) -> None: + source = self.clean_source_fixture() + log = self.write_systemctl_probe_fixture() + env = self.env.copy() + env.pop("SESSION_KIT_TESTING") + env.pop("SESSION_KIT_RELEASE_ID") + env["SESSION_KIT_SYSTEMCTL_LOG"] = str(log) + + cases = ( + ( + "0", + "1", + 0, + "OK services systemd user manager is available", + ["--user show-environment"], + ), + ( + "1", + "0", + 0, + "WARN services systemd user manager is available through " + "local-machine transport; direct user socket is unavailable", + ["--user show-environment", "--user --machine=@.host show-environment"], + ), + ( + "1", + "1", + 1, + "FAIL services systemd user manager is unavailable", + ["--user show-environment", "--user --machine=@.host show-environment"], + ), + ) + for direct_rc, machine_rc, expected_rc, message, calls in cases: + with self.subTest(direct_rc=direct_rc, machine_rc=machine_rc): + log.unlink(missing_ok=True) + env["SESSION_KIT_DIRECT_RC"] = direct_rc + env["SESSION_KIT_MACHINE_RC"] = machine_rc + result = subprocess.run( + [str(source / "install.sh"), "--check"], + cwd=source, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, expected_rc, result.stdout + result.stderr) + self.assertIn(message, result.stdout) + self.assertEqual(log.read_text(encoding="utf-8").splitlines(), calls) + def test_check_rejects_partial_inventory_package(self) -> None: source = self.temp / "source" shutil.copytree( REPO, source, - ignore=shutil.ignore_patterns( - ".git", - ".mypy_cache", - ".ruff_cache", - "__pycache__", - "*.pyc", - ), + ignore=source_tree_ignore(), ) subprocess.run(["git", "init", "-q", source], check=True) for relative in ( "lib/sessionkit_inventory/__init__.py", "lib/sessionkit_inventory/common.py", "lib/sessionkit_inventory/lifecycle.py", + "lib/sessionkit_inventory/projects.py", "lib/sessionkit_inventory/providers.py", "lib/sessionkit_inventory/reaper.py", "lib/sessionkit_inventory/state_io.py", @@ -129,9 +700,296 @@ def test_check_rejects_partial_inventory_package(self) -> None: check=False, ) self.assertNotEqual(result.returncode, 0) - self.assertIn(f"source file missing: {relative}", result.stdout) + self.assertIn( + f"FAIL source-files missing from the source: {relative}", + result.stdout, + ) path.write_bytes(payload) + # The Codex version this branch's finding was measured against. If Codex + # is upgraded, the test below fails ON PURPOSE: someone has to re-measure + # rather than assume the finding still holds. + MEASURED_CODEX_VERSION = "0.145.0" + + def test_codex_gets_no_kit_file_because_codex_cannot_use_one(self) -> None: + """Codex has no `/kit`, and installing a file that pretends otherwise + is worse than installing none. + + Measured 2026-08-15 against codex-cli 0.145.0, three ways: + + * its app-server protocol has 54 client methods and NO channel for a + custom command -- `skills/list` is the only invoke-by-name surface; + * `skills/list` against a home holding a full `prompts/` directory + returned 28 skills, none of them from `prompts/`; + * with a `skills/kit/SKILL.md` present and reported by `skills/list` + as enabled, the real TUI driven in remote mode drew a slash menu of + built-ins only -- `/model /fast /ide /permissions /keymap /vim + /experimental /approve` -- and `/ki` rendered `no matches`. + + So `~/.codex/prompts/kit.md` was a file nothing ever read, and the docs + repeated its existence as a capability. Ctrl-q is the Codex answer and + needs no file at all. An older Codex did honour `prompts/`, which is + why this used to be true. + """ + claude = self.home / ".claude" + claude.mkdir() + codex = self.home / ".codex" + codex.mkdir() + accounts = self.home / ".local/share/session-kit/accounts" + (accounts / "codex" / "wren").mkdir(parents=True) + + self.run_installer("--non-interactive") + + # Claude still gets the verb; Codex gets nothing, anywhere. + self.assertTrue((claude / "commands" / "kit.md").is_file()) + self.assertFalse((codex / "prompts" / "kit.md").exists()) + self.assertFalse((codex / "prompts").exists()) + self.assertFalse( + (accounts / "codex" / "wren" / "prompts" / "kit.md").exists() + ) + + def test_the_codex_finding_is_pinned_to_the_version_it_was_measured_on( + self, + ) -> None: + """A version bump must force a re-measurement, not an assumption. + + The test above encodes a fact about codex-cli 0.145.0's behaviour, not + a law. If Codex ever restores custom slash commands, the right response + is to measure it again -- protocol methods, then the menu the real + client draws -- and give the verb back. This is the tripwire that makes + someone look. + """ + codex = shutil.which("codex") + if codex is None: + self.skipTest("no codex on this machine to measure") + result = subprocess.run( + [codex, "--version"], capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + self.skipTest(f"codex --version failed: {result.stderr.strip()}") + version = result.stdout.strip() + self.assertIn( + self.MEASURED_CODEX_VERSION, + version, + "Codex changed version since the /kit finding was measured " + f"({version!r} is not {self.MEASURED_CODEX_VERSION}). Re-measure " + "before trusting test_codex_gets_no_kit_file_because_codex_cannot_" + "use_one: check whether the app-server protocol has gained a " + "custom-command channel, and drive the real TUI to read its slash " + "menu. If /kit works again, restore the verb and this pin.", + ) + + def test_only_the_typed_kit_verb_may_ever_detach(self) -> None: + """The bare word `kit` must do nothing. + + Claude Code merged custom commands into skills, so a file in + `commands/` is model-invocable by default. This one's body opens with + an injected `!`shpool detach``, which runs when the skill content is + RENDERED -- before the model reads any of it -- and `allowed-tools` + pre-approves the command, so nothing prompts. An operator typed the + bare word `kit` as an ordinary message, the model matched it against + the description, and their terminal detached mid-turn while output was + still painting (2026-08-15). + + `disable-model-invocation: true` is the documented field that keeps + `/kit` working while removing the description from the model's context + entirely, so there is nothing left to match. Asserted on the INSTALLED + file, because that is the one a provider reads. + """ + claude = self.home / ".claude" + claude.mkdir() + accounts = self.home / ".local/share/session-kit/accounts" + (accounts / "claude" / "work").mkdir(parents=True) + + self.run_installer("--non-interactive") + + for path in ( + claude / "commands" / "kit.md", + accounts / "claude" / "work" / "commands" / "kit.md", + ): + with self.subTest(path=str(path)): + body = path.read_text(encoding="utf-8") + front = body.split("---")[1] + self.assertIn("disable-model-invocation: true", front) + # The injected command is still there -- the fix is the gate + # on WHO may fire it, not the removal of what it does. + self.assertIn("!`shpool detach`", body) + # And the typed verb still works: the file is still a command + # named kit, with its tool grant intact. + self.assertEqual("kit.md", path.name) + self.assertIn("allowed-tools: Bash(shpool detach:*)", front) + + def test_the_kit_verb_reaches_every_enrolled_account_profile(self) -> None: + """A session the kit launches on an account reads that account's + commands, not ~/.claude's: CLAUDE_CONFIG_DIR replaces the config root + rather than adding to it. A verb installed only in the home directory + is invisible in exactly the sessions it exists for, which is what the + live drill found, with /kit unknown inside a managed session.""" + (self.home / ".claude").mkdir() + accounts = self.home / ".local/share/session-kit/accounts" + for alias in ("work", "spare"): + (accounts / "claude" / alias).mkdir(parents=True) + (accounts / "codex" / "wren").mkdir(parents=True) + + self.run_installer("--non-interactive") + + for alias in ("work", "spare"): + path = accounts / "claude" / alias / "commands" / "kit.md" + with self.subTest(alias=alias): + self.assertTrue(path.is_file(), path) + self.assertIn("shpool detach", path.read_text(encoding="utf-8")) + self.assertEqual(0o600, path.stat().st_mode & 0o777) + # Codex profiles get nothing: measured 2026-08-15, codex-cli 0.145.0 + # never reads prompts/ and has no custom slash namespace, so a file + # here would be a claim rather than a capability. See + # test_codex_gets_no_kit_file_because_codex_cannot_use_one. + self.assertFalse((accounts / "codex" / "wren" / "prompts").exists()) + + def test_the_kit_verb_skips_a_provider_that_is_not_installed(self) -> None: + # No provider home is not a failure to report: it is a provider that + # is not on this machine. (The Codex home is not a fair test of this: + # the kit creates it itself for the window themes.) + self.run_installer("--non-interactive") + self.assertFalse((self.home / ".claude" / "commands").exists()) + + def test_the_kit_verb_ignores_the_account_profile_of_the_running_session( + self, + ) -> None: + """An install run from inside a managed session inherits that + session's account profile in CLAUDE_CONFIG_DIR. A machine-wide verb + installed into one rotating profile is invisible to the next account, + and writes into a directory the installer was never asked to touch.""" + claude = self.home / ".claude" + claude.mkdir() + profile = self.home / "account-profile" + (profile / "commands").mkdir(parents=True) + environment = dict(self.env, CLAUDE_CONFIG_DIR=str(profile)) + result = subprocess.run( + [str(REPO / "install.sh"), "--non-interactive"], + cwd=REPO, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertTrue((claude / "commands" / "kit.md").is_file()) + self.assertFalse((profile / "commands" / "kit.md").exists()) + + def test_the_kit_verb_never_writes_through_a_symlink(self) -> None: + claude = self.home / ".claude" + claude.mkdir() + commands = claude / "commands" + commands.mkdir(mode=0o700) + elsewhere = self.home / "not-a-command.md" + elsewhere.write_text("untouched\n", encoding="utf-8") + (commands / "kit.md").symlink_to(elsewhere) + + self.run_installer("--non-interactive") + + self.assertTrue((commands / "kit.md").is_symlink()) + self.assertEqual("untouched\n", elsewhere.read_text(encoding="utf-8")) + + @unittest.skipUnless( + host_group_is_private(), "this host does not give the account a private group" + ) + def test_install_accepts_a_group_writable_provider_directory(self) -> None: + # A private-group distribution with a 002 umask leaves the provider + # config directory at 0775, which exposes it to no other account. The + # install still writes through it -- the /kit verb is the write that + # remains -- and leaves the operator's own mode alone. + claude = self.home / ".claude" + claude.mkdir(mode=0o775) + claude.chmod(0o775) + + checked = self.run_installer("--check") + self.assertIn("OK source installable", checked.stdout) + self.run_installer("--non-interactive") + + self.assertTrue((claude / "commands" / "kit.md").is_file()) + self.assertEqual(0o775, claude.stat().st_mode & 0o777) + + @unittest.skipUnless( + host_group_is_private(), "this host does not give the account a private group" + ) + def test_install_accepts_a_group_writable_codex_home(self) -> None: + # The Codex CLI leaves ~/.codex group-writable on the same + # private-group distributions, and the theme layout owns that path. + codex = self.home / ".codex" + codex.mkdir(mode=0o700) + codex.chmod(0o775) + + checked = self.run_installer("--check") + self.assertIn( + "OK codex-home the theme layout is owner-controlled", + checked.stdout, + ) + self.run_installer("--non-interactive") + + self.assertTrue((codex / "themes/sk-red.tmTheme").is_file()) + self.assertFalse((codex / "prompts").exists()) + self.assertEqual(0o775, codex.stat().st_mode & 0o777) + + @unittest.skipUnless( + host_group_is_private(), "this host does not give the account a private group" + ) + def test_interrupted_install_recovers_through_group_writable_directories( + self, + ) -> None: + # Recovery revalidates every recorded provider-config and theme + # ancestor, so it has to accept the same directories the install + # accepted or an interrupted install can never be finished. + claude = self.home / ".claude" + claude.mkdir(mode=0o700) + claude.chmod(0o775) + codex = self.home / ".codex" + codex.mkdir(mode=0o700) + codex.chmod(0o775) + journal = self.home / ".local/state/session-kit/lifecycle-transaction.json" + + self.env["SESSION_KIT_TEST_FAILPOINT"] = "themes" + self.env["SESSION_KIT_TEST_FAILPOINT_MODE"] = "kill" + interrupted = self.run_installer("--non-interactive", check=False) + self.assertEqual(-signal.SIGKILL, interrupted.returncode) + self.assertTrue(journal.is_file()) + + self.env.pop("SESSION_KIT_TEST_FAILPOINT") + self.env.pop("SESSION_KIT_TEST_FAILPOINT_MODE") + recovered = self.run_installer("--non-interactive") + + self.assertIn( + "Recovered interrupted Session Kit install transaction", recovered.stdout + ) + self.assertFalse(journal.exists()) + self.assertTrue((self.home / ".local/lib/session-kit/current").is_symlink()) + self.assertTrue((claude / "commands" / "kit.md").is_file()) + self.assertEqual(0o775, claude.stat().st_mode & 0o777) + self.assertEqual(0o775, codex.stat().st_mode & 0o777) + + def test_world_writable_provider_directory_is_never_written_through( + self, + ) -> None: + """A directory any account can write is never a write target. + + Provider commands still treat an unavailable provider as absent, but + the required Claude status line and hook may not be silently skipped. + The overall install therefore refuses and names the unsafe directory. + """ + claude = self.home / ".claude" + claude.mkdir(mode=0o700) + claude.chmod(0o777) + + self.run_installer("--check") + refused = self.run_installer("--non-interactive", check=False) + + self.assertNotEqual(0, refused.returncode) + self.assertIn("Claude home is not an owner-controlled directory", refused.stderr) + self.assertEqual(0o777, claude.stat().st_mode & 0o777) + self.assertEqual([], sorted(path.name for path in claude.iterdir())) + self.assertFalse((claude / "settings.json").exists()) + self.assertFalse((claude / "commands").exists()) + def test_noninteractive_install_is_local_and_login_opt_in(self) -> None: self.run_installer("--non-interactive") current = self.home / ".local/lib/session-kit/current" @@ -147,6 +1005,12 @@ def test_noninteractive_install_is_local_and_login_opt_in(self) -> None: ) self.assertEqual(receipt["installed_release"], RELEASE_A) self.assertTrue((self.home / ".local/bin/sp").is_file()) + reset_tool = current / "bin/reset-collection-order.py" + self.assertTrue(reset_tool.is_file()) + self.assertTrue(reset_tool.stat().st_mode & 0o111) + self.assertTrue( + (current / "extras/statusline-quota-refresh.example").is_file() + ) self.assertTrue( (current / "lib/sessionkit_inventory/__init__.py").is_file() ) @@ -154,6 +1018,9 @@ def test_noninteractive_install_is_local_and_login_opt_in(self) -> None: self.assertTrue( (current / "lib/sessionkit_inventory/lifecycle.py").is_file() ) + self.assertTrue( + (current / "lib/sessionkit_inventory/projects.py").is_file() + ) self.assertTrue( (current / "lib/sessionkit_inventory/providers.py").is_file() ) @@ -168,82 +1035,1375 @@ def test_noninteractive_install_is_local_and_login_opt_in(self) -> None: self.assertIn(f"ExecStart={self.fake_bin / 'shpool'} daemon", service) self.assertNotIn("@SHPOOL@", service) - def test_enable_disable_login_and_private_marker(self) -> None: - self.run_installer("--enable-login", "--journal", "off") - bashrc = self.home / ".bashrc" - self.assertIn("session-kit managed integration", bashrc.read_text()) - marker = self.home / ".local/state/session-kit/integration-ready-v1" - self.assertEqual( - marker.read_text(encoding="utf-8"), - f"session-kit-integration-v1 {RELEASE_A}\n", - ) - self.assertEqual(marker.stat().st_mode & 0o777, 0o600) - self.assertTrue((self.home / ".no_shpool_journal").is_file()) - self.installed("disable-login") - self.assertNotIn("session-kit managed integration", bashrc.read_text()) - self.assertFalse(marker.exists()) + def test_install_registers_claude_integration_in_every_account_profile(self) -> None: + accounts = self.home / ".local/share/session-kit/accounts/claude" + for alias in ("one", "two"): + profile = accounts / alias + profile.mkdir(parents=True) + (profile / "settings.json").write_text( + json.dumps({"keep": alias}), encoding="utf-8" + ) - def test_rollover_install_preserves_validated_login_state(self) -> None: - self.run_installer("--enable-login", "--journal", "off") - marker = self.home / ".local/state/session-kit/integration-ready-v1" - self.assertTrue(marker.is_file()) - self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B self.run_installer("--non-interactive") - current = self.home / ".local/lib/session-kit/current" - self.assertEqual(current.resolve().name, RELEASE_B) - self.assertEqual( - marker.read_text(encoding="utf-8"), - f"session-kit-integration-v1 {RELEASE_B}\n", + + for settings_path in ( + self.home / ".claude/settings.json", + accounts / "one/settings.json", + accounts / "two/settings.json", + ): + settings = json.loads(settings_path.read_text(encoding="utf-8")) + self.assertEqual( + settings["statusLine"], + { + "type": "command", + "command": "~/.claude/statusline.sh", + "refreshInterval": 2, + }, + ) + for event in ("SessionStart", "UserPromptSubmit", "Stop"): + commands = [ + hook.get("command") + for group in settings["hooks"][event] + for hook in group["hooks"] + if isinstance(hook, dict) + ] + self.assertIn("~/.claude/hooks/nameintent_title.sh", commands) + + result = self.installed("doctor", "--json") + names = {row["name"]: row for row in json.loads(result.stdout)["checks"]} + self.assertEqual("ok", names["naming-hook"]["status"]) + self.assertEqual("ok", names["statusline"]["status"]) + + (self.home / ".claude/statusline.sh").write_text( + "#!/bin/sh\nexit 0\n", encoding="utf-8" ) - self.assertEqual(marker.stat().st_mode & 0o777, 0o600) - bashrc = self.home / ".bashrc" - self.assertIn("session-kit managed integration", bashrc.read_text()) - self.assertEqual( - 1, bashrc.read_text().count(">>> session-kit managed integration >>>") + drifted = self.installed("doctor", "--json") + drifted_names = { + row["name"]: row for row in json.loads(drifted.stdout)["checks"] + } + self.assertEqual("warn", drifted_names["statusline"]["status"]) + self.assertIn("content", drifted_names["statusline"]["detail"]) + + def test_install_refuses_unrelated_claude_statusline_without_force(self) -> None: + settings_path = self.home / ".claude/settings.json" + settings_path.parent.mkdir() + original_status = { + "type": "command", + "command": "/opt/operator/statusline", + "refreshInterval": 9, + } + settings_path.write_text( + json.dumps({"statusLine": original_status}) + "\n", encoding="utf-8" ) - self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_A - self.run_installer("--non-interactive", "--disable-login") - self.assertFalse(marker.exists()) + settings_path.chmod(0o600) - def test_update_and_rollback_switch_exact_releases(self) -> None: - self.run_installer("--non-interactive") - self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B - self.installed("update", "--source", str(REPO)) - current = self.home / ".local/lib/session-kit/current" - self.assertEqual(current.resolve().name, RELEASE_B) - self.installed("rollback") - self.assertEqual(current.resolve().name, RELEASE_A) + refused = self.run_installer("--non-interactive", check=False) - def test_update_defaults_journals_off_and_allows_explicit_opt_in(self) -> None: - self.run_installer("--journal", "on", "--non-interactive") - sentinel = self.home / ".no_shpool_journal" - self.assertFalse(sentinel.exists()) - self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B - self.installed("update", "--source", str(REPO)) - self.assertTrue(sentinel.is_file()) - self.installed( - "update", "--source", str(REPO), "--journal", "on" + self.assertNotEqual(0, refused.returncode) + self.assertIn("refusing unrelated Claude statusLine", refused.stderr) + self.assertIn("--force", refused.stderr) + self.assertEqual( + original_status, + json.loads(settings_path.read_text(encoding="utf-8"))["statusLine"], + ) + self.assertFalse((self.home / ".local/lib/session-kit/current").exists()) + self.assertFalse( + (self.home / ".local/state/session-kit/lifecycle-transaction.json").exists() ) - self.assertFalse(sentinel.exists()) - def test_interrupted_update_recovers_before_retry(self) -> None: + def test_update_statusline_refusal_happens_before_release_transaction(self) -> None: self.run_installer("--non-interactive") + root = self.home / ".local/lib/session-kit" + current = root / "current" + manager = root / "manager" + receipt = self.home / ".local/state/session-kit/install.json" + settings_path = self.home / ".claude/settings.json" + settings = json.loads(settings_path.read_text(encoding="utf-8")) + custom_status = { + "type": "command", + "command": "/opt/operator/update-status", + } + settings["statusLine"] = custom_status + settings_path.write_text(json.dumps(settings) + "\n", encoding="utf-8") + receipt_before = receipt.read_bytes() + releases_before = sorted(path.name for path in (root / "releases").iterdir()) + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B - self.env["SESSION_KIT_TEST_FAILPOINT"] = "current" - failed = self.installed( + refused = self.installed( "update", "--source", str(REPO), check=False ) - self.assertNotEqual(failed.returncode, 0) - current = self.home / ".local/lib/session-kit/current" - self.assertEqual(current.resolve().name, RELEASE_B) - transaction = ( - self.home / ".local/state/session-kit/lifecycle-transaction.json" + + self.assertNotEqual(0, refused.returncode) + self.assertIn("refusing unrelated Claude statusLine", refused.stderr) + self.assertEqual(RELEASE_A, current.resolve().name) + self.assertEqual(RELEASE_A, manager.resolve().name) + self.assertEqual(receipt_before, receipt.read_bytes()) + self.assertEqual( + releases_before, + sorted(path.name for path in (root / "releases").iterdir()), + ) + self.assertEqual( + custom_status, + json.loads(settings_path.read_text(encoding="utf-8"))["statusLine"], + ) + self.assertFalse( + (self.home / ".local/state/session-kit/lifecycle-transaction.json").exists() ) - self.assertTrue(transaction.is_file()) - del self.env["SESSION_KIT_TEST_FAILPOINT"] + def test_update_rejects_malformed_backup_entry_before_release_flip(self) -> None: + self.run_installer("--non-interactive") + root = self.home / ".local/lib/session-kit" + current = root / "current" + manager = root / "manager" + receipt = self.home / ".local/state/session-kit/install.json" + backups = self.home / ".local/state/session-kit/claude-statusline-backups.json" + settings = self.home / ".claude/settings.json" + backups.write_text( + json.dumps( + { + "schema_version": 1, + "entries": {str(settings): {"present": "not-bool"}}, + } + ) + + "\n", + encoding="utf-8", + ) + receipt_before = receipt.read_bytes() + releases_before = sorted(path.name for path in (root / "releases").iterdir()) + + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + refused = self.installed("update", "--source", str(REPO), check=False) + + self.assertNotEqual(0, refused.returncode) + self.assertIn("invalid Claude status-line backup", refused.stderr) + self.assertEqual(RELEASE_A, current.resolve().name) + self.assertEqual(RELEASE_A, manager.resolve().name) + self.assertEqual(receipt_before, receipt.read_bytes()) + self.assertEqual( + releases_before, + sorted(path.name for path in (root / "releases").iterdir()), + ) + self.assertFalse( + (self.home / ".local/state/session-kit/lifecycle-transaction.json").exists() + ) + + def test_enrolled_profile_statusline_is_preserved_without_blocking_install(self) -> None: + profile = ( + self.home + / ".local/share/session-kit/accounts/claude/independent" + ) + profile.mkdir(parents=True) + custom_status = { + "type": "command", + "command": "/opt/profile/statusline", + "refreshInterval": 7, + } + settings_path = profile / "settings.json" + settings_path.write_text( + json.dumps({"keep": True, "statusLine": custom_status}) + "\n", + encoding="utf-8", + ) + + installed = self.run_installer("--non-interactive") + + self.assertEqual(0, installed.returncode, installed.stderr) + self.assertIn( + f"skipping Claude statusLine rewrite in {settings_path}", + installed.stderr, + ) + settings = json.loads(settings_path.read_text(encoding="utf-8")) + self.assertEqual(custom_status, settings["statusLine"]) + self.assertTrue(settings["keep"]) + for event in ("SessionStart", "UserPromptSubmit", "Stop"): + commands = [ + hook.get("command") + for group in settings["hooks"][event] + for hook in group["hooks"] + if isinstance(hook, dict) + ] + self.assertIn("~/.claude/hooks/nameintent_title.sh", commands) + + def test_force_install_restores_preexisting_statusline_on_uninstall(self) -> None: + settings_path = self.home / ".claude/settings.json" + settings_path.parent.mkdir() + original_status = { + "type": "command", + "command": "/opt/operator/statusline", + "padding": {"left": 1}, + } + settings_path.write_text( + json.dumps({"keep": True, "statusLine": original_status}) + "\n", + encoding="utf-8", + ) + settings_path.chmod(0o600) + + self.run_installer("--non-interactive", "--force") + installed = json.loads(settings_path.read_text(encoding="utf-8")) + self.assertEqual("~/.claude/statusline.sh", installed["statusLine"]["command"]) + + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(REPO)) + self.installed("uninstall") + + restored = json.loads(settings_path.read_text(encoding="utf-8")) + self.assertTrue(restored["keep"]) + self.assertEqual(original_status, restored["statusLine"]) + self.assertFalse( + (self.home / ".local/state/session-kit/claude-statusline-backups.json").exists() + ) + + def test_force_update_preserves_statusline_set_after_install(self) -> None: + settings_path = self.home / ".claude/settings.json" + self.run_installer("--non-interactive") + settings = json.loads(settings_path.read_text(encoding="utf-8")) + operator_status = { + "type": "command", + "command": "/opt/operator/after-install", + } + settings["statusLine"] = operator_status + settings_path.write_text(json.dumps(settings) + "\n", encoding="utf-8") + + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(REPO), "--force") + self.installed("uninstall") + + restored = json.loads(settings_path.read_text(encoding="utf-8")) + self.assertEqual(operator_status, restored["statusLine"]) + + def test_force_update_refreshes_changed_operator_statusline_backup(self) -> None: + settings_path = self.home / ".claude/settings.json" + settings_path.parent.mkdir() + first_status = { + "type": "command", + "command": "/opt/operator/first", + } + settings_path.write_text( + json.dumps({"statusLine": first_status}) + "\n", encoding="utf-8" + ) + self.run_installer("--non-interactive", "--force") + + settings = json.loads(settings_path.read_text(encoding="utf-8")) + current_status = { + "type": "command", + "command": "/opt/operator/current", + } + settings["statusLine"] = current_status + settings_path.write_text(json.dumps(settings) + "\n", encoding="utf-8") + + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(REPO), "--force") + self.installed("uninstall") + + restored = json.loads(settings_path.read_text(encoding="utf-8")) + self.assertEqual(current_status, restored["statusLine"]) + + def test_force_update_preserves_enrolled_statusline_set_after_install(self) -> None: + profile = self.home / ".local/share/session-kit/accounts/claude/independent" + profile.mkdir(parents=True) + settings_path = profile / "settings.json" + settings_path.write_text('{"keep":true}\n', encoding="utf-8") + self.run_installer("--non-interactive") + + settings = json.loads(settings_path.read_text(encoding="utf-8")) + operator_status = { + "type": "command", + "command": "/opt/operator/enrolled-current", + } + settings["statusLine"] = operator_status + settings_path.write_text(json.dumps(settings) + "\n", encoding="utf-8") + + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(REPO), "--force") + self.installed("uninstall") + + restored = json.loads(settings_path.read_text(encoding="utf-8")) + self.assertTrue(restored["keep"]) + self.assertEqual(operator_status, restored["statusLine"]) + + def _assert_unusable_claude_settings_do_not_block_lifecycle( + self, *, enrolled: bool, bad_kind: str + ) -> None: + if enrolled: + profile = self.home / ".local/share/session-kit/accounts/claude/broken" + else: + profile = self.home / ".claude" + profile.mkdir(parents=True) + settings_path = profile / "settings.json" + fixtures = { + "invalid-json": (b"{\n", "invalid JSON"), + "non-object": (b"[]\n", "top level is not an object"), + "bad-hooks-group": ( + b'{"hooks":{"SessionStart":{}}}\n', + "SessionStart hooks setting is not a list", + ), + "oversize": (b"x" * 1_048_577, "larger than 1 MiB"), + "foreign-owner": ( + b'{"operator":true}\n', + "not owned by the current user", + ), + } + original, reason = fixtures[bad_kind] + settings_path.write_bytes(original) + settings_path.chmod(0o600) + if bad_kind == "foreign-owner": + foreign_uid = next( + entry.pw_uid for entry in pwd.getpwall() if entry.pw_uid != os.geteuid() + ) + os.chown(settings_path, foreign_uid, -1) + + def assert_skipped(result: subprocess.CompletedProcess[str]) -> None: + self.assertEqual(0, result.returncode, result.stderr) + matching = [ + line + for line in result.stderr.splitlines() + if f"skipping unusable Claude settings file {settings_path}:" in line + ] + self.assertEqual(1, len(matching), result.stderr) + self.assertIn(reason, matching[0]) + self.assertEqual(original, settings_path.read_bytes()) + + assert_skipped(self.run_installer("--non-interactive", check=False)) + root = self.home / ".local/lib/session-kit" + self.assertEqual(RELEASE_A, (root / "current").resolve().name) + + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + assert_skipped( + self.installed("update", "--source", str(REPO), check=False) + ) + self.assertEqual(RELEASE_B, (root / "current").resolve().name) + + assert_skipped(self.installed("rollback", "--to", RELEASE_A, check=False)) + self.assertEqual(RELEASE_A, (root / "current").resolve().name) + + def test_invalid_json_default_settings_do_not_block_lifecycle(self) -> None: + self._assert_unusable_claude_settings_do_not_block_lifecycle( + enrolled=False, bad_kind="invalid-json" + ) + + def test_invalid_json_enrolled_settings_do_not_block_lifecycle(self) -> None: + self._assert_unusable_claude_settings_do_not_block_lifecycle( + enrolled=True, bad_kind="invalid-json" + ) + + def test_non_object_default_settings_do_not_block_lifecycle(self) -> None: + self._assert_unusable_claude_settings_do_not_block_lifecycle( + enrolled=False, bad_kind="non-object" + ) + + def test_non_object_enrolled_settings_do_not_block_lifecycle(self) -> None: + self._assert_unusable_claude_settings_do_not_block_lifecycle( + enrolled=True, bad_kind="non-object" + ) + + def test_bad_hooks_default_settings_do_not_block_lifecycle(self) -> None: + self._assert_unusable_claude_settings_do_not_block_lifecycle( + enrolled=False, bad_kind="bad-hooks-group" + ) + + def test_bad_hooks_enrolled_settings_do_not_block_lifecycle(self) -> None: + self._assert_unusable_claude_settings_do_not_block_lifecycle( + enrolled=True, bad_kind="bad-hooks-group" + ) + + def test_oversize_default_settings_do_not_block_lifecycle(self) -> None: + self._assert_unusable_claude_settings_do_not_block_lifecycle( + enrolled=False, bad_kind="oversize" + ) + + def test_oversize_enrolled_settings_do_not_block_lifecycle(self) -> None: + self._assert_unusable_claude_settings_do_not_block_lifecycle( + enrolled=True, bad_kind="oversize" + ) + + @unittest.skipUnless(os.geteuid() == 0, "requires permission to create a foreign owner") + def test_foreign_owned_default_settings_do_not_block_lifecycle(self) -> None: + self._assert_unusable_claude_settings_do_not_block_lifecycle( + enrolled=False, bad_kind="foreign-owner" + ) + + @unittest.skipUnless(os.geteuid() == 0, "requires permission to create a foreign owner") + def test_foreign_owned_enrolled_settings_do_not_block_lifecycle(self) -> None: + self._assert_unusable_claude_settings_do_not_block_lifecycle( + enrolled=True, bad_kind="foreign-owner" + ) + + def test_preledger_kit_statusline_is_removed_on_uninstall(self) -> None: + settings_path = self.home / ".claude/settings.json" + settings_path.parent.mkdir() + settings_path.write_text( + json.dumps( + { + "keep": True, + "statusLine": { + "type": "command", + "command": "~/.claude/statusline.sh", + "refreshInterval": 2, + }, + } + ) + + "\n", + encoding="utf-8", + ) + settings_path.chmod(0o600) + + self.run_installer("--non-interactive") + self.installed("uninstall") + + restored = json.loads(settings_path.read_text(encoding="utf-8")) + self.assertTrue(restored["keep"]) + self.assertNotIn("statusLine", restored) + + def test_install_fails_when_claude_integration_directory_is_unsafe(self) -> None: + claude_home = self.home / ".claude" + claude_home.mkdir(mode=0o700) + hooks = claude_home / "hooks" + hooks.mkdir(mode=0o777) + hooks.chmod(0o777) + + refused = self.run_installer("--non-interactive", check=False) + + self.assertNotEqual(0, refused.returncode) + self.assertIn("Claude hooks directory is not owner-controlled", refused.stderr) + + def test_initial_install_can_import_existing_provider_projects(self) -> None: + claude_project = self.home / "work/claude-project" + codex_project = self.home / "work/codex-project" + shared = self.home / "work/shared" + for path in (claude_project, codex_project, shared): + path.mkdir(parents=True) + claude_config = self.home / ".claude.json" + claude_config.write_text( + json.dumps({"projects": {str(claude_project): {}, str(shared): {}}}), + encoding="utf-8", + ) + claude_config.chmod(0o600) + codex_home = self.home / ".codex" + codex_home.mkdir() + (codex_home / "config.toml").write_text( + f'[projects."{shared}"]\ntrust_level = "trusted"\n', + encoding="utf-8", + ) + connection = sqlite3.connect(codex_home / "state_5.sqlite") + connection.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, cwd TEXT)") + connection.execute( + "INSERT INTO threads VALUES (?, ?)", + ("00000000-0000-4000-8000-000000000001", str(codex_project)), + ) + connection.commit() + connection.close() + + result = self.run_installer("--non-interactive", "--import-projects") + + # The shared directory is discovered under both providers and still + # earns exactly one shortcut. + self.assertIn("Imported 3 new project shortcut(s)", result.stdout) + projects_file = self.home / ".config/session-kit/projects.tsv" + rows = { + tuple(line.split("\t")[:3]) + for line in projects_file.read_text(encoding="utf-8").splitlines() + if line and not line.startswith("#") + } + self.assertEqual( + rows, + { + ("claude-project", "claude", str(claude_project)), + ("codex-project", "codex", str(codex_project)), + ("shared", "claude", str(shared)), + }, + ) + listed = self.installed("projects", "list") + self.assertIn(f"shared\tclaude\t{shared}", listed.stdout) + + def test_noninteractive_install_defers_project_import_and_command_can_rerun_it(self) -> None: + project = self.home / "work/existing" + project.mkdir(parents=True) + config = self.home / ".claude.json" + config.write_text( + json.dumps({"projects": {str(project): {}}}), encoding="utf-8" + ) + config.chmod(0o600) + + installed = self.run_installer("--non-interactive") + + self.assertIn("Project import skipped", installed.stdout) + projects_file = self.home / ".config/session-kit/projects.tsv" + self.assertFalse( + any( + line and not line.startswith("#") + for line in projects_file.read_text(encoding="utf-8").splitlines() + ) + ) + imported = self.installed("projects", "import") + self.assertIn("Imported 1 new project shortcut(s)", imported.stdout) + self.assertIn(f"existing\tclaude\t{project}", projects_file.read_text()) + + def test_enable_disable_login_and_private_marker(self) -> None: + self.run_installer("--enable-login", "--journal", "off") + bashrc = self.home / ".bashrc" + self.assertIn("session-kit managed integration", bashrc.read_text()) + marker = self.home / ".local/state/session-kit/integration-ready-v1" + self.assertEqual( + marker.read_text(encoding="utf-8"), + f"session-kit-integration-v1 {RELEASE_A}\n", + ) + self.assertEqual(marker.stat().st_mode & 0o777, 0o600) + self.assertTrue((self.home / ".no_shpool_journal").is_file()) + self.installed("disable-login") + self.assertNotIn("session-kit managed integration", bashrc.read_text()) + self.assertFalse(marker.exists()) + + def test_rollover_install_preserves_validated_login_state(self) -> None: + self.run_installer("--enable-login", "--journal", "off") + marker = self.home / ".local/state/session-kit/integration-ready-v1" + self.assertTrue(marker.is_file()) + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.run_installer("--non-interactive") + current = self.home / ".local/lib/session-kit/current" + self.assertEqual(current.resolve().name, RELEASE_B) + self.assertEqual( + marker.read_text(encoding="utf-8"), + f"session-kit-integration-v1 {RELEASE_B}\n", + ) + self.assertEqual(marker.stat().st_mode & 0o777, 0o600) + bashrc = self.home / ".bashrc" + self.assertIn("session-kit managed integration", bashrc.read_text()) + self.assertEqual( + 1, bashrc.read_text().count(">>> session-kit managed integration >>>") + ) + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_A + self.run_installer("--non-interactive", "--disable-login") + self.assertFalse(marker.exists()) + + def test_update_and_rollback_switch_exact_releases(self) -> None: + self.run_installer("--non-interactive") + floor = self.home / ".local/state/session-kit/collection-sequence-floor.json" + floor_bytes = floor.read_bytes() + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(REPO)) + current = self.home / ".local/lib/session-kit/current" + self.assertEqual(current.resolve().name, RELEASE_B) + self.installed("rollback") + self.assertEqual(current.resolve().name, RELEASE_A) + self.assertEqual(floor_bytes, floor.read_bytes()) + + def test_rollback_unfences_a_code_proven_legacy_release_for_a_fresh_picker( + self, + ) -> None: + self.run_installer("--non-interactive") + legacy = self.make_release_fence_unaware(RELEASE_A) + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(REPO)) + + state = Path(self.env["SESSION_KIT_STATE_DIR"]) + inventory = state / "inventory.json" + inventory.write_text( + json.dumps( + { + "schema_version": 1, + "sessions": [ + {"display_title": "Alpha", "shpool_id_raw": "human-alpha"}, + {"display_title": "Beta", "shpool_id_raw": "human-beta"}, + ], + } + ) + + "\n", + encoding="utf-8", + ) + inventory.chmod(0o600) + sentinel = state / "inventory.lock" + sentinel.write_bytes(b"session-kit publishing lock generation 2\n") + sentinel.chmod(0o400) + + refused = subprocess.run( + [str(legacy / "bin/shpool_status"), "--json"], + env=self.env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertNotEqual(0, refused.returncode) + self.assertEqual("", refused.stdout) + + rolled_back = self.installed("rollback", "--to", RELEASE_A) + + self.assertIn("Rolled back Session Kit", rolled_back.stdout) + self.assertEqual(0o600, stat.S_IMODE(sentinel.stat().st_mode)) + self.assertEqual(b"", sentinel.read_bytes()) + fresh = subprocess.run( + [str(self.home / ".local/bin/shpool_status"), "--json"], + env=self.env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(0, fresh.returncode, fresh.stderr) + self.assertEqual( + ["human-alpha", "human-beta"], + [row["shpool_id_raw"] for row in json.loads(fresh.stdout)["sessions"]], + ) + + def test_rollback_keeps_the_fence_for_a_code_proven_aware_release(self) -> None: + self.run_installer("--non-interactive") + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(REPO)) + state = Path(self.env["SESSION_KIT_STATE_DIR"]) + sentinel = state / "inventory.lock" + expected = b"session-kit publishing lock generation 2\n" + sentinel.write_bytes(expected) + sentinel.chmod(0o400) + + self.installed("rollback", "--to", RELEASE_A) + + current = self.home / ".local/lib/session-kit/current" + self.assertEqual(RELEASE_A, current.resolve().name) + self.assertEqual(0o400, stat.S_IMODE(sentinel.stat().st_mode)) + self.assertEqual(expected, sentinel.read_bytes()) + + def test_crash_after_unfence_cannot_select_legacy_code_behind_the_fence( + self, + ) -> None: + self.run_installer("--non-interactive") + self.make_release_fence_unaware(RELEASE_A) + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(REPO)) + state = Path(self.env["SESSION_KIT_STATE_DIR"]) + sentinel = state / "inventory.lock" + sentinel.write_bytes(b"session-kit publishing lock generation 2\n") + sentinel.chmod(0o400) + self.env["SESSION_KIT_TEST_FAILPOINT"] = "rollback-unfenced" + self.env["SESSION_KIT_TEST_FAILPOINT_MODE"] = "kill" + + killed = self.installed("rollback", "--to", RELEASE_A, check=False) + + self.assertEqual(-signal.SIGKILL, killed.returncode) + current = self.home / ".local/lib/session-kit/current" + self.assertEqual(RELEASE_B, current.resolve().name) + self.assertEqual(0o600, stat.S_IMODE(sentinel.stat().st_mode)) + self.env.pop("SESSION_KIT_TEST_FAILPOINT") + self.env.pop("SESSION_KIT_TEST_FAILPOINT_MODE") + recovered = self.installed("rollback", "--to", RELEASE_A) + self.assertIn("Recovered interrupted Session Kit rollback", recovered.stdout) + self.assertEqual(RELEASE_A, current.resolve().name) + self.assertEqual(0o600, stat.S_IMODE(sentinel.stat().st_mode)) + + def test_generation_two_publisher_cannot_refence_during_legacy_rollback( + self, + ) -> None: + self.run_installer("--non-interactive") + self.make_release_fence_unaware(RELEASE_A) + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(REPO)) + state = Path(self.env["SESSION_KIT_STATE_DIR"]) + sentinel = state / "inventory.lock" + sentinel.write_bytes(b"session-kit publishing lock generation 2\n") + sentinel.chmod(0o400) + events: list[str] = [] + + def publish_in_gap() -> None: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + try: + if stat.S_IMODE(sentinel.stat().st_mode) == 0o600: + events.append("unfenced") + try: + with StateLock(state, state / "inventory-v2.lock"): + events.append("published") + except CollectionError as exc: + events.append(str(exc)) + return + except OSError: + pass + events.append("unfence-not-observed") + + publisher = threading.Thread( + target=publish_in_gap, name="generation-two-publisher" + ) + publisher.start() + rolled_back = self.installed("rollback", "--to", RELEASE_A) + publisher.join(12) + + self.assertFalse(publisher.is_alive()) + self.assertIn("Rolled back Session Kit", rolled_back.stdout) + self.assertEqual( + RELEASE_A, + (self.home / ".local/lib/session-kit/current").resolve().name, + ) + self.assertEqual(0o600, stat.S_IMODE(sentinel.stat().st_mode)) + self.assertNotIn("published", events) + self.assertTrue( + any("legacy rollback selected" in event for event in events), events + ) + + def test_forward_update_retires_the_legacy_rollback_hold(self) -> None: + self.run_installer("--non-interactive") + self.make_release_fence_unaware(RELEASE_A) + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(REPO)) + state = Path(self.env["SESSION_KIT_STATE_DIR"]) + inventory = state / "inventory.json" + inventory.write_text( + '{"schema_version":1,"sessions":[]}\n', encoding="utf-8" + ) + inventory.chmod(0o600) + sentinel = state / "inventory.lock" + sentinel.write_bytes(b"session-kit publishing lock generation 2\n") + sentinel.chmod(0o400) + + self.installed("rollback", "--to", RELEASE_A) + + hold = state / "inventory-rollback-v2" + self.assertTrue(hold.is_file()) + self.assertEqual(0o400, stat.S_IMODE(hold.stat().st_mode)) + self.assertEqual( + b"session-kit legacy rollback selected\n", hold.read_bytes() + ) + self.assertEqual(0o600, stat.S_IMODE(sentinel.stat().st_mode)) + + self.installed("update", "--source", str(REPO)) + refreshed = subprocess.run( + [str(self.home / ".local/bin/shpool_status"), "--json"], + env=self.env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual(0, refreshed.returncode, refreshed.stderr) + self.assertFalse(hold.exists()) + self.assertEqual(0o400, stat.S_IMODE(sentinel.stat().st_mode)) + + def test_each_legacy_rollback_fence_kill_point_leaves_a_safe_pair(self) -> None: + cases = ( + ("rollback-fence-marked", RELEASE_B, 0o400), + ("rollback-unfenced", RELEASE_B, 0o600), + ("current", RELEASE_A, 0o600), + ) + for failpoint, selected, lock_mode in cases: + with self.subTest(failpoint=failpoint): + try: + self.run_installer("--non-interactive") + self.make_release_fence_unaware(RELEASE_A) + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(REPO)) + state = Path(self.env["SESSION_KIT_STATE_DIR"]) + inventory = state / "inventory.json" + inventory.write_text( + '{"schema_version":1,"sessions":[]}\n', encoding="utf-8" + ) + inventory.chmod(0o600) + sentinel = state / "inventory.lock" + sentinel.write_bytes( + b"session-kit publishing lock generation 2\n" + ) + sentinel.chmod(0o400) + self.env["SESSION_KIT_TEST_FAILPOINT"] = failpoint + self.env["SESSION_KIT_TEST_FAILPOINT_MODE"] = "kill" + + killed = self.installed( + "rollback", "--to", RELEASE_A, check=False + ) + + self.assertEqual(-signal.SIGKILL, killed.returncode) + current = self.home / ".local/lib/session-kit/current" + self.assertEqual(selected, current.resolve().name) + self.assertEqual( + lock_mode, stat.S_IMODE(sentinel.stat().st_mode) + ) + hold = state / "inventory-rollback-v2" + self.assertEqual(0o400, stat.S_IMODE(hold.stat().st_mode)) + self.assertEqual( + b"session-kit legacy rollback selected\n", + hold.read_bytes(), + ) + if selected == RELEASE_A: + fresh = subprocess.run( + [ + str(self.home / ".local/bin/shpool_status"), + "--json", + ], + env=self.env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(0, fresh.returncode, fresh.stderr) + finally: + self.env.pop("SESSION_KIT_TEST_FAILPOINT", None) + self.env.pop("SESSION_KIT_TEST_FAILPOINT_MODE", None) + self.tearDown() + self.setUp() + + def test_rollback_refuses_a_lookalike_fence_before_selecting_legacy_code( + self, + ) -> None: + self.run_installer("--non-interactive") + self.make_release_fence_unaware(RELEASE_A) + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(REPO)) + state = Path(self.env["SESSION_KIT_STATE_DIR"]) + sentinel = state / "inventory.lock" + lookalike = b"session-kit publishing lock generation two\n" + sentinel.write_bytes(lookalike) + sentinel.chmod(0o400) + + refused = self.installed("rollback", "--to", RELEASE_A, check=False) + + self.assertNotEqual(0, refused.returncode) + self.assertIn("not the exact generation-2 fence", refused.stderr) + current = self.home / ".local/lib/session-kit/current" + self.assertEqual(RELEASE_B, current.resolve().name) + self.assertEqual(lookalike, sentinel.read_bytes()) + self.assertEqual(0o400, stat.S_IMODE(sentinel.stat().st_mode)) + + def test_rollback_accepts_legacy_release_without_claude_payload(self) -> None: + self.run_installer("--non-interactive") + root = self.home / ".local/lib/session-kit" + legacy = root / "releases" / RELEASE_A + self.unseal_release(legacy) + shutil.rmtree(legacy / "config/claude") + self.seal_release(legacy) + + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(REPO)) + statusline = self.home / ".claude/statusline.sh" + modern_statusline = statusline.read_bytes() + + rolled_back = self.installed("rollback", "--to", RELEASE_A) + + self.assertIn("Rolled back Session Kit", rolled_back.stdout) + self.assertEqual(RELEASE_A, (root / "current").resolve().name) + self.assertEqual(modern_statusline, statusline.read_bytes()) + + def test_rollback_skips_statusline_drift_and_still_flips_release(self) -> None: + self.run_installer("--non-interactive") + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(REPO)) + settings_path = self.home / ".claude/settings.json" + settings = json.loads(settings_path.read_text(encoding="utf-8")) + custom_status = { + "type": "command", + "command": "/opt/operator/emergency-status", + } + settings["statusLine"] = custom_status + settings_path.write_text(json.dumps(settings) + "\n", encoding="utf-8") + + rolled_back = self.installed("rollback", "--to", RELEASE_A) + + self.assertIn("Rolled back Session Kit", rolled_back.stdout) + self.assertIn( + f"skipping Claude statusLine rewrite in {settings_path}", + rolled_back.stderr, + ) + self.assertNotIn("--force", rolled_back.stderr) + self.assertEqual( + RELEASE_A, + (self.home / ".local/lib/session-kit/current").resolve().name, + ) + self.assertEqual( + custom_status, + json.loads(settings_path.read_text(encoding="utf-8"))["statusLine"], + ) + + def test_doctor_uses_integration_ledger_after_legacy_rollback(self) -> None: + self.install_mixed_modern_legacy_history() + + doctor = self.installed("doctor", "--json") + + checks = {row["name"]: row for row in json.loads(doctor.stdout)["checks"]} + self.assertEqual("ok", checks["naming-hook"]["status"]) + self.assertEqual("ok", checks["statusline"]["status"]) + self.assertNotIn("content", checks["naming-hook"]["detail"]) + self.assertNotIn("content", checks["statusline"]["detail"]) + + def test_doctor_requires_owner_only_modes_for_claude_scripts(self) -> None: + self.run_installer("--non-interactive") + hook = self.home / ".claude/hooks/nameintent_title.sh" + statusline = self.home / ".claude/statusline.sh" + hook.chmod(0o755) + statusline.chmod(0o755) + + doctor = self.installed("doctor", "--json") + + checks = {row["name"]: row for row in json.loads(doctor.stdout)["checks"]} + self.assertEqual("warn", checks["naming-hook"]["status"]) + self.assertIn("hook file", checks["naming-hook"]["detail"]) + self.assertEqual("warn", checks["statusline"]["status"]) + self.assertIn("status line file", checks["statusline"]["detail"]) + + def test_uninstall_removes_ledger_owned_scripts_after_legacy_rollback(self) -> None: + self.install_mixed_modern_legacy_history() + hook = self.home / ".claude/hooks/nameintent_title.sh" + statusline = self.home / ".claude/statusline.sh" + manager_statusline = ( + self.home + / ".local/lib/session-kit/manager/config/claude/statusline.sh" + ) + self.assertTrue(hook.is_file()) + self.assertTrue(statusline.is_file()) + self.assertNotEqual(statusline.read_bytes(), manager_statusline.read_bytes()) + + self.installed("uninstall") + + self.assertFalse(hook.exists()) + self.assertFalse(statusline.exists()) + self.assertFalse( + (self.home / ".local/state/session-kit/claude-integration.json").exists() + ) + + def test_uninstall_partial_ledger_removes_recorded_file_and_retains_ledger(self) -> None: + self.run_installer("--non-interactive") + hook = self.home / ".claude/hooks/nameintent_title.sh" + statusline = self.home / ".claude/statusline.sh" + ledger_path = self.home / ".local/state/session-kit/claude-integration.json" + ledger = json.loads(ledger_path.read_text(encoding="utf-8")) + ledger["files"].pop(str(statusline)) + ledger_path.write_text(json.dumps(ledger) + "\n", encoding="utf-8") + + removed = self.installed("uninstall") + + self.assertFalse(hook.exists()) + self.assertTrue(statusline.is_file()) + self.assertTrue(ledger_path.is_file()) + self.assertIn("incomplete Claude integration ledger retained", removed.stderr) + self.assertIn(str(statusline), removed.stderr) + + def test_uninstall_digest_drift_retains_only_unremoved_ledger_entry(self) -> None: + self.run_installer("--non-interactive") + hook = self.home / ".claude/hooks/nameintent_title.sh" + statusline = self.home / ".claude/statusline.sh" + ledger_path = self.home / ".local/state/session-kit/claude-integration.json" + statusline.write_text( + statusline.read_text(encoding="utf-8") + "\n# operator edit\n", + encoding="utf-8", + ) + + removed = self.installed("uninstall") + + self.assertFalse(hook.exists()) + self.assertTrue(statusline.is_file()) + ledger = json.loads(ledger_path.read_text(encoding="utf-8")) + self.assertEqual({str(statusline)}, set(ledger["files"])) + self.assertIn("Claude integration ledger retained", removed.stderr) + self.assertIn(str(statusline), removed.stderr) + + def assert_uninstall_refusal_preserves_login( + self, ledger_name: str, invalid_payload: object, expected_message: str + ) -> None: + self.run_installer("--non-interactive", "--enable-login") + state = self.home / ".local/state/session-kit" + (state / ledger_name).write_text( + json.dumps(invalid_payload) + "\n", encoding="utf-8" + ) + marker = state / "integration-ready-v1" + bashrc = self.home / ".bashrc" + self.assertTrue(marker.is_file()) + self.assertIn("session-kit managed integration", bashrc.read_text(encoding="utf-8")) + + refused = self.installed("uninstall", check=False) + + self.assertNotEqual(0, refused.returncode) + self.assertIn(expected_message, refused.stderr) + self.assertFalse((state / "lifecycle-transaction.json").exists()) + self.assertTrue(marker.is_file()) + self.assertIn("session-kit managed integration", bashrc.read_text(encoding="utf-8")) + + def test_uninstall_invalid_integration_ledger_preserves_login(self) -> None: + self.assert_uninstall_refusal_preserves_login( + "claude-integration.json", + {"schema_version": 1, "release_id": RELEASE_A, "files": {"bad": "0" * 64}}, + "invalid Claude integration ledger", + ) + + def test_uninstall_invalid_statusline_backup_preserves_login(self) -> None: + self.assert_uninstall_refusal_preserves_login( + "claude-statusline-backups.json", + {"schema_version": 1, "entries": {"relative": {"present": False}}}, + "invalid Claude status-line backup", + ) + + def test_uninstall_post_preflight_failure_recovers_login_and_journal(self) -> None: + self.run_installer("--non-interactive", "--enable-login") + failing_rm = self.fake_bin / "rm" + failing_rm.write_text("#!/usr/bin/env bash\nexit 77\n", encoding="utf-8") + failing_rm.chmod(0o755) + state = self.home / ".local/state/session-kit" + marker = state / "integration-ready-v1" + bashrc = self.home / ".bashrc" + + refused = self.installed("uninstall", check=False) + + self.assertEqual(77, refused.returncode) + self.assertIn("Recovered interrupted Session Kit uninstall", refused.stdout) + self.assertFalse((state / "lifecycle-transaction.json").exists()) + self.assertTrue(marker.is_file()) + self.assertIn("session-kit managed integration", bashrc.read_text(encoding="utf-8")) + self.assertTrue((self.home / ".claude/statusline.sh").is_file()) + self.assertTrue((self.home / ".claude/hooks/nameintent_title.sh").is_file()) + + def test_doctor_warns_for_invalid_statusline_backup_ledger(self) -> None: + self.run_installer("--non-interactive") + backups = self.home / ".local/state/session-kit/claude-statusline-backups.json" + backups.write_text( + json.dumps({"schema_version": 1, "entries": {"relative": {"present": False}}}) + + "\n", + encoding="utf-8", + ) + + doctor = self.installed("doctor", "--json") + + checks = {row["name"]: row for row in json.loads(doctor.stdout)["checks"]} + self.assertEqual("warn", checks["claude-statusline-backups"]["status"]) + self.assertIn("invalid data", checks["claude-statusline-backups"]["detail"]) + + def test_doctor_warns_for_existing_unsafe_integration_ledger(self) -> None: + self.run_installer("--non-interactive") + ledger_path = self.home / ".local/state/session-kit/claude-integration.json" + target = ledger_path.with_name("unsafe-ledger-target.json") + ledger_path.rename(target) + ledger_path.symlink_to(target) + + doctor = self.installed("doctor", "--json") + + checks = {row["name"]: row for row in json.loads(doctor.stdout)["checks"]} + self.assertEqual("warn", checks["claude-integration-ledger"]["status"]) + self.assertIn("symbolic link", checks["claude-integration-ledger"]["detail"]) + self.assertEqual("warn", checks["naming-hook"]["status"]) + self.assertEqual("warn", checks["statusline"]["status"]) + + def test_uninstall_rejects_unexpected_integration_ledger_key(self) -> None: + self.run_installer("--non-interactive") + hook = self.home / ".claude/hooks/nameintent_title.sh" + statusline = self.home / ".claude/statusline.sh" + ledger_path = self.home / ".local/state/session-kit/claude-integration.json" + ledger = json.loads(ledger_path.read_text(encoding="utf-8")) + ledger["files"][str(self.home / "unrelated-script")] = "0" * 64 + ledger_path.write_text(json.dumps(ledger) + "\n", encoding="utf-8") + + refused = self.installed("uninstall", check=False) + + self.assertNotEqual(0, refused.returncode) + self.assertIn("invalid Claude integration ledger", refused.stderr) + self.assertTrue(hook.is_file()) + self.assertTrue(statusline.is_file()) + self.assertTrue(ledger_path.is_file()) + + def test_uninstall_removes_per_account_quota_cache_tree(self) -> None: + self.run_installer("--non-interactive") + account = self.home / ".local/share/session-kit/accounts/claude/work" + account.mkdir(parents=True) + credentials = account / ".credentials.json" + credentials.write_text('{"token":"fixture-only"}\n', encoding="utf-8") + cache = self.home / ".claude/cache/session-kit-quota/work-fixture" + probe = cache / "probe-home/.claude" + probe.mkdir(parents=True) + (cache / "quota_headers").write_text("x-probe-account: fixture\n", encoding="utf-8") + (probe / ".credentials.json").symlink_to(credentials) + + self.installed("uninstall") + + self.assertFalse((self.home / ".claude/cache/session-kit-quota").exists()) + self.assertTrue(credentials.is_file()) + + def test_real_installed_release_layouts_are_legacy_or_complete(self) -> None: + """Exercise every real rollback payload shape without changing it.""" + releases = Path.home() / ".local/lib/session-kit/releases" + if not releases.is_dir(): + self.skipTest("no installed Session Kit release corpus") + layouts: dict[str, int] = {"legacy": 0, "modern": 0} + visited = 0 + for release in sorted(releases.iterdir()): + if not release.is_dir(): + continue + title = release / "config/claude/nameintent_title.sh" + status = release / "config/claude/statusline.sh" + shape = ( + title.is_file() and not title.is_symlink(), + status.is_file() and not status.is_symlink(), + ) + self.assertIn( + shape, + {(False, False), (True, True)}, + f"partial Claude integration payload in {release.name}", + ) + layouts["modern" if shape == (True, True) else "legacy"] += 1 + visited += 1 + self.assertGreater(visited, 0) + self.assertGreater(layouts["legacy"], 0) + self.assertGreater(layouts["modern"], 0) + + def test_legacy_rollback_keeps_modern_manager_for_forward_update(self) -> None: + self.install_legacy_generation() + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.run_installer("--non-interactive") + root = self.home / ".local/lib/session-kit" + current = root / "current" + manager = root / "manager" + launcher = self.home / ".local/bin/session-kit" + release_b = root / "releases" / RELEASE_B + self.assertEqual(RELEASE_B, manager.resolve().name) + + self.installed("rollback", "--to", RELEASE_A) + + self.assertEqual(RELEASE_A, current.resolve().name) + self.assertEqual(RELEASE_B, manager.resolve().name) + self.assertEqual( + (release_b / "deploy/session-kit-launcher").read_bytes(), + launcher.read_bytes(), + ) + self.assertNotEqual( + (root / "releases" / RELEASE_A / "deploy/session-kit-launcher").read_bytes(), + launcher.read_bytes(), + ) + + updated = self.installed("update", "--source", str(REPO)) + + self.assertIn("Installed Session Kit release", updated.stdout) + self.assertEqual(RELEASE_B, current.resolve().name) + self.assertEqual(RELEASE_B, manager.resolve().name) + doctor = self.installed("doctor", "--json") + checks = {row["name"]: row for row in json.loads(doctor.stdout)["checks"]} + self.assertEqual("ok", checks["manager"]["status"]) + self.assertEqual("ok", checks["manager-launcher"]["status"]) + + def test_direct_manager_rollback_pins_preflip_launcher_source(self) -> None: + self.install_legacy_generation() + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.run_installer("--non-interactive") + root = self.home / ".local/lib/session-kit" + release_b = root / "releases" / RELEASE_B + launcher = self.home / ".local/bin/session-kit" + shutil.copy2(release_b / "bin/session-kit", launcher) + + rolled_back = self.installed("rollback", "--to", RELEASE_A) + + self.assertIn("Rolled back Session Kit", rolled_back.stdout) + self.assertEqual(RELEASE_A, (root / "current").resolve().name) + self.assertEqual( + (release_b / "deploy/session-kit-launcher").read_bytes(), + launcher.read_bytes(), + ) + self.assertNotEqual( + (root / "releases" / RELEASE_A / "deploy/session-kit-launcher").read_bytes(), + launcher.read_bytes(), + ) + self.installed("doctor", "--json") + + def test_management_launcher_fails_closed_without_manager_anchor(self) -> None: + self.run_installer("--non-interactive") + manager = self.home / ".local/lib/session-kit/manager" + manager.unlink() + + result = self.installed("doctor", check=False) + + self.assertEqual(78, result.returncode) + self.assertIn("unsafe manager link", result.stderr) + + def test_rollback_to_a_legacy_release_and_next_recovery_succeed(self) -> None: + self.install_legacy_generation() + + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.run_installer("--non-interactive") + current = self.home / ".local/lib/session-kit/current" + self.assertEqual(RELEASE_B, current.resolve().name) + + rolled_back = self.installed("rollback", "--to", RELEASE_A) + self.assertIn("Rolled back Session Kit", rolled_back.stdout) + self.assertEqual(RELEASE_A, current.resolve().name) + + self.env["SESSION_KIT_TEST_FAILPOINT"] = "current" + self.env["SESSION_KIT_TEST_FAILPOINT_MODE"] = "kill" + interrupted = self.installed( + "update", "--source", str(REPO), check=False + ) + self.assertEqual(-signal.SIGKILL, interrupted.returncode) + journal = self.home / ".local/state/session-kit/lifecycle-transaction.json" + self.assertTrue(journal.is_file()) + self.env.pop("SESSION_KIT_TEST_FAILPOINT") + self.env.pop("SESSION_KIT_TEST_FAILPOINT_MODE") recovered = self.installed("update", "--source", str(REPO)) self.assertIn("Recovered interrupted Session Kit install", recovered.stdout) + self.assertFalse(journal.exists()) + + def test_sigkill_before_rollback_commit_uses_pinned_recovery_release(self) -> None: + self.install_legacy_generation() + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.run_installer("--non-interactive") + + self.env["SESSION_KIT_TEST_FAILPOINT"] = "rollback-precommit" + self.env["SESSION_KIT_TEST_FAILPOINT_MODE"] = "kill" + killed = self.installed("rollback", "--to", RELEASE_A, check=False) + self.assertEqual(-signal.SIGKILL, killed.returncode) + current = self.home / ".local/lib/session-kit/current" + journal = self.home / ".local/state/session-kit/lifecycle-transaction.json" + launcher = self.home / ".local/bin/session-kit" + self.assertEqual(RELEASE_A, current.resolve().name) + self.assertTrue(journal.is_file()) + self.assertEqual( + (self.home / ".local/lib/session-kit/releases" / RELEASE_B / "deploy/session-kit-launcher").read_bytes(), + launcher.read_bytes(), + ) + + self.env.pop("SESSION_KIT_TEST_FAILPOINT") + self.env.pop("SESSION_KIT_TEST_FAILPOINT_MODE") + recovered = self.installed("update", "--source", str(REPO)) + self.assertIn("Recovered interrupted Session Kit rollback", recovered.stdout) + self.assertEqual(RELEASE_B, current.resolve().name) + self.assertFalse(journal.exists()) + + def test_sigkill_immediately_after_rollback_commit_keeps_stable_launcher(self) -> None: + self.install_legacy_generation() + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.run_installer("--non-interactive") + + self.env["SESSION_KIT_TEST_FAILPOINT"] = "rollback-postcommit" + self.env["SESSION_KIT_TEST_FAILPOINT_MODE"] = "kill" + killed = self.installed("rollback", "--to", RELEASE_A, check=False) + self.assertEqual(-signal.SIGKILL, killed.returncode) + current = self.home / ".local/lib/session-kit/current" + journal = self.home / ".local/state/session-kit/lifecycle-transaction.json" + launcher = self.home / ".local/bin/session-kit" + self.assertEqual(RELEASE_A, current.resolve().name) + self.assertFalse(journal.exists()) + self.assertEqual( + (self.home / ".local/lib/session-kit/releases" / RELEASE_B / "deploy/session-kit-launcher").read_bytes(), + launcher.read_bytes(), + ) + + self.env.pop("SESSION_KIT_TEST_FAILPOINT") + self.env.pop("SESSION_KIT_TEST_FAILPOINT_MODE") + recovered = self.installed("update", "--source", str(REPO)) + self.assertEqual(RELEASE_B, current.resolve().name) + self.assertFalse(journal.exists()) + self.assertIn("Installed Session Kit release", recovered.stdout) + + def test_install_owns_claude_integration_and_preserves_other_hooks(self) -> None: + claude_settings = self.home / ".claude/settings.json" + codex_hooks = self.home / ".codex/hooks.json" + claude_settings.parent.mkdir() + codex_hooks.parent.mkdir() + claude_original = ( + b'{ "hooks" : { "UserPromptSubmit" : [{"hooks":[' + b'{"command":"python3 ~/.claude/hooks/quota_human_session.py"},' + b'{"command":"sh ~/.claude/hooks/nameintent_title.sh"}' + b']}]}, "keep" : true }\n' + ) + codex_original = ( + b'{"hooks":{"UserPromptSubmit":[{"hooks":[' + b'{"command":"user-codex-hook","type":"command"}' + b']}]},"description":"mine"}\n' + ) + for path, payload in ( + (claude_settings, claude_original), + (codex_hooks, codex_original), + ): + path.write_bytes(payload) + path.chmod(0o600) + + self.run_installer("--non-interactive") + settings = json.loads(claude_settings.read_text(encoding="utf-8")) + self.assertTrue(settings["keep"]) + self.assertEqual( + settings["statusLine"], + { + "type": "command", + "command": "~/.claude/statusline.sh", + "refreshInterval": 2, + }, + ) + prompt_commands = [ + hook["command"] + for group in settings["hooks"]["UserPromptSubmit"] + for hook in group["hooks"] + if isinstance(hook, dict) and "command" in hook + ] + self.assertIn("python3 ~/.claude/hooks/quota_human_session.py", prompt_commands) + self.assertIn("sh ~/.claude/hooks/nameintent_title.sh", prompt_commands) + self.assertIn("~/.claude/hooks/nameintent_title.sh", prompt_commands) + self.assertEqual( + (self.home / ".claude/hooks/nameintent_title.sh").read_bytes(), + (REPO / "config/claude/nameintent_title.sh").read_bytes(), + ) + self.assertEqual( + (self.home / ".claude/statusline.sh").read_bytes(), + (REPO / "config/claude/statusline.sh").read_bytes(), + ) + self.assertEqual(codex_original, codex_hooks.read_bytes()) + self.installed("uninstall") + restored = json.loads(claude_settings.read_text(encoding="utf-8")) + self.assertTrue(restored["keep"]) + self.assertNotIn("statusLine", restored) + remaining_commands = [ + hook["command"] + for group in restored["hooks"]["UserPromptSubmit"] + for hook in group["hooks"] + if isinstance(hook, dict) and "command" in hook + ] + self.assertEqual( + remaining_commands, + [ + "python3 ~/.claude/hooks/quota_human_session.py", + "sh ~/.claude/hooks/nameintent_title.sh", + ], + ) + self.assertFalse((self.home / ".claude/hooks/nameintent_title.sh").exists()) + self.assertFalse((self.home / ".claude/statusline.sh").exists()) + self.assertEqual(codex_original, codex_hooks.read_bytes()) + + def test_update_preserves_journal_choice_unless_explicitly_changed(self) -> None: + # The old default flipped journals OFF on every update; that silent + # flip erased all terminal recordings 2026-07-30..08-12. An update now + # keeps the operator's choice in both directions unless --journal is + # passed explicitly. + self.run_installer("--journal", "on", "--non-interactive") + sentinel = self.home / ".no_shpool_journal" + self.assertFalse(sentinel.exists()) + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(REPO)) + self.assertFalse(sentinel.exists()) + self.installed( + "update", "--source", str(REPO), "--journal", "off" + ) + self.assertTrue(sentinel.is_file()) + self.installed("update", "--source", str(REPO)) + self.assertTrue(sentinel.is_file()) + self.installed( + "update", "--source", str(REPO), "--journal", "on" + ) + self.assertFalse(sentinel.exists()) + + def test_noninteractive_rollover_keeps_journals_on(self) -> None: + # The exact 2026-08-12 regression: a --non-interactive reinstall over + # an existing installation re-created the kill switch and silently + # stopped all new-session recording. + self.run_installer("--journal", "on", "--non-interactive") + sentinel = self.home / ".no_shpool_journal" + self.assertFalse(sentinel.exists()) + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.run_installer("--non-interactive") + self.assertFalse(sentinel.exists()) + + def test_interrupted_update_recovers_before_retry(self) -> None: + self.run_installer("--non-interactive") + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.env["SESSION_KIT_TEST_FAILPOINT"] = "current" + failed = self.installed( + "update", "--source", str(REPO), check=False + ) + self.assertNotEqual(failed.returncode, 0) + current = self.home / ".local/lib/session-kit/current" + self.assertEqual(current.resolve().name, RELEASE_A) + transaction = ( + self.home / ".local/state/session-kit/lifecycle-transaction.json" + ) + self.assertFalse(transaction.exists()) + self.assertIn("Recovered interrupted Session Kit install", failed.stdout) + + del self.env["SESSION_KIT_TEST_FAILPOINT"] + self.installed("update", "--source", str(REPO)) self.assertEqual(current.resolve().name, RELEASE_B) self.assertFalse(transaction.exists()) backups = list( @@ -251,120 +2411,922 @@ def test_interrupted_update_recovers_before_retry(self) -> None: "lifecycle-*.json" ) ) - self.assertGreaterEqual(len(backups), 2) + self.assertGreaterEqual(len(backups), 2) + + def test_reused_release_is_verified_before_mutation(self) -> None: + self.run_installer("--non-interactive") + current = self.home / ".local/lib/session-kit/current" + target = current / "bin/sp" + target.chmod(0o755) + target.write_text("#!/bin/sh\nexit 99\n", encoding="utf-8") + target.chmod(0o555) + result = self.installed( + "update", "--source", str(REPO), check=False + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("release verification failed", result.stderr) + self.assertFalse( + ( + self.home + / ".local/state/session-kit/lifecycle-transaction.json" + ).exists() + ) + + def test_purge_requires_matching_ownership_before_login_mutation(self) -> None: + self.run_installer("--enable-login", "--journal", "off") + marker = ( + self.home + / ".local/lib/session-kit/.session-kit-owned.json" + ) + marker.write_text('{"owner":"someone-else"}\n', encoding="utf-8") + bashrc = self.home / ".bashrc" + before = bashrc.read_bytes() + result = self.installed("uninstall", "--purge-code", check=False) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(bashrc.read_bytes(), before) + self.assertTrue((self.home / ".local/bin/sp").is_file()) + + def test_broad_install_root_is_rejected_without_mutation(self) -> None: + self.env["SESSION_KIT_ROOT"] = str(self.home) + result = self.run_installer("--non-interactive", check=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn("refusing broad install root", result.stderr) + self.assertEqual(list(self.home.iterdir()), []) + + @unittest.skipIf( + sys.version_info < (3, 11), + "the supported macOS lifecycle requires Python 3.11 or newer", + ) + def test_macos_install_update_and_rollback_are_transactional(self) -> None: + self.env["SESSION_KIT_TEST_PLATFORM"] = "macos" + self.env["SESSION_KIT_SERVICE_ROOT"] = str(self.temp / "LaunchAgents") + self.run_installer("--enable-login", "--journal", "off") + current = self.home / ".local/lib/session-kit/current" + self.assertEqual(current.resolve().name, RELEASE_A) + self.assertEqual(current.resolve().stat().st_mode & 0o777, 0o555) + templates = self.home / ".config/session-kit/launchd" + self.assertEqual( + {path.name for path in templates.glob("*.plist")}, + { + "com.session-kit.shpool.plist", + "com.session-kit.reaper.plist", + "com.session-kit.watchdog.plist", + }, + ) + self.assertFalse((self.temp / "LaunchAgents").exists()) + shpool_plist = plistlib.loads( + (templates / "com.session-kit.shpool.plist").read_bytes() + ) + expected_bash = shutil.which("bash", path=self.env["PATH"]) + self.assertIsNotNone(expected_bash) + expected_config_bash = str(Path(expected_bash).resolve()) + self.assertEqual( + Path(shpool_plist["EnvironmentVariables"]["SHELL"]).resolve(), + Path(expected_bash).resolve(), + ) + self.assertEqual( + shpool_plist["ProgramArguments"], + [ + str(self.fake_bin / "shpool"), + "--config-file", + str(self.home / ".config/shpool/config.toml"), + "daemon", + ], + ) + zshrc = self.home / ".zshrc" + self.assertIn("$HOME/.local/bin", zshrc.read_text(encoding="utf-8")) + self.assertNotIn("exec", zshrc.read_text(encoding="utf-8")) + bash_profile = self.home / ".bash_profile" + self.assertIn( + ".local/lib/session-kit/current/bashrc/shpool.bashrc", + bash_profile.read_text(encoding="utf-8"), + ) + self.installed("disable-login") + self.assertNotIn( + "session-kit managed integration", + bash_profile.read_text(encoding="utf-8"), + ) + self.installed("enable-login") + self.assertTrue((self.home / ".local/bin/kit").is_file()) + shpool_config = self.home / ".config/shpool/config.toml" + installed_config = shpool_config.read_text(encoding="utf-8") + self.assertIn(f'shell = "{expected_config_bash}"', installed_config) + # Ctrl-q is what docs/usage.md tells people to press to leave a + # conversation running, so a fresh install has to make it true. Stock + # shpool 0.11 detaches on the two-key chord Ctrl-Space Ctrl-q and a + # lone Ctrl-q does nothing at all; shipping the instruction without + # the binding passed for the one machine whose private config already + # had it, and was false for every new setup (found in review, + # 2026-08-15). That is the worst shape of documentation bug: it + # survives the only test anyone will actually run. + self.assertIn("[[keybinding]]", installed_config) + self.assertIn('binding = "Ctrl-q"', installed_config) + self.assertIn('action = "detach"', installed_config) + + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(REPO)) + self.assertEqual(current.resolve().name, RELEASE_B) + self.installed("rollback") + self.assertEqual(current.resolve().name, RELEASE_A) + + def test_rollback_restores_helpers_units_pointer_and_receipt(self) -> None: + self.run_installer("--non-interactive") + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(REPO)) + unit = self.temp / "systemd/shpool.socket" + unit.write_text("foreign unit\n", encoding="utf-8") + helper = self.home / ".local/bin/sp" + helper.write_text("#!/bin/sh\nexit 88\n", encoding="utf-8") + helper.chmod(0o755) + + self.installed("rollback") + current = self.home / ".local/lib/session-kit/current" + self.assertEqual(current.resolve().name, RELEASE_A) + self.assertNotEqual(unit.read_text(encoding="utf-8"), "foreign unit\n") + self.assertEqual( + helper.read_bytes(), + (current / "deploy/session-kit-launcher").read_bytes(), + ) + receipt = json.loads( + (self.home / ".local/state/session-kit/install.json").read_text() + ) + self.assertEqual(receipt["installed_release"], RELEASE_A) + self.assertEqual(receipt["previous_release"], RELEASE_B) + backups = sorted( + (self.home / ".local/state/session-kit/backups").glob( + "lifecycle-*.json" + ), + key=lambda path: path.stat().st_mtime_ns, + ) + payload = json.loads(backups[-1].read_text(encoding="utf-8")) + saved = { + entry["path"]: base64.b64decode(entry["content"]) + for entry in payload["entries"] + if entry["kind"] == "file" + } + self.assertEqual(saved[str(unit)], b"foreign unit\n") + self.assertEqual(saved[str(helper)], b"#!/bin/sh\nexit 88\n") + + def test_uninstall_retains_journals_and_does_not_manage_services(self) -> None: + self.run_installer("--enable-login") + journal = self.home / ".local/state/shpool-journal/example/segment.raw" + journal.parent.mkdir(parents=True) + journal.write_text("private transcript", encoding="utf-8") + result = self.installed("uninstall", "--purge-code", "--purge-config") + self.assertIn("No service was stopped or restarted", result.stdout) + self.assertEqual(journal.read_text(encoding="utf-8"), "private transcript") + self.assertFalse((self.home / ".local/lib/session-kit").exists()) + self.assertFalse((self.home / ".local/bin/sp").exists()) + + def test_the_release_ships_bytecode_caches_that_validate(self) -> None: + """A cache that does not match its source is worse than none. + + `cp -R` renews every mtime, so a cache built while the source tree was + in use records a timestamp the copy does not have. Python then compiled + from source on every import -- 112-148 ms where a valid cache costs 41 -- + and could never repair it: the release is read-only and + `shpool_status` exports PYTHONDONTWRITEBYTECODE=1 for that reason. + """ + self.run_installer() + release = ( + self.home / ".local/lib/session-kit/releases" / RELEASE_A / "lib" + ) + caches = sorted(release.rglob("*.pyc")) + self.assertTrue(caches, msg="no bytecode caches were shipped") + stale = [] + for cache in caches: + source = cache.parents[1] / (cache.name.split(".")[0] + ".py") + self.assertTrue(source.exists(), msg=f"orphan cache: {cache}") + data = cache.read_bytes() + flags = int.from_bytes(data[4:8], "little") + # Hash-based (PEP 552): valid whatever the mtimes are, which is the + # only kind a copied, resealed tree can promise. + if not flags & 0b1: + stale.append(f"{cache.name}: mtime-based") + continue + if data[8:16] != importlib.util.source_hash(source.read_bytes()): + stale.append(f"{cache.name}: hash does not match its source") + self.assertEqual([], stale) + + def test_doctor_reports_the_recording_footprint_without_removing_any( + self, + ) -> None: + """The largest thing the kit writes, and the only feed with no policy. + + Recordings go when their session is closed or pruned, and nothing ages + them out -- 2.6 GB on a two-week-old box, 470 MB of it belonging to + sessions that no longer existed. Doctor states the number and warns past + a bound the operator sets. It deletes nothing: a recording is the history + `sp find` and `sp history` read. + """ + self.run_installer() + journals = self.home / ".local/state/shpool-journal/s20260101-000000-1" + journals.mkdir(parents=True) + old = journals / "segment-000001.raw" + old.write_bytes(b"x" * 4096) + stale = time.time() - 40 * 86400 + os.utime(old, (stale, stale)) + + result = self.installed("doctor", "--json", check=False) + checks = {row["name"]: row for row in json.loads(result.stdout)["checks"]} + self.assertEqual("warn", checks["journals"]["status"]) + self.assertIn( + "SESSION_KIT_JOURNAL_RETENTION_DAYS", checks["journals"]["detail"] + ) + # Nothing was removed by looking. + self.assertTrue(old.exists()) + + # And the bound is the operator's: a wider one is satisfied. + self.env["SESSION_KIT_JOURNAL_RETENTION_DAYS"] = "90" + result = self.installed("doctor", "--json", check=False) + checks = {row["name"]: row for row in json.loads(result.stdout)["checks"]} + self.assertEqual("ok", checks["journals"]["status"]) + self.assertTrue(old.exists()) + + def test_doctor_names_a_disabled_live_channel_on_the_kill_switch_line( + self, + ) -> None: + """A kill switch that doctor cannot see is a false all-clear. + + The picker's three live channels default on; each is turned off by + 0/off/no/false in any case. Doctor's own `kill-switches` row must name a + disabled one instead of reporting that none is active -- otherwise the + line meant to surface a turned-off feature lies about the three newest. + """ + self.run_installer() + + clean = self.installed("doctor", "--json", check=False) + row = {r["name"]: r for r in json.loads(clean.stdout)["checks"]}["kill-switches"] + self.assertEqual("ok", row["status"]) + self.assertIn("no supported kill switch", row["detail"]) + + for var, spelling in ( + ("SESSION_KIT_PICKER_EVENTS", "0"), + ("SESSION_KIT_PICKER_PULSE", " Off "), + ("SESSION_KIT_CLAUDE_SOCKET", "false"), + ): + self.env[var] = spelling + result = self.installed("doctor", "--json", check=False) + row = {r["name"]: r for r in json.loads(result.stdout)["checks"]}["kill-switches"] + self.assertEqual("warn", row["status"]) + for var in ( + "SESSION_KIT_PICKER_EVENTS", + "SESSION_KIT_PICKER_PULSE", + "SESSION_KIT_CLAUDE_SOCKET", + ): + self.assertIn(var, row["detail"]) + for var in ( + "SESSION_KIT_PICKER_EVENTS", + "SESSION_KIT_PICKER_PULSE", + "SESSION_KIT_CLAUDE_SOCKET", + ): + self.env.pop(var) + + # ---- what is running vs what was shipped ------------------------------ + # + # A release rolls by writing new unit files and moving `current`. systemd + # keeps serving the previous unit definitions until it is told, and a + # long-running kit process keeps executing the inode it started with. Ten + # activations passed over one watchdog process on a live box: every fix + # shipped that day ran nowhere, and doctor reported green throughout. + + def fake_systemctl(self, *, reload: str = "no", main_pid: str = "0") -> Path: + """A systemctl that answers the two queries doctor asks and logs calls. + + The real one is never reached from a test: `SESSION_KIT_SYSTEMCTL_CMD` + is the seam, and without it the kit skips this work entirely under the + test flag rather than restarting the operator's own units. + """ + log = self.temp / "systemctl.log" + tool = self.fake_bin / "fake-systemctl" + tool.write_text( + "#!/usr/bin/env bash\n" + f'printf "%s\\n" "$*" >> {log}\n' + "case \"$*\" in\n" + f' *NeedDaemonReload*) printf "NeedDaemonReload={reload}\\nMainPID={main_pid}\\n" ;;\n' + ' *UnitFileState*) printf "UnitFileState=enabled\\nActiveState=active\\n" ;;\n' + "esac\n" + "exit 0\n", + encoding="utf-8", + ) + tool.chmod(0o755) + self.env["SESSION_KIT_SYSTEMCTL_CMD"] = str(tool) + return log + + def start_release_process( + self, release: str, *, evidence: str = "script" + ) -> subprocess.Popen[str]: + """A live process that belongs to `release`, the way the watchdog does. + + Two kinds of evidence, both of which doctor reads: `script` is the + descriptor bash keeps open on the file it exec'd, which resolves through + `current` to the release as it was at exec time; `environ` is the + release directory the launcher exports, for a process whose descriptors + cannot be read. + """ + root = self.home / ".local/lib/session-kit/releases" / release + if evidence == "environ": + environment = dict(self.env) + environment["SESSION_KIT_RELEASE_DIR"] = str(root) + return subprocess.Popen( + ["/usr/bin/env", "bash", "-c", "read -r _line"], + env=environment, + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, + ) + script = root / "bin/fixture_watchdog" + script.parent.mkdir(parents=True, exist_ok=True) + script.write_text( + "#!/usr/bin/env bash\nread -r _line\n", encoding="utf-8" + ) + script.chmod(0o755) + return subprocess.Popen( + ["/usr/bin/env", "bash", str(script)], + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, + ) + + def test_activation_reloads_the_units_and_restarts_the_watchdog(self) -> None: + log = self.fake_systemctl() + self.run_installer() + called = log.read_text(encoding="utf-8").splitlines() + self.assertIn("--user daemon-reload", called) + self.assertIn( + "--user try-restart session-kit-watchdog.service", called + ) + # The session daemon is never restarted by an activation: that would + # end every managed session. + for line in called: + self.assertNotIn("shpool.service", line) + self.assertNotIn("shpool.socket", line) + + def test_doctor_fails_when_the_running_watchdog_is_another_release( + self, + ) -> None: + self.run_installer() + stale = "b" * 40 + process = self.start_release_process(stale) + try: + self.fake_systemctl(main_pid=str(process.pid)) + result = self.installed("doctor", "--json", check=False) + checks = { + row["name"]: row for row in json.loads(result.stdout)["checks"] + } + self.assertEqual("fail", checks["release-running"]["status"]) + self.assertIn("not the installed", checks["release-running"]["detail"]) + self.assertIn( + "systemctl --user restart session-kit-watchdog.service", + checks["release-running"]["detail"], + ) + self.assertNotEqual(0, result.returncode) + finally: + process.stdin.close() + process.wait(timeout=10) + + def test_doctor_passes_when_the_running_watchdog_is_this_release( + self, + ) -> None: + self.run_installer() + process = self.start_release_process(RELEASE_A, evidence="environ") + try: + self.fake_systemctl(main_pid=str(process.pid)) + result = self.installed("doctor", "--json", check=False) + checks = { + row["name"]: row for row in json.loads(result.stdout)["checks"] + } + self.assertEqual("ok", checks["release-running"]["status"]) + self.assertEqual("ok", checks["units-loaded"]["status"]) + finally: + process.stdin.close() + process.wait(timeout=10) + + def test_doctor_fails_when_systemd_has_not_read_the_new_units(self) -> None: + self.run_installer() + self.fake_systemctl(reload="yes") + result = self.installed("doctor", "--json", check=False) + checks = {row["name"]: row for row in json.loads(result.stdout)["checks"]} + self.assertEqual("fail", checks["units-loaded"]["status"]) + self.assertIn( + "systemctl --user daemon-reload", checks["units-loaded"]["detail"] + ) + self.assertNotEqual(0, result.returncode) + + def test_doctor_json_reports_install(self) -> None: + self.run_installer("--enable-login") + result = self.installed("doctor", "--json") + payload = json.loads(result.stdout) + self.assertTrue(payload["ok"]) + names = {row["name"]: row for row in payload["checks"]} + self.assertEqual(names["release"]["detail"], RELEASE_A) + self.assertEqual(names["login"]["status"], "ok") + self.assertEqual(names["codex-themes"]["status"], "ok") + self.assertEqual(names["acceptance"]["status"], "warn") + + def test_every_entry_point_speaks_with_one_prefix_and_one_sentence( + self, + ) -> None: + """One error prefix, and one prerequisite sentence in all three places. + + The launcher used to print `session-kit launcher:` on twelve lines, and + the same macOS prerequisite was three different sentences.""" + launcher = (REPO / "deploy/session-kit-launcher").read_text( + encoding="utf-8" + ) + printed = re.findall(r'echo "([^"]+)" >&2', launcher) + self.assertEqual(12, len(printed), printed) + for message in printed: + self.assertTrue( + message.startswith("session-kit: "), message + ) + sentence = ( + "session-kit: macOS needs Homebrew Bash 4 or newer. " + "Install it with: brew install bash python rust" + ) + for relative in ("install.sh", "bin/session-kit", "deploy/session-kit-launcher"): + self.assertIn( + sentence, + (REPO / relative).read_text(encoding="utf-8"), + relative, + ) + + def test_doctor_and_check_render_one_row_form_with_one_set_of_names( + self, + ) -> None: + """The two reports a person runs when something is wrong agree. + + One renderer, one name per check: `check` used to print + `OK operating system: Linux` where `doctor` printed + `OK platform linux`.""" + row = re.compile(r"^(OK|WARN|FAIL)\s{2,}(\S+)\s+(\S.*)$") + self.run_installer("--non-interactive") + doctor = self.installed("doctor") + checked = self.run_installer("--check") + names = {} + for label, output in (("doctor", doctor.stdout), ("check", checked.stdout)): + parsed = [row.match(line) for line in output.splitlines() if line.strip()] + self.assertTrue(all(parsed), output) + names[label] = {match.group(2) for match in parsed} + for match in parsed: + self.assertEqual( + f"{match.group(1):<5} {match.group(2):<18} {match.group(3)}", + match.group(0), + output, + ) + for shared in ("platform", "prerequisites", "services", "provider", "process"): + self.assertIn(shared, names["doctor"]) + self.assertIn(shared, names["check"]) + self.assertIn("OK platform linux", doctor.stdout) + self.assertIn("OK platform linux", checked.stdout) + + def test_doctor_warns_when_only_systemd_machine_transport_works(self) -> None: + self.run_installer("--non-interactive") + log = self.write_systemctl_probe_fixture() + env = self.env.copy() + env.pop("SESSION_KIT_TESTING") + env.pop("SESSION_KIT_RELEASE_ID") + env.update( + { + "SESSION_KIT_SYSTEMCTL_LOG": str(log), + "SESSION_KIT_DIRECT_RC": "1", + "SESSION_KIT_MACHINE_RC": "0", + } + ) + + result = subprocess.run( + [str(self.home / ".local/bin/session-kit"), "doctor", "--json"], + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + names = {row["name"]: row for row in json.loads(result.stdout)["checks"]} + self.assertEqual(names["services"]["status"], "warn") + self.assertEqual( + names["services"]["detail"], + "systemd user manager is available through local-machine transport; direct user socket is unavailable", + ) + self.assertEqual( + log.read_text(encoding="utf-8").splitlines(), + ["--user show-environment", "--user --machine=@.host show-environment"], + ) + + def test_doctor_audits_naming_and_redacts_kill_switch_values(self) -> None: + self.run_installer("--enable-login") + codex = self.home / ".codex" + claude = self.home / ".claude" + (codex / "AGENTS.md").write_text("run sp self-name once\n", encoding="utf-8") + claude.mkdir(exist_ok=True) + (claude / "CLAUDE.md").write_text("run sp self-name once\n", encoding="utf-8") + acceptance = self.home / ".config/session-kit/release-acceptance.json" + acceptance.write_text( + json.dumps( + { + "schema_version": 1, + "release_id": RELEASE_A, + "platform": "linux" if sys.platform != "darwin" else "macos", + "provider_versions": { + "claude": "2.1.221 (Claude Code)", + "codex": "codex-cli 0.145.0", + }, + "accepted_on": "2026-08-04", + "evidence": { + "unique_colors": "fixture-colors", + "thread_titles": "fixture-titles", + "resume_roundtrip": "fixture-resume", + }, + } + ), + encoding="utf-8", + ) + acceptance.chmod(0o600) + self.env["SESSION_KIT_NO_COLOR"] = "secret-color-value" + + result = self.installed("doctor", "--json", check=False) + + # Nothing here is a `fail`, so the run itself succeeds; the kill-switch + # warning is reported without being turned into an exit code. + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertNotIn("secret-color-value", result.stdout) + names = {row["name"]: row for row in json.loads(result.stdout)["checks"]} + self.assertEqual(names["naming-instructions"]["status"], "ok") + self.assertEqual(names["naming-hook"]["status"], "ok") + self.assertEqual(names["acceptance"]["status"], "ok") + self.assertEqual(names["kill-switches"]["status"], "warn") + self.assertIn("SESSION_KIT_NO_COLOR", names["kill-switches"]["detail"]) + # Who owns the tab name (K3). Nothing is silencing it in this fixture. + self.assertEqual(names["tab-title"]["status"], "ok") + + # The vendor's own off-switch silences the kit's title too (drill, + # 2026-08-13), so doctor has to name it when it is set. + silenced = self.installed( + "doctor", + "--json", + check=False, + env={**self.env, "CLAUDE_CODE_DISABLE_TERMINAL_TITLE": "1"}, + ) + silenced_names = { + row["name"]: row for row in json.loads(silenced.stdout)["checks"] + } + self.assertEqual(silenced_names["tab-title"]["status"], "warn") + self.assertIn( + "CLAUDE_CODE_DISABLE_TERMINAL_TITLE", silenced_names["tab-title"]["detail"] + ) + + stale = json.loads(acceptance.read_text(encoding="utf-8")) + stale["release_id"] = RELEASE_B + acceptance.write_text(json.dumps(stale), encoding="utf-8") + acceptance.chmod(0o600) + stale_result = self.installed("doctor", "--json", check=False) + stale_names = { + row["name"]: row for row in json.loads(stale_result.stdout)["checks"] + } + self.assertEqual(stale_names["acceptance"]["status"], "warn") + + def test_doctor_requires_exact_typed_title_hook_command(self) -> None: + self.run_installer("--non-interactive") + claude = self.home / ".claude" + hooks = claude / "hooks" + hooks.mkdir(parents=True, exist_ok=True) + title_hook = hooks / "nameintent_title.sh" + title_hook.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + title_hook.chmod(0o700) + bad_hook = { + "hooks": [ + { + "type": "command", + "command": "sh ~/.claude/hooks/nameintent_title.sh", + } + ] + } + (claude / "settings.json").write_text( + json.dumps( + { + "hooks": { + "SessionStart": [bad_hook], + "UserPromptSubmit": {"hooks": []}, + "Stop": [bad_hook], + } + } + ), + encoding="utf-8", + ) + + result = self.installed("doctor", "--json", check=False) + + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + names = {row["name"]: row for row in json.loads(result.stdout)["checks"]} + self.assertEqual(names["naming-hook"]["status"], "warn") + self.assertIn("hook content", names["naming-hook"]["detail"]) + self.assertIn("SessionStart", names["naming-hook"]["detail"]) + self.assertIn("UserPromptSubmit", names["naming-hook"]["detail"]) + + def test_doctor_reports_a_wrong_shaped_title_template(self) -> None: + self.run_installer("--non-interactive") + template = self.home / ".codex/session-kit/terminal-title.toml" + template.write_text('tui = "scalar"\n', encoding="utf-8") + + result = self.installed("doctor", "--json", check=False) + + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + names = {row["name"]: row for row in json.loads(result.stdout)["checks"]} + self.assertEqual("warn", names["tab-title"]["status"]) + self.assertIn("tui must be a table", names["tab-title"]["detail"]) + + def test_doctor_reports_the_durable_codex_titler_failure(self) -> None: + self.run_installer("--non-interactive") + record = self.home / ".local/state/session-kit/codex-autotitle-error.json" + record.write_text( + json.dumps( + { + "detail": "Codex thread store unreadable: page two broke", + "at": "2026-08-13T12:00:00Z", + } + ), + encoding="utf-8", + ) - def test_reused_release_is_verified_before_mutation(self) -> None: + result = self.installed("doctor", "--json", check=False) + + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + names = {row["name"]: row for row in json.loads(result.stdout)["checks"]} + self.assertEqual("warn", names["codex-titles"]["status"]) + self.assertIn("page two broke", names["codex-titles"]["detail"]) + + def test_doctor_uses_custom_codex_home_for_themes_and_instructions(self) -> None: + codex_home = self.home / "private-codex" + codex_home.mkdir(mode=0o700) + self.env["CODEX_HOME"] = str(codex_home) self.run_installer("--non-interactive") - current = self.home / ".local/lib/session-kit/current" - target = current / "bin/sp" - target.chmod(0o755) - target.write_text("#!/bin/sh\nexit 99\n", encoding="utf-8") - target.chmod(0o555) - result = self.installed( - "update", "--source", str(REPO), check=False + (codex_home / "AGENTS.md").write_text( + "run sp self-name once\n", encoding="utf-8" ) - self.assertNotEqual(result.returncode, 0) - self.assertIn("release verification failed", result.stderr) - self.assertFalse( - ( - self.home - / ".local/state/session-kit/lifecycle-transaction.json" - ).exists() + claude = self.home / ".claude" + claude.mkdir(exist_ok=True) + (claude / "CLAUDE.md").write_text( + "run sp self-name once\n", encoding="utf-8" ) - def test_purge_requires_matching_ownership_before_login_mutation(self) -> None: - self.run_installer("--enable-login", "--journal", "off") - marker = ( - self.home - / ".local/lib/session-kit/.session-kit-owned.json" + result = self.installed("doctor", "--json") + + names = {row["name"]: row for row in json.loads(result.stdout)["checks"]} + self.assertEqual(names["codex-themes"]["status"], "ok") + self.assertEqual(names["naming-instructions"]["status"], "ok") + self.assertFalse((self.home / ".codex").exists()) + + def test_doctor_warns_for_unsafe_theme_without_failing(self) -> None: + self.run_installer("--non-interactive") + theme = self.home / ".codex/themes/sk-red.tmTheme" + theme.chmod(0o644) + + result = self.installed("doctor", "--json") + + payload = json.loads(result.stdout) + self.assertTrue(payload["ok"]) + names = {row["name"]: row for row in payload["checks"]} + self.assertEqual(names["codex-themes"]["status"], "warn") + self.assertIn("red", names["codex-themes"]["detail"]) + + def test_doctor_bounds_provider_version_output(self) -> None: + self.run_installer("--non-interactive") + codex = self.fake_bin / "codex" + codex.write_text( + "#!/usr/bin/env bash\nprintf '%0300d' 0\n", + encoding="utf-8", ) - marker.write_text('{"owner":"someone-else"}\n', encoding="utf-8") - bashrc = self.home / ".bashrc" - before = bashrc.read_bytes() - result = self.installed("uninstall", "--purge-code", check=False) - self.assertNotEqual(result.returncode, 0) - self.assertEqual(bashrc.read_bytes(), before) - self.assertTrue((self.home / ".local/bin/sp").is_file()) + codex.chmod(0o755) - def test_broad_install_root_is_rejected_without_mutation(self) -> None: - self.env["SESSION_KIT_ROOT"] = str(self.home) - result = self.run_installer("--non-interactive", check=False) - self.assertNotEqual(result.returncode, 0) - self.assertIn("refusing broad install root", result.stderr) - self.assertEqual(list(self.home.iterdir()), []) + result = self.installed("doctor", "--json") - def test_macos_install_update_and_rollback_fail_closed(self) -> None: - self.env["SESSION_KIT_TEST_PLATFORM"] = "macos" - install = self.run_installer("--non-interactive", check=False) - self.assertNotEqual(install.returncode, 0) - self.assertIn("macOS lifecycle operations are not supported", install.stdout) - self.assertEqual(list(self.home.iterdir()), []) + self.assertNotIn("0" * 257, result.stdout) + names = {row["name"]: row for row in json.loads(result.stdout)["checks"]} + self.assertEqual(names["codex-version"]["status"], "warn") + self.assertEqual( + names["codex-version"]["detail"], + "codex --version exceeded 256 bytes", + ) - self.env["SESSION_KIT_TEST_PLATFORM"] = "linux" + def test_doctor_never_echoes_unrecognized_provider_stderr(self) -> None: self.run_installer("--non-interactive") - self.env["SESSION_KIT_TEST_PLATFORM"] = "macos" - update = self.installed("update", "--source", str(REPO), check=False) - rollback = self.installed("rollback", "--to", RELEASE_A, check=False) - self.assertIn("update is supported only on Linux", update.stderr) - self.assertIn("rollback is supported only on Linux", rollback.stderr) + codex = self.fake_bin / "codex" + codex.write_text( + "#!/usr/bin/env bash\nprintf 'provider-secret-value\\n' >&2\n", + encoding="utf-8", + ) + codex.chmod(0o755) - def test_rollback_restores_helpers_units_pointer_and_receipt(self) -> None: + result = self.installed("doctor", "--json") + + self.assertNotIn("provider-secret-value", result.stdout) + names = {row["name"]: row for row in json.loads(result.stdout)["checks"]} + self.assertEqual(names["codex-version"]["status"], "warn") + self.assertEqual( + names["codex-version"]["detail"], + "codex reported an unrecognized version", + ) + + @unittest.skipUnless(sys.platform.startswith("linux"), "uses Linux /proc") + def test_doctor_kills_provider_group_after_leader_exits(self) -> None: + self.run_installer("--non-interactive") + descendant_pid = self.temp / "provider-descendant.pid" + codex = self.fake_bin / "codex" + codex.write_text( + "#!/usr/bin/env bash\n" + "( trap '' TERM; while :; do sleep 1; done ) &\n" + f"printf '%s\\n' \"$!\" > {descendant_pid!s}\n" + "exit 0\n", + encoding="utf-8", + ) + codex.chmod(0o755) + + self.installed("doctor", "--json") + + pid = int(descendant_pid.read_text(encoding="utf-8")) + deadline = time.monotonic() + 1.0 + while time.monotonic() < deadline: + status = Path(f"/proc/{pid}/stat") + if not status.exists() or status.read_text().split()[2] == "Z": + break + time.sleep(0.02) + else: + self.fail(f"provider descendant {pid} survived doctor cleanup") + + def test_install_rejects_relative_or_symlinked_custom_codex_home(self) -> None: + self.env["CODEX_HOME"] = "relative-codex-home" + relative = self.run_installer("--non-interactive", check=False) + self.assertNotEqual(relative.returncode, 0) + # Preflight reports it on stdout; the transaction would repeat it on + # stderr. Either layer refusing before any write is the contract. + self.assertIn("absolute normalized path", relative.stdout + relative.stderr) + self.assertFalse((self.home / ".local/lib/session-kit").exists()) + + target = self.temp / "codex-target" + target.mkdir(mode=0o700) + link = self.temp / "codex-link" + link.symlink_to(target, target_is_directory=True) + self.env["CODEX_HOME"] = str(link) + linked = self.run_installer("--non-interactive", check=False) + self.assertNotEqual(linked.returncode, 0) + self.assertIn("unsafe Codex path ancestor", linked.stdout + linked.stderr) + self.assertFalse((target / "themes").exists()) + + def test_install_and_doctor_reject_unsafe_codex_ancestors(self) -> None: + unsafe = self.home / "unsafe" + unsafe.mkdir(mode=0o700) + codex_home = unsafe / "codex" + codex_home.mkdir(mode=0o700) + unsafe.chmod(0o777) + self.env["CODEX_HOME"] = str(codex_home) + + rejected = self.run_installer("--non-interactive", check=False) + + self.assertNotEqual(rejected.returncode, 0) + self.assertIn("unsafe Codex path ancestor", rejected.stdout + rejected.stderr) + self.assertFalse((codex_home / "themes").exists()) + + self.env.pop("CODEX_HOME") + unsafe.chmod(0o700) + self.run_installer("--non-interactive") + installed_root = self.home / ".codex" + installed_root.chmod(0o777) + result = self.installed("doctor", "--json") + names = {row["name"]: row for row in json.loads(result.stdout)["checks"]} + self.assertEqual(names["codex-themes"]["status"], "warn") + self.assertEqual(names["naming-instructions"]["status"], "warn") + + def test_empty_codex_home_uses_runtime_default(self) -> None: + self.env["CODEX_HOME"] = "" + + self.run_installer("--non-interactive") + + self.assertTrue((self.home / ".codex/themes/sk-red.tmTheme").is_file()) + + def test_theme_recovery_uses_recorded_home_when_environment_changes(self) -> None: + codex_a = self.home / "codex-a" + codex_a.mkdir(mode=0o700) + self.env["CODEX_HOME"] = str(codex_a) self.run_installer("--non-interactive") + red = codex_a / "themes/sk-red.tmTheme" + red.write_text("recorded-a\n", encoding="utf-8") + red.chmod(0o600) + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + + for changed_home in ("", str(self.home / "unsafe-b")): + self.env["CODEX_HOME"] = str(codex_a) + self.env["SESSION_KIT_TEST_FAILPOINT"] = "themes" + self.env["SESSION_KIT_TEST_FAILPOINT_MODE"] = "kill" + interrupted = self.installed( + "update", "--source", str(REPO), check=False + ) + self.assertEqual(-signal.SIGKILL, interrupted.returncode) + self.env.pop("SESSION_KIT_TEST_FAILPOINT") + self.env.pop("SESSION_KIT_TEST_FAILPOINT_MODE") + if changed_home: + unsafe_b = Path(changed_home) + unsafe_b.mkdir(mode=0o700) + unsafe_b.chmod(0o777) + self.env["CODEX_HOME"] = changed_home + + recovered = self.installed( + "update", "--source", str(self.temp / "missing-source"), check=False + ) + + self.assertNotEqual(recovered.returncode, 0) + self.assertIn("Recovered interrupted Session Kit", recovered.stdout) + self.assertEqual(red.read_text(encoding="utf-8"), "recorded-a\n") + if changed_home: + Path(changed_home).chmod(0o700) + + def test_no_theme_release_removes_only_captured_kit_themes(self) -> None: + source = self.temp / "pre-theme-source" + shutil.copytree( + REPO, + source, + ignore=source_tree_ignore(), + ) + shutil.rmtree(source / "config/codex-themes") + subprocess.run(["git", "init", "-q", source], check=True) + subprocess.run(["git", "-C", source, "add", "."], check=True) + subprocess.run( + [ + "git", "-C", source, "-c", "user.name=Fixture", + "-c", "user.email=fixture@example.invalid", "commit", "-qm", "fixture", + ], + check=True, + ) + self.run_installer("--source", str(source), "--non-interactive") self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B self.installed("update", "--source", str(REPO)) - unit = self.temp / "systemd/shpool.socket" - unit.write_text("foreign unit\n", encoding="utf-8") - helper = self.home / ".local/bin/sp" - helper.write_text("#!/bin/sh\nexit 88\n", encoding="utf-8") - helper.chmod(0o755) + themes = self.home / ".codex/themes" + unrelated = themes / "personal.tmTheme" + unrelated.write_text("personal\n", encoding="utf-8") - self.installed("rollback") - current = self.home / ".local/lib/session-kit/current" - self.assertEqual(current.resolve().name, RELEASE_A) - self.assertNotEqual(unit.read_text(encoding="utf-8"), "foreign unit\n") - self.assertEqual( - helper.read_bytes(), - (current / "deploy/session-kit-launcher").read_bytes(), + self.installed("rollback", "--to", RELEASE_A) + + self.assertTrue(unrelated.is_file()) + for color in THEME_COLORS: + self.assertFalse((themes / f"sk-{color}.tmTheme").exists()) + + def test_mid_copy_failure_cleans_theme_temporary(self) -> None: + self.run_installer("--non-interactive") + themes = self.home / ".codex/themes" + red_before = (themes / "sk-red.tmTheme").read_bytes() + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.env["SESSION_KIT_TEST_FAILPOINT"] = "theme-copy" + + interrupted = self.installed( + "update", "--source", str(REPO), check=False ) - receipt = json.loads( - (self.home / ".local/state/session-kit/install.json").read_text() + + self.assertNotEqual(interrupted.returncode, 0) + self.assertEqual(list(themes.glob(".sk-*.tmTheme.*")), []) + self.assertEqual((themes / "sk-red.tmTheme").read_bytes(), red_before) + + def test_theme_targets_recover_after_interrupted_update(self) -> None: + self.run_installer("--non-interactive") + themes = self.home / ".codex/themes" + red = themes / "sk-red.tmTheme" + red.write_text("pre-update-theme\n", encoding="utf-8") + red.chmod(0o600) + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.env["SESSION_KIT_TEST_FAILPOINT"] = "themes" + self.env["SESSION_KIT_TEST_FAILPOINT_MODE"] = "kill" + + interrupted = self.installed( + "update", "--source", str(REPO), check=False ) - self.assertEqual(receipt["installed_release"], RELEASE_A) - self.assertEqual(receipt["previous_release"], RELEASE_B) - backups = sorted( - (self.home / ".local/state/session-kit/backups").glob( - "lifecycle-*.json" - ), - key=lambda path: path.stat().st_mtime_ns, + + self.assertEqual(-signal.SIGKILL, interrupted.returncode) + transaction = json.loads( + (self.home / ".local/state/session-kit/lifecycle-transaction.json").read_text( + encoding="utf-8" + ) ) - payload = json.loads(backups[-1].read_text(encoding="utf-8")) - saved = { - entry["path"]: base64.b64decode(entry["content"]) - for entry in payload["entries"] - if entry["kind"] == "file" + captured = {entry["path"] for entry in transaction["entries"]} + expected = { + str(themes / f"sk-{color}.tmTheme") for color in THEME_COLORS } - self.assertEqual(saved[str(unit)], b"foreign unit\n") - self.assertEqual(saved[str(helper)], b"#!/bin/sh\nexit 88\n") + self.assertTrue(expected.issubset(captured)) + self.assertNotEqual(red.read_text(encoding="utf-8"), "pre-update-theme\n") - def test_uninstall_retains_journals_and_does_not_manage_services(self) -> None: - self.run_installer("--enable-login") - journal = self.home / ".local/state/shpool-journal/example/segment.raw" - journal.parent.mkdir(parents=True) - journal.write_text("private transcript", encoding="utf-8") - result = self.installed("uninstall", "--purge-code", "--purge-config") - self.assertIn("No service was stopped or restarted", result.stdout) - self.assertEqual(journal.read_text(encoding="utf-8"), "private transcript") - self.assertFalse((self.home / ".local/lib/session-kit").exists()) - self.assertFalse((self.home / ".local/bin/sp").exists()) + self.env.pop("SESSION_KIT_TEST_FAILPOINT") + self.env.pop("SESSION_KIT_TEST_FAILPOINT_MODE") + recovered = self.installed( + "update", "--source", str(self.temp / "missing-source"), check=False + ) - def test_doctor_json_reports_install(self) -> None: - self.run_installer("--enable-login") - result = self.installed("doctor", "--json") - payload = json.loads(result.stdout) - self.assertTrue(payload["ok"]) - names = {row["name"]: row for row in payload["checks"]} - self.assertEqual(names["release"]["detail"], RELEASE_A) - self.assertEqual(names["login"]["status"], "ok") + self.assertNotEqual(recovered.returncode, 0) + self.assertIn("Recovered interrupted Session Kit", recovered.stdout) + self.assertEqual(red.read_text(encoding="utf-8"), "pre-update-theme\n") + self.assertFalse( + (self.home / ".local/state/session-kit/lifecycle-transaction.json").exists() + ) def test_doctor_rejects_partial_inventory_package(self) -> None: self.run_installer("--non-interactive") @@ -382,6 +3344,390 @@ def test_doctor_rejects_partial_inventory_package(self) -> None: self.assertEqual(names["release"]["status"], "fail") self.assertIn("lib/sessionkit_inventory/common.py", names["release"]["detail"]) + def unit_adding_source_fixture(self) -> Path: + """A copy of this checkout that ships one more systemd unit than it + does, named in its own `systemd_units` list. + + That is the only shape in which the rollback defect appears: the list + an activation reads belongs to the RUNNING kit, so a release that adds + a unit hands the next rollback a name the older release never carried. + A fixture that adds the file without adding the name, or the name + without the file, reproduces nothing. + """ + source = self.clean_source_fixture() + (source / "systemd" / FIXTURE_UNIT).write_text( + "[Unit]\n" + "Description=Session Kit fixture unit\n" + "\n" + "[Service]\n" + "Type=oneshot\n" + "ExecStart=/bin/true\n", + encoding="utf-8", + ) + entry = source / "bin/session-kit" + text = entry.read_text(encoding="utf-8") + listed = " session-kit-watchdog.service\n)" + self.assertIn(listed, text, "bin/session-kit no longer declares systemd_units") + entry.write_text( + text.replace( + listed, f" session-kit-watchdog.service\n {FIXTURE_UNIT}\n)", 1 + ), + encoding="utf-8", + ) + subprocess.run(["git", "-C", source, "add", "."], check=True) + subprocess.run( + [ + "git", "-C", source, + "-c", "user.name=Session Kit Tests", + "-c", "user.email=session-kit-tests@invalid.example", + "commit", "-q", "--amend", "--no-edit", + ], + check=True, + ) + return source + + def test_rollback_to_a_release_that_predates_a_unit_completes(self) -> None: + """Adding a systemd unit must not remove the undo for adding it. + + The rollback runs the release being left, so its unit list names a file + the target release does not have. Copying that file used to fail and + take the whole transaction with it, which left the installation on the + release the operator was trying to leave -- with no way back to any + release older than the one that added the unit. + """ + self.run_installer("--non-interactive") + service_root = self.temp / "systemd" + carried = { + path.name: path.read_bytes() for path in sorted(service_root.iterdir()) + } + self.assertIn("shpool.service", carried) + self.assertNotIn(FIXTURE_UNIT, carried) + + self.env["SESSION_KIT_RELEASE_ID"] = RELEASE_B + self.installed("update", "--source", str(self.unit_adding_source_fixture())) + stale = service_root / FIXTURE_UNIT + self.assertTrue(stale.is_file(), "the fixture release installed no new unit") + + rolled_back = self.installed("rollback") + + current = self.home / ".local/lib/session-kit/current" + self.assertEqual(RELEASE_A, current.resolve().name) + self.assertIn( + f"{FIXTURE_UNIT} is not part of this release", rolled_back.stderr + ) + # The stale unit's program is the release that is now current, which + # does not take the arguments the unit passes it, so leaving the file + # would leave systemd running a failing job on a timer. + self.assertFalse(stale.exists() or stale.is_symlink()) + # Everything the target release does carry survives, byte for byte. + self.assertEqual( + sorted(carried), sorted(path.name for path in service_root.iterdir()) + ) + for name, content in carried.items(): + self.assertEqual(content, (service_root / name).read_bytes(), name) + receipt = json.loads( + (self.home / ".local/state/session-kit/install.json").read_text() + ) + self.assertEqual(RELEASE_A, receipt["installed_release"]) + + def test_uninstall_removes_the_unit_files_it_installed(self) -> None: + """An uninstall that leaves unit files behind leaves systemd pointing + at code the same command just deleted.""" + self.run_installer("--non-interactive") + service_root = self.temp / "systemd" + self.assertTrue((service_root / "shpool.socket").is_file()) + + self.installed("uninstall", "--purge-code", "--purge-config") + + self.assertEqual([], sorted(path.name for path in service_root.iterdir())) + + def write_systemctl_state_fixture(self) -> Path: + """A systemctl that answers `is-enabled` and `is-active` from the + environment, so a test can name the state systemd is in.""" + systemctl = self.fake_bin / "systemctl-state" + systemctl.write_text( + "#!/usr/bin/env bash\n" + "case ${2:-} in\n" + " is-enabled) printf '%s\\n' \"${FIXTURE_ENABLED:-disabled}\" ;;\n" + " is-active) printf '%s\\n' \"${FIXTURE_ACTIVE:-inactive}\" ;;\n" + "esac\n" + "exit 0\n", + encoding="utf-8", + ) + systemctl.chmod(0o755) + return systemctl + + def test_uninstall_refuses_while_systemd_holds_an_enablement(self) -> None: + """Removing the unit file of a unit systemd has enabled leaves the + manager holding a job whose file is gone. The documented verb comes + first, and nothing is removed until it has. + + `is-enabled` has four answers that mean systemd is holding this file, + not one, and a check that reads only `enabled` deletes the file under + the other three. + """ + self.run_installer("--non-interactive") + env = self.env.copy() + env["SESSION_KIT_SYSTEMCTL_CMD"] = str(self.write_systemctl_state_fixture()) + + for state in ("enabled", "enabled-runtime", "linked", "linked-runtime"): + with self.subTest(is_enabled=state): + env["FIXTURE_ENABLED"] = state + refused = self.installed( + "uninstall", + "--purge-code", + "--purge-config", + check=False, + env=env, + ) + self.assertNotEqual(0, refused.returncode) + self.assertIn("session-kit services disable", refused.stderr) + self.assertTrue((self.temp / "systemd/shpool.socket").is_file()) + self.assertTrue((self.home / ".local/bin/sp").exists()) + self.assertTrue( + (self.home / ".local/lib/session-kit/current").is_symlink() + ) + + def test_uninstall_proceeds_while_a_unit_runs_without_an_enablement(self) -> None: + """A running daemon is holding live sessions. Uninstall says so and + removes its files; it does not make the way out of an installation go + through killing every session in it.""" + self.run_installer("--non-interactive") + env = self.env.copy() + env["SESSION_KIT_SYSTEMCTL_CMD"] = str(self.write_systemctl_state_fixture()) + env["FIXTURE_ENABLED"] = "disabled" + env["FIXTURE_ACTIVE"] = "active" + + removed = self.installed( + "uninstall", "--purge-code", "--purge-config", check=False, env=env + ) + + self.assertEqual(0, removed.returncode, removed.stderr) + self.assertIn("is still running and is left running", removed.stderr) + self.assertEqual([], sorted(path.name for path in (self.temp / "systemd").iterdir())) + + def test_uninstall_refuses_a_symlinked_unit_path_and_spares_its_target( + self, + ) -> None: + """A unit path that is a symlink is not this installation's file to + delete, and the removal must not reach through it.""" + self.run_installer("--non-interactive") + decoy = self.temp / "decoy-outside-the-service-root" + decoy.write_text("operator file\n", encoding="utf-8") + planted = self.temp / "systemd/shpool.socket" + planted.unlink() + planted.symlink_to(decoy) + + refused = self.installed( + "uninstall", "--purge-code", "--purge-config", check=False + ) + + self.assertNotEqual(0, refused.returncode) + self.assertIn("refusing", refused.stderr) + self.assertEqual("operator file\n", decoy.read_text(encoding="utf-8")) + self.assertTrue(planted.is_symlink()) + self.assertTrue((self.home / ".local/bin/sp").exists()) + + +class SystemdUnitLoopTests(unittest.TestCase): + """Drive `install_systemd_units` directly, with a service root of the + test's own making. + + The end-to-end proof above shows the loop reached from a real rollback. + These pin what the loop does with the cases a release cannot be built to + contain -- a symlinked unit path, a unit name carrying a path -- and the + half that deletes, which is the half worth proving twice. + """ + + STALE = "session-kit-fixture-stale.service" + TEMPLATE = "[Unit]\nDescription=fixture\n\n[Service]\nExecStart=@SHPOOL@ daemon\n" + + def setUp(self) -> None: + self.temp = Path( + tempfile.mkdtemp(prefix="session-kit-units-test.", dir=Path("/tmp")) + ).resolve() + self.home = self.temp / "home" + self.install_root = self.home / ".local/lib/session-kit" + self.service_root = self.temp / "systemd" + self.fake_bin = self.temp / "bin" + self.service_root.mkdir() + self.fake_bin.mkdir() + (self.install_root / "releases").mkdir(parents=True) + shpool = self.fake_bin / "shpool" + shpool.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") + shpool.chmod(0o755) + self.harness = self.temp / "harness.sh" + self.harness.write_text( + "#!/usr/bin/env bash\n" + # The dispatcher runs under these options; the abort this branch + # fixes only happens under them. + "set -euo pipefail\n" + "install_root=$1\n" + "service_root=$2\n" + "release_id=$3\n" + "shift 3\n" + "systemd_units=(\"$@\")\n" + "die() { printf 'session-kit: %s\\n' \"$*\" >&2; exit 1; }\n" + "platform() { printf 'linux\\n'; }\n" + 'source "$SESSION_KIT_MODULE"\n' + 'install_systemd_units "$release_id"\n', + encoding="utf-8", + ) + self.harness.chmod(0o755) + self.env = os.environ.copy() + self.env.update( + { + "HOME": str(self.home), + "CLAUDE_CONFIG_DIR": str(self.home / ".claude"), + "PATH": f"{self.fake_bin}:{self.env['PATH']}", + "XDG_CONFIG_HOME": str(self.home / ".config"), + "XDG_DATA_HOME": str(self.home / ".local/share"), + "XDG_STATE_HOME": str(self.home / ".local/state"), + "SESSION_KIT_ROOT": str(self.install_root), + "SESSION_KIT_SYSTEMD_ROOT": str(self.service_root), + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_MODULE": str(REPO / "lib/sh/session_kit_install.sh"), + } + ) + + def tearDown(self) -> None: + shutil.rmtree(self.temp) + + def stage_release(self, release_id: str, units: dict[str, str]) -> None: + root = self.install_root / "releases" / release_id / "systemd" + root.mkdir(parents=True) + for name, content in units.items(): + (root / name).write_text(content, encoding="utf-8") + + def install_units( + self, release_id: str, units: tuple[str, ...] + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + str(self.harness), + str(self.install_root), + str(self.service_root), + release_id, + *units, + ], + env=self.env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + def test_a_unit_the_release_does_not_carry_is_removed_and_named(self) -> None: + self.stage_release(RELEASE_A, {"shpool.service": self.TEMPLATE}) + stale = self.service_root / self.STALE + stale.write_text("stale unit\n", encoding="utf-8") + + result = self.install_units(RELEASE_A, ("shpool.service", self.STALE)) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertFalse(stale.exists() or stale.is_symlink()) + self.assertIn(f"{self.STALE} is not part of this release", result.stderr) + self.assertIn(f"systemctl --user disable {self.STALE}", result.stderr) + installed = (self.service_root / "shpool.service").read_text(encoding="utf-8") + self.assertIn(f"ExecStart={self.fake_bin}/shpool daemon", installed) + + def test_a_unit_the_release_carries_is_kept_and_rewritten(self) -> None: + """The pruning half must not reach a unit the target release has.""" + self.stage_release( + RELEASE_A, + {"shpool.service": self.TEMPLATE, "shpool.socket": "[Socket]\n"}, + ) + kept = self.service_root / "shpool.socket" + kept.write_text("foreign content\n", encoding="utf-8") + + result = self.install_units(RELEASE_A, ("shpool.service", "shpool.socket")) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertTrue(kept.is_file() and not kept.is_symlink()) + self.assertEqual("[Socket]\n", kept.read_text(encoding="utf-8")) + self.assertNotIn("shpool.socket is not part of", result.stderr) + + def test_a_symlinked_stale_unit_is_neither_followed_nor_removed(self) -> None: + """`rm` on a symlink would take the link, not its target -- but the + link is not this installation's file either, and the guard that keeps + the deletion off it is worth a test of its own.""" + self.stage_release(RELEASE_A, {"shpool.service": self.TEMPLATE}) + decoy = self.temp / "decoy-outside-the-service-root" + decoy.write_text("operator file\n", encoding="utf-8") + planted = self.service_root / self.STALE + planted.symlink_to(decoy) + + result = self.install_units(RELEASE_A, ("shpool.service", self.STALE)) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertTrue(decoy.is_file()) + self.assertEqual("operator file\n", decoy.read_text(encoding="utf-8")) + self.assertTrue(planted.is_symlink()) + self.assertIn(f"{self.STALE} is not part of this release", result.stderr) + self.assertNotIn("was removed", result.stderr) + + def test_a_symlinked_live_unit_is_replaced_without_writing_through(self) -> None: + """The copying half installs over the link, never through it.""" + self.stage_release( + RELEASE_A, + {"shpool.service": self.TEMPLATE, "shpool.socket": "[Socket]\n"}, + ) + decoy = self.temp / "decoy-outside-the-service-root" + decoy.write_text("operator file\n", encoding="utf-8") + planted = self.service_root / "shpool.socket" + planted.symlink_to(decoy) + + result = self.install_units(RELEASE_A, ("shpool.service", "shpool.socket")) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("operator file\n", decoy.read_text(encoding="utf-8")) + self.assertFalse(planted.is_symlink()) + self.assertEqual("[Socket]\n", planted.read_text(encoding="utf-8")) + + def test_a_unit_name_that_leaves_the_service_root_is_refused(self) -> None: + """Both halves join the name onto a directory. A name carrying a path + would copy a file in from outside the release and delete one from + outside the service root, so the name is refused before either.""" + escaping = "../escape.service" + release = self.install_root / "releases" / RELEASE_A + self.stage_release(RELEASE_A, {"shpool.service": self.TEMPLATE}) + (release / "escape.service").write_text("payload\n", encoding="utf-8") + victim = self.temp / "escape.service" + victim.write_text("operator file\n", encoding="utf-8") + self.assertEqual(victim, (self.service_root / escaping).resolve()) + + result = self.install_units(RELEASE_A, ("shpool.service", escaping)) + + self.assertEqual("operator file\n", victim.read_text(encoding="utf-8")) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn(f"refusing unsafe unit name: {escaping}", result.stderr) + + def test_every_malformed_unit_name_is_refused(self) -> None: + self.stage_release(RELEASE_A, {"shpool.service": self.TEMPLATE}) + for name in ("../escape.service", "..", ".", "sub/dir.service", ""): + with self.subTest(name=name): + result = self.install_units(RELEASE_A, ("shpool.service", name)) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("refusing unsafe unit name", result.stderr) + + def test_a_release_without_the_templated_unit_does_not_abort(self) -> None: + """The templating step reads shpool.service straight after the loop. + A release that does not carry it leaves nothing to fill in, and + reading it anyway would restore the abort the loop just removed.""" + self.stage_release(RELEASE_A, {"shpool.socket": "[Socket]\n"}) + (self.service_root / "shpool.service").write_text( + "stale service\n", encoding="utf-8" + ) + + result = self.install_units(RELEASE_A, ("shpool.service", "shpool.socket")) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertFalse((self.service_root / "shpool.service").exists()) + self.assertEqual( + "[Socket]\n", + (self.service_root / "shpool.socket").read_text(encoding="utf-8"), + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_inventory.py b/tests/test_inventory.py index dc53191..b7edd0a 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -1,21 +1,31 @@ from __future__ import annotations import argparse +import ast import copy import contextlib +import errno import fcntl import hashlib import importlib.util +import inspect import io import json import os from pathlib import Path +import pty import re +import select import sqlite3 +import stat +import struct import subprocess import sys import tempfile +import termios +import time import threading +from typing import Any import unittest from unittest import mock import unicodedata @@ -30,6 +40,52 @@ sys.modules[CORE_SPEC.name] = inventory_core CORE_SPEC.loader.exec_module(inventory_core) from sessionkit_inventory import lifecycle as lifecycle_state # noqa: E402 +from sessionkit_inventory.model import ( # noqa: E402 + classify_top_level_sessions, + session_is_unavailable, +) +from sessionkit_inventory import origins as origins_state # noqa: E402 +from sessionkit_inventory import collector as collector_inventory # noqa: E402 +from sessionkit_inventory import processes as process_inventory # noqa: E402 +from sessionkit_inventory import providers_claude as claude_inventory # noqa: E402 +from sessionkit_inventory import recovery as recovery_state # noqa: E402 +from sessionkit_inventory import snapshot as snapshot_state # noqa: E402 +from sessionkit_inventory import state_io as state_io_state # noqa: E402 + + +# The renderer reads two things from the ambient process: whether stdout is a +# terminal (colour, `render._color_enabled`) and how wide that terminal is +# (`shutil.get_terminal_size`). Inheriting them made this module's answer +# depend on how the suite was launched -- green through a pipe, red on a +# terminal, with the width deciding which detail form a row shows and the +# terminal deciding whether escape codes sit between a row's marker and its +# text. Pin both here, once, so every test renders against the documented +# no-terminal fallbacks instead of the developer's window. A test that needs a +# specific width or colour still sets it locally, and that still wins. +_AMBIENT_PINS: list = [] + + +def setUpModule() -> None: + environment = mock.patch.dict(os.environ, {"SESSION_KIT_NO_COLOR": "1"}) + environment.start() + _AMBIENT_PINS.append(environment) + # Restored with the rest of the dict when the patch stops. Without this an + # exported COLUMNS would answer shutil before the refusal below is reached. + os.environ.pop("COLUMNS", None) + # Refusing the window query is exactly the answer a pipe gives, so each + # caller falls back to the width it declares in its own signature. + no_terminal = mock.patch.object( + os, + "get_terminal_size", + side_effect=OSError(errno.ENOTTY, "no terminal (pinned by the suite)"), + ) + no_terminal.start() + _AMBIENT_PINS.append(no_terminal) + + +def tearDownModule() -> None: + while _AMBIENT_PINS: + _AMBIENT_PINS.pop().stop() def uuid_for(number: int) -> str: @@ -57,16 +113,1229 @@ def process( } +class SnapshotPublicationOrderTests(unittest.TestCase): + BOOT = "11111111-2222-3333-4444-555555555555" + GENERATION = {"pid": 10, "process_start_ticks": 100} + + def row(self, session_id: str, number: int, color: str) -> dict[str, object]: + uuid = uuid_for(number) + return { + "shpool_id_raw": session_id, + "provider": "codex", + "identity": { + "confidence": "exact", + "provider": "codex", + "uuid": uuid, + }, + "display_color": color, + "recovery": { + "available": True, + "provider": "codex", + "uuid": uuid, + "cwd": "/tmp", + "argv": [], + "command": "codex", + }, + } + + def inventory(self, *rows: dict[str, object]) -> dict[str, object]: + return { + "schema_version": inventory_core.SCHEMA_VERSION, + "generated_at": "2026-08-15T17:00:00Z", + "source": "live", + "stale": False, + "warnings": [], + "daemon_generation": dict(self.GENERATION), + "sessions": list(rows), + "outside_agents": [], + } + + def assert_exact_marker(self, state: Path, document: Path) -> int: + """Assert the on-disk artifact, without requiring a new-runtime API.""" + marker_path = state / "collection-markers" / f"{document.name}.json" + self.assertTrue( + marker_path.exists(), + f"publication did not create a freshness marker for {document.name}", + ) + marker = json.loads(marker_path.read_text(encoding="utf-8")) + self.assertEqual( + hashlib.sha256(document.read_bytes()).hexdigest(), + marker.get("content_sha256"), + ) + sequence = marker.get("collection_start") + self.assertIsInstance(sequence, int) + self.assertGreater(sequence, 0) + return sequence + + def ordered_write( + self, + paths: dict[str, Path], + path: Path, + payload: object, + *, + collection_start: int | None, + ) -> None: + """Exercise each runtime's actual publication behavior. + + Before ordered publication existed, the production primitive was the + same atomic JSON replacement used here. This fallback lets the test + reach that old behavior and fail on the overwritten document instead + of erroring merely because the new helper is absent. + """ + writer = getattr(state_io_state, "write_collection_json", None) + if writer is None: + state_io_state.atomic_write_json(path, payload) + return + writer(paths, path, payload, collection_start=collection_start) + + def allocate_start(self, paths: dict[str, Path]) -> tuple[int, str | None]: + """Reach the old plain-counter behavior when the allocator is absent.""" + allocator = getattr(state_io_state, "allocate_collection_start", None) + if allocator is not None: + return allocator(paths) + counter = paths.get( + "collection_sequence", paths["root"] / "collection-sequence.json" + ) + try: + prior_document = json.loads(counter.read_text(encoding="utf-8")) + except (OSError, ValueError): + prior_document = {} + prior = prior_document.get("last_collection_start") + if isinstance(prior, bool) or not isinstance(prior, int) or prior <= 0: + prior = 0 + sequence = prior + 1 + state_io_state.atomic_write_json( + counter, + {"schema_version": 1, "last_collection_start": sequence}, + ) + return sequence, None + + def snapshot_kwargs( + self, + state: Path, + collect_live, + ) -> dict[str, object]: + paths = state_io_state._state_paths({"state_dir": state}) + + def manifest(value): + return recovery_state.recovery_manifest( + value, + schema_version=inventory_core.SCHEMA_VERSION, + boot_id=lambda: self.BOOT, + ) + + def update_recovery(current_paths, value, *, collection_start=None): + extra = {} + if ( + "collection_start" + in inspect.signature(recovery_state.update_recovery_state).parameters + ): + extra.update( + collection_start=collection_start, + write_collection_json=( + state_io_state.write_collection_json + if collection_start is not None + else None + ), + ) + recovery_state.update_recovery_state( + current_paths, + value, + schema_version=inventory_core.SCHEMA_VERSION, + recovery_manifest=manifest, + read_state_json=inventory_core._read_state_json, + has_recovery_entries=lambda document: ( + recovery_state._has_recovery_entries( + document, + valid_recovery_state=lambda candidate: ( + recovery_state._valid_recovery_state( + candidate, schema_version=inventory_core.SCHEMA_VERSION + ) + ), + ) + ), + generation_key=recovery_state._generation_key, + atomic_write_json=state_io_state.atomic_write_json, + **extra, + ) + + def enqueue( + current_paths, + current, + previous, + *, + boot_id, + collection_start=None, + ): + extra = {} + if ( + "collection_start" + in inspect.signature( + inventory_core.enqueue_lost_conversations + ).parameters + ): + extra["collection_start"] = collection_start + return inventory_core.enqueue_lost_conversations( + current_paths, + current, + previous, + boot_id=boot_id, + config={"state_dir": state}, + **extra, + ) + + kwargs: dict[str, object] = { + "write_state": True, + "config": {"state_dir": state}, + "schema_version": inventory_core.SCHEMA_VERSION, + "load_config": lambda: {"state_dir": state}, + "state_paths": lambda _settings: paths, + "state_lock": inventory_core.StateLock, + "read_state_json": inventory_core._read_state_json, + "collect_live": collect_live, + "boot_id_factory": lambda: self.BOOT, + "persist_last_exact": lambda *_args, **_kwargs: None, + "apply_provider_exit_states": lambda *_args, **_kwargs: None, + "prune_inactive_state": lambda *_args, **_kwargs: None, + "read_terminal_registry": lambda *_args, **_kwargs: {}, + "read_terminal_retirements": lambda *_args, **_kwargs: {}, + "apply_terminal_numbers": lambda _value, *_args, **_kwargs: {}, + "terminal_retirement_payload": lambda *_args, **_kwargs: {}, + "atomic_write_json": state_io_state.atomic_write_json, + "quarantine_orphaned_provider_untitled_markers": ( + lambda *_args, **_kwargs: [] + ), + "update_recovery_state": update_recovery, + "enqueue_lost_conversations": enqueue, + "apply_session_origins": lambda *_args, **_kwargs: None, + "capture_bounce_receipts": lambda _state: frozenset(), + "capture_bounce_cleanup_generations": lambda _state: frozenset(), + "capture_lifecycle_generations": lambda _state: frozenset(), + "capture_origin_generations": lambda _state: frozenset(), + "capture_provider_untitled_generations": lambda _state: frozenset(), + "capture_session_color_generations": ( + inventory_core.capture_session_color_generations + ), + "prune_origins": lambda *_args, **_kwargs: 0, + "publish_session_colors": inventory_core.publish_session_colors, + "cold_inventory": lambda error: {"error": error}, + } + parameters = inspect.signature(snapshot_state.snapshot).parameters + if "allocate_collection_start" in parameters: + kwargs.update( + allocate_collection_start=state_io_state.allocate_collection_start, + preflight_collection_documents=( + state_io_state.preflight_collection_documents + ), + write_collection_json=state_io_state.write_collection_json, + ) + return kwargs + + def test_an_older_collection_cannot_replace_any_newer_published_state( + self, + ) -> None: + with tempfile.TemporaryDirectory(prefix=".publication-order-", dir=REPO) as raw: + state = Path(raw) + state.chmod(0o700) + old = self.row("old", 1, "blue") + new = self.row("new", 2, "red") + human_new = { + "shpool_id_raw": "human-new", + "display_color": "green", + "origin": "human", + "recovery": {"available": False}, + } + stale = self.inventory(old) + newer = self.inventory(old, new, human_new) + stale_started = threading.Event() + release_stale = threading.Event() + + def collect(_settings): + if threading.current_thread().name == "stale-collector": + stale_started.set() + self.assertTrue(release_stale.wait(5)) + return copy.deepcopy(stale) + return copy.deepcopy(newer) + + kwargs = self.snapshot_kwargs(state, collect) + outcomes: dict[str, object] = {} + + def run_snapshot(label: str) -> None: + outcomes[label] = snapshot_state.snapshot(**kwargs) + + older = threading.Thread( + target=run_snapshot, args=("older",), name="stale-collector" + ) + older.start() + self.assertTrue(stale_started.wait(5)) + try: + run_snapshot("newer") + finally: + release_stale.set() + older.join(5) + self.assertFalse(older.is_alive()) + + paths = state_io_state._state_paths({"state_dir": state}) + stored_inventory = inventory_core._read_state_json(paths["inventory"]) + stored_manifest = inventory_core._read_state_json(paths["manifest"]) + pending = inventory_core._read_state_json(paths["pending"]) + self.assertEqual( + ["human-new", "new", "old"], + sorted(row["shpool_id_raw"] for row in stored_inventory["sessions"]), + ) + self.assertEqual(["new", "old"], sorted(stored_manifest["sessions"])) + self.assertFalse( + pending + and any( + entry["old_shpool_id"] == "new" + for entry in inventory_core.flatten_pending(pending)["entries"] + ) + ) + self.assertEqual( + ["human-new", "new", "old"], + sorted(row["shpool_id_raw"] for row in outcomes["older"]["sessions"]), + ) + + def test_preupgrade_document_without_allocation_state_refuses(self) -> None: + with tempfile.TemporaryDirectory( + prefix=".publication-upgrade-", dir=REPO + ) as raw: + state = Path(raw) + state.chmod(0o700) + paths = state_io_state._state_paths({"state_dir": state}) + incumbent = self.inventory(self.row("old", 1, "blue")) + state_io_state.atomic_write_json(paths["inventory"], incumbent) + incumbent_bytes = paths["inventory"].read_bytes() + fresh = self.inventory(self.row("new", 2, "red")) + result = snapshot_state.snapshot( + **self.snapshot_kwargs(state, lambda _settings: copy.deepcopy(fresh)) + ) + + self.assertEqual(incumbent_bytes, paths["inventory"].read_bytes()) + self.assertFalse(paths["collection_sequence"].exists()) + self.assertFalse(paths["collection_sequence_floor"].exists()) + self.assertNotIn("collection_start", result) + self.assertEqual( + ["new"], [row["shpool_id_raw"] for row in result["sessions"]] + ) + self.assertTrue( + any( + "both durable collection sequence records are unreadable" in warning + and "read-only" in warning + for warning in result["warnings"] + ) + ) + + def test_corrupt_document_without_allocation_state_refuses(self) -> None: + with tempfile.TemporaryDirectory( + prefix=".publication-corrupt-", dir=REPO + ) as raw: + state = Path(raw) + state.chmod(0o700) + paths = state_io_state._state_paths({"state_dir": state}) + paths["inventory"].write_text("{broken\n", encoding="utf-8") + paths["inventory"].chmod(0o600) + incumbent_bytes = paths["inventory"].read_bytes() + fresh = self.inventory(self.row("new", 2, "red")) + + result = snapshot_state.snapshot( + **self.snapshot_kwargs(state, lambda _settings: copy.deepcopy(fresh)) + ) + + self.assertEqual(incumbent_bytes, paths["inventory"].read_bytes()) + self.assertFalse(paths["collection_sequence"].exists()) + self.assertFalse(paths["collection_sequence_floor"].exists()) + self.assertNotIn("collection_start", result) + self.assertEqual( + ["new"], + [row["shpool_id_raw"] for row in result["sessions"]], + ) + self.assertTrue( + any( + "both durable collection sequence records are unreadable" in warning + and "read-only" in warning + for warning in result["warnings"] + ) + ) + + def test_unknown_incoming_cannot_replace_a_marked_document(self) -> None: + with tempfile.TemporaryDirectory( + prefix=".publication-unknown-", dir=REPO + ) as raw: + state = Path(raw) + state.chmod(0o700) + paths = state_io_state._state_paths({"state_dir": state}) + with inventory_core.StateLock(state, paths["lock"]): + sequence, _diagnostic = self.allocate_start(paths) + self.ordered_write( + paths, + paths["inventory"], + self.inventory(self.row("new", 2, "red")), + collection_start=sequence, + ) + with self.assertRaisesRegex( + inventory_core.CollectionError, "unknown collection" + ): + self.ordered_write( + paths, + paths["inventory"], + self.inventory(self.row("old", 1, "blue")), + collection_start=None, + ) + stored = inventory_core._read_state_json(paths["inventory"]) + self.assertEqual("new", stored["sessions"][0]["shpool_id_raw"]) + + def test_counter_rebuild_stays_above_an_inflight_allocation(self) -> None: + with tempfile.TemporaryDirectory(prefix=".publication-floor-", dir=REPO) as raw: + state = Path(raw) + state.chmod(0o700) + paths = state_io_state._state_paths({"state_dir": state}) + counter_path = paths.get( + "collection_sequence", state / "collection-sequence.json" + ) + huge = 10**40 + state_io_state.atomic_write_json( + counter_path, + {"schema_version": 1, "last_collection_start": huge}, + ) + + first, _diagnostic = self.allocate_start(paths) + counter_path.write_text("{broken\n", encoding="utf-8") + counter_path.chmod(0o600) + second, diagnostic = self.allocate_start(paths) + + self.assertEqual(huge + 1, first) + self.assertEqual(huge + 2, second) + self.assertIn("durable allocation floor", diagnostic) + floor = state_io_state.read_private_json( + paths["collection_sequence_floor"], max_bytes=8192 + ) + self.assertEqual(second, floor["last_collection_start"]) + + def test_two_unreadable_sequence_records_refuse_publication(self) -> None: + with tempfile.TemporaryDirectory( + prefix=".publication-refusal-", dir=REPO + ) as raw: + state = Path(raw) + state.chmod(0o700) + paths = state_io_state._state_paths({"state_dir": state}) + old = self.row("old", 1, "blue") + human_new = self.row("human-new", 3, "green") + human_new["origin"] = "human" + + with inventory_core.StateLock(state, paths["lock"]): + first, _diagnostic = self.allocate_start(paths) + self.ordered_write( + paths, + paths["inventory"], + self.inventory(old), + collection_start=first, + ) + + stale_started = threading.Event() + release_stale = threading.Event() + + def collect(_settings): + if threading.current_thread().name == "stale-collector": + stale_started.set() + self.assertTrue(release_stale.wait(5)) + return copy.deepcopy(self.inventory(old)) + return copy.deepcopy(self.inventory(old, human_new)) + + kwargs = self.snapshot_kwargs(state, collect) + stale_result: dict[str, object] = {} + + def run_stale() -> None: + stale_result.update(snapshot_state.snapshot(**kwargs)) + + stale = threading.Thread(target=run_stale, name="stale-collector") + stale.start() + self.assertTrue(stale_started.wait(5)) + try: + for path in ( + paths["collection_sequence"], + paths["collection_sequence_floor"], + ): + path.write_text("{broken\n", encoding="utf-8") + path.chmod(0o600) + before = { + path.relative_to(state): path.read_bytes() + for path in state.rglob("*") + if path.is_file() + } + + fresh = snapshot_state.snapshot(**kwargs) + + after = { + path.relative_to(state): path.read_bytes() + for path in state.rglob("*") + if path.is_file() + } + self.assertEqual(before, after) + self.assertNotIn("collection_start", fresh) + self.assertEqual( + ["human-new", "old"], + sorted(row["shpool_id_raw"] for row in fresh["sessions"]), + ) + self.assertTrue( + any( + "both durable collection sequence records are unreadable" + in warning + and "read-only" in warning + for warning in fresh["warnings"] + ) + ) + finally: + release_stale.set() + stale.join(5) + self.assertFalse(stale.is_alive()) + self.assertEqual(2, stale_result["collection_start"]["sequence"]) + + stored = inventory_core._read_state_json(paths["inventory"]) + self.assertEqual( + ["old"], + [row["shpool_id_raw"] for row in stored["sessions"]], + ) + pending = inventory_core._read_state_json(paths["pending"]) + self.assertNotIn("human-new", json.dumps(pending, sort_keys=True)) + + def test_absent_sequence_records_with_a_document_refuse_publication(self) -> None: + with tempfile.TemporaryDirectory(prefix=".publication-lost-", dir=REPO) as raw: + state = Path(raw) + state.chmod(0o700) + paths = state_io_state._state_paths({"state_dir": state}) + old = self.row("old", 1, "blue") + human_new = self.row("human-new", 3, "green") + human_new["origin"] = "human" + + with inventory_core.StateLock(state, paths["lock"]): + first, _diagnostic = self.allocate_start(paths) + self.ordered_write( + paths, + paths["inventory"], + self.inventory(old), + collection_start=first, + ) + + stale_started = threading.Event() + release_stale = threading.Event() + + def collect(_settings): + if threading.current_thread().name == "stale-collector": + stale_started.set() + self.assertTrue(release_stale.wait(5)) + return copy.deepcopy(self.inventory(old)) + return copy.deepcopy(self.inventory(old, human_new)) + + kwargs = self.snapshot_kwargs(state, collect) + stale_result: dict[str, object] = {} + + def run_stale() -> None: + stale_result.update(snapshot_state.snapshot(**kwargs)) + + stale = threading.Thread(target=run_stale, name="stale-collector") + stale.start() + self.assertTrue(stale_started.wait(5)) + try: + paths["collection_sequence"].unlink() + paths["collection_sequence_floor"].unlink() + for marker in paths["collection_markers"].iterdir(): + marker.unlink() + before = { + path.relative_to(state): path.read_bytes() + for path in state.rglob("*") + if path.is_file() + } + + fresh = snapshot_state.snapshot(**kwargs) + + after = { + path.relative_to(state): path.read_bytes() + for path in state.rglob("*") + if path.is_file() + } + self.assertEqual(before, after) + self.assertNotIn("collection_start", fresh) + self.assertEqual( + ["human-new", "old"], + sorted(row["shpool_id_raw"] for row in fresh["sessions"]), + ) + self.assertTrue( + any( + "both durable collection sequence records are unreadable" + in warning + and "read-only" in warning + for warning in fresh["warnings"] + ) + ) + finally: + release_stale.set() + stale.join(5) + self.assertFalse(stale.is_alive()) + self.assertEqual(2, stale_result["collection_start"]["sequence"]) + pending = inventory_core._read_state_json(paths["pending"]) + self.assertNotIn("human-new", json.dumps(pending, sort_keys=True)) + + def test_any_document_or_marker_prevents_absent_state_bootstrap(self) -> None: + document_keys = ( + "inventory", + "terminal_numbers", + "terminal_numbers_retired", + "terminal_numbers_epoch", + "manifest", + "pending", + ) + for evidence in (*document_keys, "marker"): + with ( + self.subTest(evidence=evidence), + tempfile.TemporaryDirectory( + prefix=".publication-evidence-", dir=REPO + ) as raw, + ): + state = Path(raw) + state.chmod(0o700) + paths = state_io_state._state_paths({"state_dir": state}) + if evidence == "marker": + paths["collection_markers"].mkdir(mode=0o700) + marker = paths["collection_markers"] / "orphan.json" + marker.write_text("{broken\n", encoding="utf-8") + marker.chmod(0o600) + else: + paths[evidence].write_text("{broken\n", encoding="utf-8") + paths[evidence].chmod(0o600) + before = { + path.relative_to(state): path.read_bytes() + for path in state.rglob("*") + if path.is_file() + } + + with self.assertRaisesRegex( + inventory_core.CollectionError, + "both durable collection sequence records are unreadable.*read-only", + ): + self.allocate_start(paths) + + after = { + path.relative_to(state): path.read_bytes() + for path in state.rglob("*") + if path.is_file() + } + self.assertEqual(before, after) + + def test_genuinely_fresh_state_bootstraps_at_one(self) -> None: + with tempfile.TemporaryDirectory(prefix=".publication-first-", dir=REPO) as raw: + state = Path(raw) + state.chmod(0o700) + paths = state_io_state._state_paths({"state_dir": state}) + + sequence, diagnostic = self.allocate_start(paths) + + self.assertEqual(1, sequence) + self.assertIsNone(diagnostic) + for key in ("collection_sequence", "collection_sequence_floor"): + record = state_io_state.read_private_json(paths[key], max_bytes=8192) + self.assertEqual(1, record["last_collection_start"]) + + def test_one_readable_record_recovers_above_markers_and_inflight_floor( + self, + ) -> None: + for floor, marker, expected in ((12, 20, 21), (20, 12, 21)): + with ( + self.subTest(floor=floor, marker=marker), + tempfile.TemporaryDirectory( + prefix=".publication-recovery-", dir=REPO + ) as raw, + ): + state = Path(raw) + state.chmod(0o700) + paths = state_io_state._state_paths({"state_dir": state}) + state_io_state.atomic_write_json( + paths["collection_sequence_floor"], + {"schema_version": 1, "last_collection_start": floor}, + ) + self.ordered_write( + paths, + paths["inventory"], + self.inventory(self.row("old", 1, "blue")), + collection_start=marker, + ) + + sequence, _diagnostic = self.allocate_start(paths) + + self.assertEqual(expected, sequence) + for key in ("collection_sequence", "collection_sequence_floor"): + record = state_io_state.read_private_json( + paths[key], max_bytes=8192 + ) + self.assertEqual(expected, record["last_collection_start"]) + + def test_witness_precedes_document_and_closes_each_crash_boundary(self) -> None: + with tempfile.TemporaryDirectory( + prefix=".publication-intent-", dir=REPO + ) as raw: + state = Path(raw) + state.chmod(0o700) + paths = state_io_state._state_paths({"state_dir": state}) + old_payload = self.inventory(self.row("old", 1, "blue")) + new_payload = self.inventory(self.row("new", 2, "red")) + first, _diagnostic = self.allocate_start(paths) + self.ordered_write( + paths, + paths["inventory"], + old_payload, + collection_start=first, + ) + second, _diagnostic = self.allocate_start(paths) + marker_dir = paths.get("collection_markers", state / "collection-markers") + marker_path = marker_dir / f"{paths['inventory'].name}.json" + original_atomic_write = state_io_state.atomic_write_json + + def crash_before_witness(path: Path, payload: object) -> None: + if path == marker_path: + raise inventory_core.CollectionError( + "simulated crash before witness replacement" + ) + original_atomic_write(path, payload) + + with mock.patch.object( + state_io_state, "atomic_write_json", side_effect=crash_before_witness + ): + with self.assertRaisesRegex( + inventory_core.CollectionError, "before witness" + ): + self.ordered_write( + paths, + paths["inventory"], + new_payload, + collection_start=second, + ) + self.assertEqual(first, self.assert_exact_marker(state, paths["inventory"])) + + calls: list[Path] = [] + + def crash_after_witness(path: Path, payload: object) -> None: + calls.append(path) + if path == paths["inventory"]: + raise inventory_core.CollectionError( + "simulated crash after witness, before document" + ) + original_atomic_write(path, payload) + + with mock.patch.object( + state_io_state, "atomic_write_json", side_effect=crash_after_witness + ): + with self.assertRaisesRegex( + inventory_core.CollectionError, "after witness" + ): + self.ordered_write( + paths, + paths["inventory"], + new_payload, + collection_start=second, + ) + + self.assertEqual([marker_path, paths["inventory"]], calls) + self.assertEqual( + "old", + inventory_core._read_state_json(paths["inventory"])["sessions"][0][ + "shpool_id_raw" + ], + ) + self.assertIsNone( + state_io_state.read_collection_marker(paths, paths["inventory"]) + ) + refused, _diagnostics = state_io_state.preflight_collection_documents( + paths, ("inventory",), first + ) + self.assertEqual(["inventory.json"], refused) + with self.assertRaisesRegex( + inventory_core.CollectionError, "replacing newer" + ): + self.ordered_write( + paths, + paths["inventory"], + old_payload, + collection_start=first, + ) + + third, _diagnostic = self.allocate_start(paths) + self.ordered_write( + paths, + paths["inventory"], + new_payload, + collection_start=third, + ) + self.assertEqual( + "new", + inventory_core._read_state_json(paths["inventory"])["sessions"][0][ + "shpool_id_raw" + ], + ) + self.assertEqual(third, self.assert_exact_marker(state, paths["inventory"])) + + fourth, _diagnostic = self.allocate_start(paths) + + def crash_after_document(path: Path, payload: object) -> None: + original_atomic_write(path, payload) + if path == paths["inventory"]: + raise inventory_core.CollectionError( + "simulated crash after document replacement" + ) + + with mock.patch.object( + state_io_state, "atomic_write_json", side_effect=crash_after_document + ): + with self.assertRaisesRegex( + inventory_core.CollectionError, "after document" + ): + self.ordered_write( + paths, + paths["inventory"], + old_payload, + collection_start=fourth, + ) + self.assertEqual( + fourth, self.assert_exact_marker(state, paths["inventory"]) + ) + refused, _diagnostics = state_io_state.preflight_collection_documents( + paths, ("inventory",), third + ) + self.assertEqual(["inventory.json"], refused) + + def test_pending_loss_is_reproved_immediately_before_authorities_advance( + self, + ) -> None: + """A writer's returned success is not the consumer's durable evidence.""" + with tempfile.TemporaryDirectory( + prefix=".pending-consumer-proof-", dir=REPO + ) as raw: + state = Path(raw) + state.chmod(0o700) + paths = state_io_state._state_paths({"state_dir": state}) + lost = self.row("lost", 91, "blue") + survivor = self.row("survivor", 92, "green") + previous = self.inventory(lost, survivor) + current = self.inventory(survivor) + previous_manifest = recovery_state.recovery_manifest( + previous, + schema_version=inventory_core.SCHEMA_VERSION, + boot_id=lambda: self.BOOT, + ) + prior_pending = { + "schema_version": inventory_core.SCHEMA_VERSION, + "generated_at": previous["generated_at"], + "source_boot_id": self.BOOT, + "source_daemon_generation": dict(self.GENERATION), + "detected_boot_id": self.BOOT, + "detected_daemon_generation": dict(self.GENERATION), + "sessions": {}, + "queued_generations": [], + } + state_io_state.atomic_write_json( + paths["collection_sequence_floor"], + {"schema_version": 1, "last_collection_start": 1}, + ) + state_io_state.atomic_write_json(paths["inventory"], previous) + state_io_state.atomic_write_json(paths["manifest"], previous_manifest) + with inventory_core.StateLock(state, paths["lock"]): + state_io_state.atomic_write_json(paths["pending"], prior_pending) + replacement = state / ".prior-pending" + state_io_state.atomic_write_json(replacement, prior_pending) + + real_prove = state_io_state._prove_published_descriptor + attacked = False + + def replace_after_proof(descriptor: int, path: Path) -> None: + nonlocal attacked + real_prove(descriptor, path) + if path == paths["pending"] and not attacked: + attacked = True + os.replace(replacement, path) + directory = os.open(state, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + + with mock.patch.object( + state_io_state, + "_prove_published_descriptor", + replace_after_proof, + ): + result = snapshot_state.snapshot( + **self.snapshot_kwargs( + state, lambda _settings: copy.deepcopy(current) + ) + ) + + self.assertTrue(attacked) + self.assertEqual( + ["lost", "survivor"], + [ + row["shpool_id_raw"] + for row in inventory_core._read_state_json(paths["inventory"])[ + "sessions" + ] + ], + ) + self.assertEqual( + ["lost", "survivor"], + sorted(inventory_core._read_state_json(paths["manifest"])["sessions"]), + ) + self.assertTrue( + any( + "pending recovery evidence changed before inventory/manifest advance" + in warning + and "found none" in warning + for warning in result["warnings"] + ) + ) + + snapshot_state.snapshot( + **self.snapshot_kwargs(state, lambda _settings: copy.deepcopy(current)) + ) + queued = inventory_core.list_pending({"state_dir": state})["entries"] + self.assertIn(uuid_for(91), {entry["uuid"] for entry in queued}) + + def test_pending_publication_requires_this_thread_to_hold_the_state_lock( + self, + ) -> None: + with tempfile.TemporaryDirectory(prefix=".pending-lock-", dir=REPO) as raw: + state = Path(raw) + state.chmod(0o700) + paths = state_io_state._state_paths({"state_dir": state}) + lost = self.row("lost", 93, "blue") + previous = self.inventory(lost) + current = self.inventory() + config = {"state_dir": state} + real_atomic = state_io_state._atomic_write_bytes + lock_observations: list[bool] = [] + + def observe_pending_write(path: Path, payload: bytes) -> None: + if path == paths["pending"]: + held = getattr( + state_io_state, "_held_publishing_locks", lambda: set() + )() + key = getattr( + state_io_state, + "_publishing_lock_key", + lambda root: os.path.abspath(os.fspath(root)), + )(state) + lock_observations.append(key in held) + real_atomic(path, payload) + + with mock.patch.object( + state_io_state, + "_atomic_write_bytes", + side_effect=observe_pending_write, + ): + queued = inventory_core.enqueue_lost_conversations( + paths, + current, + previous, + boot_id=self.BOOT, + config=config, + ) + self.assertEqual([f"codex:{uuid_for(93)}"], queued) + self.assertEqual([True], lock_observations) + + paths["pending"].unlink() + with inventory_core.StateLock(state, paths["lock"]): + inventory_core.enqueue_lost_conversations( + paths, + current, + previous, + boot_id=self.BOOT, + config=config, + ) + + def test_every_shipped_pending_writer_uses_the_locked_publication_seam( + self, + ) -> None: + production = [ + REPO / "lib/session_inventory.py", + *(REPO / "lib/sessionkit_inventory").rglob("*.py"), + *(REPO / "tools").rglob("*.py"), + REPO / "bin/reset-collection-order.py", + ] + calls: list[tuple[str, str, str]] = [] + aliases: list[tuple[str, str]] = [] + literals: list[tuple[str, str]] = [] + for path in production: + tree = ast.parse(path.read_text(encoding="utf-8")) + parents = { + child: parent + for parent in ast.walk(tree) + for child in ast.iter_child_nodes(parent) + } + + def owner(node: ast.AST) -> str: + while node in parents: + node = parents[node] + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + return node.name + return "<module>" + + def is_pending_subscript(node: ast.AST) -> bool: + return ( + isinstance(node, ast.Subscript) + and isinstance(node.slice, ast.Constant) + and node.slice.value == "pending" + ) + + relative = path.relative_to(REPO).as_posix() + for node in ast.walk(tree): + if isinstance(node, ast.Call) and any( + is_pending_subscript(candidate) for candidate in ast.walk(node) + ): + if isinstance(node.func, ast.Name): + callee = node.func.id + elif isinstance(node.func, ast.Attribute): + callee = node.func.attr + else: + callee = ast.unparse(node.func) + calls.append((relative, owner(node), callee)) + if isinstance(node, (ast.Assign, ast.AnnAssign)): + value = node.value + if value is not None and is_pending_subscript(value): + aliases.append((relative, owner(node))) + if ( + isinstance(node, ast.Constant) + and node.value == "recovery-pending.json" + ): + literals.append((relative, owner(node))) + + self.assertEqual( + sorted( + [ + ( + "lib/session_inventory.py", + "enqueue_lost_conversations", + "_state_paths", + ), + ( + "lib/sessionkit_inventory/migration.py", + "apply_legacy_recovery_manifest", + "exists", + ), + ( + "lib/sessionkit_inventory/migration.py", + "apply_legacy_recovery_manifest", + "is_symlink", + ), + ( + "lib/sessionkit_inventory/migration.py", + "plan_legacy_recovery_manifest", + "exists", + ), + ( + "lib/sessionkit_inventory/migration.py", + "plan_legacy_recovery_manifest", + "is_symlink", + ), + ( + "lib/sessionkit_inventory/migration.py", + "rollback_legacy_recovery_manifest", + "exists", + ), + ( + "lib/sessionkit_inventory/migration.py", + "rollback_legacy_recovery_manifest", + "is_symlink", + ), + ( + "lib/sessionkit_inventory/recovery.py", + "acknowledge_pending", + "read_state_json", + ), + ( + "lib/sessionkit_inventory/recovery.py", + "enqueue_lost_conversations", + "atomic_write_json", + ), + ( + "lib/sessionkit_inventory/recovery.py", + "enqueue_lost_conversations", + "read_state_json", + ), + ( + "lib/sessionkit_inventory/recovery.py", + "enqueue_lost_conversations", + "write_collection_json", + ), + ( + "lib/sessionkit_inventory/recovery.py", + "list_pending", + "flatten_pending", + ), + ( + "lib/sessionkit_inventory/recovery.py", + "list_pending", + "read_state_json", + ), + ( + "lib/sessionkit_inventory/recovery.py", + "update_recovery_state", + "CollectionError", + ), + ( + "lib/sessionkit_inventory/recovery.py", + "update_recovery_state", + "publish", + ), + ( + "lib/sessionkit_inventory/recovery.py", + "update_recovery_state", + "read_state_json", + ), + ( + "lib/sessionkit_inventory/recovery.py", + "update_recovery_state", + "read_state_json", + ), + ( + "lib/sessionkit_inventory/snapshot.py", + "snapshot", + "CollectionError", + ), + ( + "lib/sessionkit_inventory/snapshot.py", + "snapshot", + "read_state_json", + ), + ] + ), + sorted(calls), + ) + self.assertEqual([], aliases) + self.assertEqual( + [ + ("bin/reset-collection-order.py", "<module>"), + ("lib/sessionkit_inventory/state_io.py", "_state_paths"), + ], + sorted(literals), + ) + facade = (REPO / "lib/session_inventory.py").read_text(encoding="utf-8") + self.assertEqual(4, facade.count("_require_pending_publication_lock(path)")) + self.assertEqual(2, facade.count("with _state_io.publication_lock(")) + reset = (REPO / "bin/reset-collection-order.py").read_text(encoding="utf-8") + self.assertLess( + reset.index("fcntl.flock(descriptor, fcntl.LOCK_EX)"), + reset.index("os.replace(source, target)"), + ) + + def test_the_new_lock_generation_fences_legacy_publishers(self) -> None: + with tempfile.TemporaryDirectory(prefix=".publication-fence-", dir=REPO) as raw: + state = Path(raw) + state.chmod(0o700) + paths = state_io_state._state_paths({"state_dir": state}) + # Spell the legacy pathname independently of the new path map. + # Loaded against a pre-fence runtime, this reaches the behavioral + # inode assertion below instead of dying on a new-only dict key. + legacy_lock = state / "inventory.lock" + legacy_lock.touch(mode=0o600) + waiting_fd = os.open(legacy_lock, os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC) + self.addCleanup(lambda: os.close(waiting_fd)) + old_inode = os.fstat(waiting_fd).st_ino + + with inventory_core.StateLock(state, paths["lock"]): + self.assertNotEqual(old_inode, legacy_lock.stat().st_ino) + + # An old collector that opened the former inode before the fence + # can acquire that orphaned inode later, but its existing + # post-flock inode check sees that the pathname now names the + # refusal sentinel and aborts before publishing. + fcntl.flock(waiting_fd, fcntl.LOCK_EX) + self.assertNotEqual( + os.fstat(waiting_fd).st_ino, + legacy_lock.stat().st_ino, + ) + fcntl.flock(waiting_fd, fcntl.LOCK_UN) + self.assertEqual(0o400, stat.S_IMODE(legacy_lock.stat().st_mode)) + with self.assertRaises((inventory_core.CollectionError, OSError)): + inventory_core.StateLock(state, legacy_lock).__enter__() + + +class CollectionOrderResetToolTests(unittest.TestCase): + def run_reset(self, state: Path) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + env["SESSION_KIT_STATE_DIR"] = os.fspath(state) + return subprocess.run( + [sys.executable, os.fspath(REPO / "bin/reset-collection-order.py")], + env=env, + text=True, + capture_output=True, + check=False, + ) + + def test_offline_reset_archives_lost_order_state_and_preserves_other_state( + self, + ) -> None: + with tempfile.TemporaryDirectory(prefix=".publication-reset-", dir=REPO) as raw: + state = Path(raw) + state.chmod(0o700) + paths = state_io_state._state_paths({"state_dir": state}) + state_io_state.atomic_write_json(paths["inventory"], {"sessions": []}) + paths["collection_markers"].mkdir(mode=0o700) + state_io_state.atomic_write_json( + paths["collection_markers"] / "inventory.json.json", {"broken": True} + ) + paths["collection_sequence"].write_text("{broken\n", encoding="utf-8") + paths["collection_sequence"].chmod(0o600) + unrelated = state / "closed-sessions.jsonl" + unrelated.write_text("preserved\n", encoding="utf-8") + unrelated.chmod(0o600) + + completed = self.run_reset(state) + + self.assertEqual(0, completed.returncode, completed.stderr) + archive = Path(completed.stdout.strip()) + self.assertEqual(state, archive.parent) + self.assertEqual(b"preserved\n", unrelated.read_bytes()) + self.assertTrue((archive / "inventory.json").is_file()) + self.assertTrue((archive / "collection-sequence.json").is_file()) + self.assertTrue((archive / "collection-markers").is_dir()) + self.assertFalse(paths["inventory"].exists()) + self.assertFalse(paths["collection_sequence"].exists()) + self.assertFalse(paths["collection_markers"].exists()) + first, diagnostic = state_io_state.allocate_collection_start(paths) + self.assertEqual(1, first) + self.assertIsNone(diagnostic) + + def test_offline_reset_refuses_a_readable_allocation_record(self) -> None: + with tempfile.TemporaryDirectory(prefix=".publication-reset-", dir=REPO) as raw: + state = Path(raw) + state.chmod(0o700) + paths = state_io_state._state_paths({"state_dir": state}) + state_io_state.atomic_write_json(paths["inventory"], {"sessions": []}) + state_io_state.atomic_write_json( + paths["collection_sequence_floor"], + {"schema_version": 1, "last_collection_start": 17}, + ) + inventory_before = paths["inventory"].read_bytes() + floor_before = paths["collection_sequence_floor"].read_bytes() + + completed = self.run_reset(state) + + self.assertNotEqual(0, completed.returncode) + self.assertIn("reset is unnecessary", completed.stderr) + self.assertEqual(inventory_before, paths["inventory"].read_bytes()) + self.assertEqual( + floor_before, paths["collection_sequence_floor"].read_bytes() + ) + self.assertFalse(any(state.glob("collection-order-reset-*"))) + + def inventory_fixture( count: int, *, providers: tuple[str, ...] = ("claude", "codex"), -) -> tuple[dict, list[dict], dict[int, dict], dict[int, list[dict]], tuple[dict, dict], dict]: +) -> tuple[ + dict, list[dict], dict[int, dict], dict[int, list[dict]], tuple[dict, dict], dict +]: shpool = {"sessions": []} claude: list[dict] = [] - table = { - 10: process(10, 1, "shpool", cmdline=["/usr/bin/shpool", "daemon"]) - } + table = {10: process(10, 1, "shpool", cmdline=["/usr/bin/shpool", "daemon"])} codex_index: dict[int, list[dict]] = {} threads: dict[str, dict] = {} for index in range(1, count + 1): @@ -83,9 +1352,7 @@ def inventory_fixture( "started_at_unix_ms": 1_700_000_000_000 + index, } ) - table[root_pid] = process( - root_pid, 10, "bash", session_name=name, cwd=cwd - ) + table[root_pid] = process(root_pid, 10, "bash", session_name=name, cwd=cwd) table[agent_pid] = process( agent_pid, root_pid, @@ -127,6 +1394,93 @@ def inventory_fixture( class InventoryScaleTests(unittest.TestCase): + def test_claude_derived_placeholder_yields_to_alias_but_missing_source_wins( + self, + ) -> None: + fixture = list(inventory_fixture(1, providers=("claude",))) + uuid = uuid_for(1) + key = f"claude:{uuid}" + fixture[1][0].update({"name": "v2-5e", "nameSource": "derived"}) + fixture[5].update( + { + "aliases": {key: "Session Kit Closeout"}, + "automatic_titles": {key: "Session Kit Closeout"}, + "pushed_titles": {key: "Session Kit Closeout"}, + } + ) + + derived = inventory_core.build_inventory(*fixture, now=1_800_000_000)[ + "sessions" + ][0] + self.assertEqual("Session Kit Closeout", derived["title"]) + self.assertEqual("alias", derived["title_source"]) + + fixture[1][0].pop("nameSource") + historical = inventory_core.build_inventory(*fixture, now=1_800_000_000)[ + "sessions" + ][0] + self.assertEqual("v2-5e", historical["title"]) + self.assertEqual("native", historical["title_source"]) + + def test_claude_agent_name_outranks_generated_native_title(self) -> None: + fixture = list(inventory_fixture(1, providers=("claude",))) + fixture[1][0].update( + { + "name": "Verify disposable session kit", + "nameSource": "derived", + "aiTitle": "Verify disposable session kit", + "agentName": "Disposable Claude Proof", + } + ) + + result = inventory_core.build_inventory(*fixture, now=1_800_000_000) + + row = result["sessions"][0] + self.assertEqual("Disposable Claude Proof", row["native_title"]) + self.assertEqual("Disposable Claude Proof", row["title"]) + self.assertEqual("native", row["title_source"]) + self.assertEqual("ready", row["provider_title_state"]) + self.assertEqual("not-needed", row["automatic_name_state"]) + + def test_claude_stale_pre_push_name_is_pending_and_third_name_is_human( + self, + ) -> None: + fixture = list(inventory_fixture(1, providers=("claude",))) + uuid = uuid_for(1) + key = f"claude:{uuid}" + fixture[5].update( + { + "aliases": {key: "New Kit Name"}, + "automatic_titles": {key: "New Kit Name"}, + "pushed_titles": {key: "New Kit Name"}, + "pending_native_titles": { + key: { + "title": "Old Registry Name", + "nameSince": 100, + "nameSource": "", + } + }, + } + ) + fixture[1][0]["name"] = "Old Registry Name" + fixture[1][0]["nameSince"] = 100 + + pending = inventory_core.build_inventory(*fixture, now=1_800_000_000)[ + "sessions" + ][0] + self.assertEqual("New Kit Name", pending["title"]) + self.assertEqual("alias", pending["title_source"]) + self.assertEqual("pending", pending["provider_title_state"]) + + fixture[1][0]["name"] = "A Third Human Name" + fixture[1][0]["nameSince"] = 200 + human = inventory_core.build_inventory(*fixture, now=1_800_000_000)["sessions"][ + 0 + ] + self.assertEqual("A Third Human Name", human["title"]) + self.assertEqual("native", human["title_source"]) + self.assertEqual("ready", human["provider_title_state"]) + def test_zero_one_and_100_plus_counts_have_no_ceiling(self) -> None: for count in (0, 1, 9, 10, 99, 125, 250): with self.subTest(count=count): @@ -146,14 +1500,15 @@ def test_zero_one_and_100_plus_counts_have_no_ceiling(self) -> None: {"COLUMNS": "60", "SESSION_KIT_NO_COLOR": "1"}, clear=False, ): - rendered = inventory_core.render_inventory( - result, rows_only=True + rendered = inventory_core.render_inventory(result, rows_only=True) + if count: + session_word = "session" if count == 1 else "sessions" + self.assertTrue( + rendered.startswith(f" {count} {session_word}:"), + rendered[:100], ) - session_word = "session" if count == 1 else "sessions" - self.assertTrue( - rendered.startswith(f" {count} {session_word}:"), - rendered[:100], - ) + else: + self.assertTrue(rendered.startswith(" Sessions: none.")) if count in (0, 1, 125): self.assertTrue( all( @@ -188,10 +1543,10 @@ def test_action_provider_activity_sort_and_rows_are_deterministic(self) -> None: recent_output_by_shpool_id=dict(reversed(tuple(recent.items()))), ) expected = [ - "main10", "main3", "main4", "main2", + "main10", "main", "main5", "main6", @@ -206,10 +1561,12 @@ def test_action_provider_activity_sort_and_rows_are_deterministic(self) -> None: first_rows = {item["shpool_id"]: item["row"] for item in first["sessions"]} second_rows = {item["shpool_id"]: item["row"] for item in second["sessions"]} self.assertEqual(first_rows, second_rows) - self.assertEqual(list(range(1, 13)), [item["row"] for item in first["sessions"]]) - self.assertTrue(first["sessions"][0]["needs_you"]) + self.assertEqual( + list(range(1, 13)), [item["row"] for item in first["sessions"]] + ) + self.assertTrue(first["sessions"][3]["needs_you"]) + self.assertEqual(3_000, first["sessions"][0]["recent_output_at_unix_ms"]) self.assertEqual(3_000, first["sessions"][1]["recent_output_at_unix_ms"]) - self.assertEqual(3_000, first["sessions"][2]["recent_output_at_unix_ms"]) def test_availability_then_provider_precede_activity(self) -> None: fixture = list(inventory_fixture(4, providers=("codex", "claude"))) @@ -264,9 +1621,7 @@ def test_recent_output_uses_exact_map_legacy_and_segmented_files(self) -> None: recovered.write_text("mapped", encoding="utf-8") os.utime(recovered, ns=(4_000_000_000, 4_000_000_000)) (recovery / "current-map.tsv").write_text( - f"main3\t{recovered}\n" - f"main4\t{recovered}\n" - f"main4\t{active_but_mapped}\n", + f"main3\t{recovered}\nmain4\t{recovered}\nmain4\t{active_but_mapped}\n", encoding="utf-8", ) @@ -275,13 +1630,712 @@ def test_recent_output_uses_exact_map_legacy_and_segmented_files(self) -> None: journal_dir=journals, recovery_dir=recovery, ) - self.assertEqual( - {"main": 1_000, "main2": 3_000, "main3": 4_000}, - result, + self.assertEqual( + {"main": 1_000, "main2": 3_000, "main3": 4_000}, + result, + ) + + +class InventoryIdentityTests(unittest.TestCase): + def test_claude_owner_keeps_a_descendant_codex_candidate_as_a_worker( + self, + ) -> None: + fixture = list(inventory_fixture(1, providers=("claude",))) + child_pid = 3001 + child_uuid = uuid_for(2) + fixture[2][child_pid] = process( + child_pid, + 2001, + "codex", + start_ticks=30_010, + cmdline=["/usr/bin/codex", "exec", "verify"], + ) + fixture[3][child_pid] = [ + { + "source": "cli", + "id": child_uuid, + "session_id": child_uuid, + "_turn_state": "working", + } + ] + fixture[4][0][child_uuid] = { + "id": child_uuid, + "title": "Codex worker", + "cwd": "/srv/project-1", + } + + result = inventory_core.build_inventory(*fixture, now=1_800_000_000) + + row = result["sessions"][0] + self.assertEqual("claude", row["provider"]) + self.assertEqual(uuid_for(1), row["identity"]["uuid"]) + self.assertEqual( + [ + { + "provider": "codex", + "uuid": child_uuid, + "pid": child_pid, + "title": "Codex worker", + "status": "working", + } + ], + row["subagents"], + ) + self.assertEqual(1, row["active_subagent_count"]) + self.assertEqual([], result["outside_agents"]) + + def test_codex_owner_keeps_a_descendant_claude_candidate_as_a_worker( + self, + ) -> None: + fixture = list(inventory_fixture(1, providers=("codex",))) + child_pid = 3001 + child_uuid = uuid_for(2) + fixture[2][child_pid] = process( + child_pid, + 2001, + "claude", + start_ticks=30_010, + cmdline=["/usr/bin/claude", "--session-id", child_uuid], + ) + fixture[1].append( + { + "pid": child_pid, + "sessionId": child_uuid, + "cwd": "/srv/project-1", + "kind": "interactive", + "name": "Claude worker", + "status": "busy", + "startedAt": 1_700_000_000_002, + } + ) + + result = inventory_core.build_inventory(*fixture, now=1_800_000_000) + + row = result["sessions"][0] + self.assertEqual("codex", row["provider"]) + self.assertEqual(uuid_for(1), row["identity"]["uuid"]) + self.assertEqual( + [ + { + "provider": "claude", + "uuid": child_uuid, + "pid": child_pid, + "title": "Claude worker", + "status": "working", + } + ], + row["subagents"], + ) + self.assertEqual(1, row["active_subagent_count"]) + self.assertEqual([], result["outside_agents"]) + + def test_disjoint_provider_candidates_stay_unresolved(self) -> None: + fixture = list(inventory_fixture(1, providers=("claude",))) + rival_pid = 3001 + rival_uuid = uuid_for(2) + fixture[2][rival_pid] = process( + rival_pid, + 1001, + "codex", + start_ticks=30_010, + cmdline=["/usr/bin/codex"], + ) + fixture[3][rival_pid] = [ + {"source": "cli", "id": rival_uuid, "session_id": rival_uuid} + ] + fixture[4][0][rival_uuid] = { + "id": rival_uuid, + "title": "Rival Codex", + "cwd": "/srv/project-1", + } + + row = inventory_core.build_inventory(*fixture, now=1_800_000_000)["sessions"][0] + + self.assertEqual("unknown", row["provider"]) + self.assertEqual("Unresolved provider session", row["title"]) + self.assertIsNone(row["identity"]["uuid"]) + + def test_unreadable_link_cannot_turn_a_candidate_into_a_worker(self) -> None: + fixture = list(inventory_fixture(1, providers=("claude",))) + bridge_pid = 2501 + child_pid = 3001 + child_uuid = uuid_for(2) + fixture[2][bridge_pid] = { + **process(bridge_pid, 2001, "", start_ticks=-1, cmdline=[]), + "argv_unreadable": True, + } + fixture[2][child_pid] = process( + child_pid, + bridge_pid, + "codex", + start_ticks=30_010, + cmdline=["/usr/bin/codex", "exec", "verify"], + ) + fixture[3][child_pid] = [ + {"source": "cli", "id": child_uuid, "session_id": child_uuid} + ] + fixture[4][0][child_uuid] = { + "id": child_uuid, + "title": "Unproven Codex worker", + "cwd": "/srv/project-1", + } + + row = inventory_core.build_inventory(*fixture, now=1_800_000_000)["sessions"][0] + + self.assertEqual("unknown", row["provider"]) + self.assertEqual("Unresolved provider session", row["title"]) + self.assertIsNone(row["identity"]["uuid"]) + + def test_recycled_parent_generation_cannot_fake_worker_lineage(self) -> None: + fixture = list(inventory_fixture(1, providers=("claude",))) + child_pid = 3001 + child_uuid = uuid_for(2) + fixture[2][2001]["start_ticks"] = 40_010 + fixture[2][child_pid] = process( + child_pid, + 2001, + "codex", + start_ticks=30_010, + cmdline=["/usr/bin/codex", "exec", "verify"], + ) + fixture[3][child_pid] = [ + {"source": "cli", "id": child_uuid, "session_id": child_uuid} + ] + fixture[4][0][child_uuid] = { + "id": child_uuid, + "title": "Stale Codex child", + "cwd": "/srv/project-1", + } + + row = inventory_core.build_inventory(*fixture, now=1_800_000_000)["sessions"][0] + + self.assertEqual("unknown", row["provider"]) + self.assertEqual("Unresolved provider session", row["title"]) + self.assertIsNone(row["identity"]["uuid"]) + + def test_linux_process_scan_retains_exact_provider_account_environment( + self, + ) -> None: + with tempfile.TemporaryDirectory(prefix=".proc-account-", dir=REPO) as raw: + proc_root = Path(raw) + entry = proc_root / "123" + entry.mkdir() + (entry / "stat").write_text("fixture\n", encoding="utf-8") + (entry / "cmdline").write_bytes(b"codex\0") + (entry / "comm").write_text("codex\n", encoding="utf-8") + (entry / "environ").write_bytes( + b"CLAUDE_CONFIG_DIR=/profiles/claude-a\0" + b"CODEX_HOME=/profiles/codex-a\0" + b"SESSION_KIT_ACCOUNT_ALIAS=paid-a\0" + b"SESSION_KIT_ACCOUNT_CAPABLE=1\0" + ) + os.symlink(REPO, entry / "cwd") + with mock.patch.object( + process_inventory, "_proc_stat", return_value=(123, 10, 1000) + ): + scanned = process_inventory.scan_process_table( + proc_root, + 64, + proc_stat=process_inventory._proc_stat, + proc_environ=process_inventory._proc_environ, + ) + self.assertEqual("/profiles/claude-a", scanned[123]["claude_config_dir"]) + self.assertEqual("/profiles/codex-a", scanned[123]["codex_home"]) + self.assertEqual("paid-a", scanned[123]["account_alias"]) + self.assertEqual("1", scanned[123]["account_capable"]) + + def test_unreadable_own_environment_is_recorded_and_named_in_diagnostics( + self, + ) -> None: + with tempfile.TemporaryDirectory(prefix=".proc-denied-", dir=REPO) as raw: + proc_root = Path(raw) + entry = proc_root / "123" + entry.mkdir() + (entry / "stat").write_text("fixture\n", encoding="utf-8") + (entry / "cmdline").write_bytes(b"bash\0") + (entry / "comm").write_text("bash\n", encoding="utf-8") + # A directory where the file belongs denies the read for every + # uid, which is what a ptrace policy does to a live managed shell. + (entry / "environ").mkdir() + with mock.patch.object( + process_inventory, "_proc_stat", return_value=(123, 10, 1000) + ): + scanned = process_inventory.scan_process_table( + proc_root, + 64, + proc_stat=process_inventory._proc_stat, + proc_environ=process_inventory._proc_environ, + ) + self.assertTrue(scanned[123]["environ_unreadable"]) + self.assertEqual("", scanned[123]["session_name"]) + table = { + 10: {"pid": 10, "ppid": 1, "cmdline": ["/usr/bin/shpool", "daemon"]}, + 123: scanned[123], + } + roots, diagnostics = process_inventory.shpool_roots( + ["s20260811-090000-3"], + table, + is_shpool_daemon=process_inventory._is_shpool_daemon, + ) + self.assertEqual({}, roots) + self.assertTrue( + any( + "unreadable environment" in line + for line in diagnostics["s20260811-090000-3"] + ), + diagnostics, + ) + + def test_failed_uid_check_records_environment_as_unknown(self) -> None: + with mock.patch.object(Path, "stat", side_effect=OSError("denied")): + self.assertIsNone(process_inventory._readable_by_us(Path("/proc/123"))) + with tempfile.TemporaryDirectory(prefix=".proc-uid-unknown-", dir=REPO) as raw: + proc_root = Path(raw) + entry = proc_root / "123" + entry.mkdir() + (entry / "stat").write_text("fixture\n", encoding="utf-8") + (entry / "cmdline").write_bytes(b"bash\0") + (entry / "comm").write_text("bash\n", encoding="utf-8") + with ( + mock.patch.object( + process_inventory, "_proc_stat", return_value=(123, 10, 1000) + ), + mock.patch.object( + process_inventory, "_readable_by_us", return_value=None + ), + ): + scanned = process_inventory.scan_process_table( + proc_root, + 64, + proc_stat=process_inventory._proc_stat, + proc_environ=process_inventory._proc_environ, + ) + self.assertTrue(scanned[123]["environ_unreadable"]) + self.assertEqual("", scanned[123]["session_name"]) + + def test_a_process_whose_argv_will_not_read_stays_in_the_table(self) -> None: + """A process that exists and will not say what it is still exists. + + Dropped from the table, it left readers answering "nothing in this + tree holds that socket" about a process they never saw -- and that + answer is what moves a person's session behind the machine count. It + stays, placed by its parent, saying nothing but that it is unread. + """ + with tempfile.TemporaryDirectory(prefix=".proc-argv-", dir=REPO) as raw: + proc_root = Path(raw) + entry = proc_root / "123" + entry.mkdir() + (entry / "stat").write_text("fixture\n", encoding="utf-8") + # A directory where the file belongs denies the read for every + # uid, the same shape the environ test uses. + (entry / "cmdline").mkdir() + (entry / "comm").write_text("codex\n", encoding="utf-8") + (entry / "environ").write_bytes(b"SHPOOL_SESSION_NAME=main\0") + with mock.patch.object( + process_inventory, "_proc_stat", return_value=(123, 10, 1000) + ): + scanned = process_inventory.scan_process_table( + proc_root, + 64, + proc_stat=process_inventory._proc_stat, + proc_environ=process_inventory._proc_environ, + ) + + self.assertIn(123, scanned) + self.assertTrue(scanned[123]["argv_unreadable"]) + self.assertEqual(10, scanned[123]["ppid"]) + # Nothing about it may be mistaken for evidence of anything. + self.assertEqual([], scanned[123]["cmdline"]) + self.assertEqual("", scanned[123]["comm"]) + self.assertEqual("", scanned[123]["session_name"]) + + def test_a_scan_that_lost_a_process_says_so(self) -> None: + """The loss with no parent, which cannot be recorded in place. + + A pid listed a moment ago whose stat will not read has no parent, so + it belongs to no tree and no slot can hold it. The scan itself carries + the fact instead. A fixture that never says otherwise reads as + complete, which is what a fixture is. + """ + with tempfile.TemporaryDirectory(prefix=".proc-gone-", dir=REPO) as raw: + proc_root = Path(raw) + for name in ("123", "124"): + entry = proc_root / name + entry.mkdir() + (entry / "stat").write_text("fixture\n", encoding="utf-8") + (entry / "cmdline").write_bytes(b"bash\0") + (entry / "comm").write_text("bash\n", encoding="utf-8") + (entry / "environ").write_bytes(b"") + with mock.patch.object( + process_inventory, + "_proc_stat", + side_effect=[ + OSError(errno.ESRCH, "vanished"), + (124, 10, 1000), + (124, 10, 1000), + ], + ): + scanned = process_inventory.scan_process_table( + proc_root, + 64, + proc_stat=process_inventory._proc_stat, + proc_environ=process_inventory._proc_environ, + ) + + self.assertEqual([124], sorted(scanned)) + self.assertFalse(process_inventory.table_is_complete(scanned)) + self.assertTrue(process_inventory.table_is_complete({124: scanned[124]})) + + def test_readable_environment_never_claims_an_unreadable_one(self) -> None: + with tempfile.TemporaryDirectory(prefix=".proc-plain-", dir=REPO) as raw: + proc_root = Path(raw) + entry = proc_root / "123" + entry.mkdir() + (entry / "stat").write_text("fixture\n", encoding="utf-8") + (entry / "cmdline").write_bytes(b"bash\0") + (entry / "comm").write_text("bash\n", encoding="utf-8") + (entry / "environ").write_bytes(b"SHPOOL_SESSION_NAME=main\0") + with mock.patch.object( + process_inventory, "_proc_stat", return_value=(123, 10, 1000) + ): + scanned = process_inventory.scan_process_table( + proc_root, + 64, + proc_stat=process_inventory._proc_stat, + proc_environ=process_inventory._proc_environ, + ) + self.assertFalse(scanned[123]["environ_unreadable"]) + table = { + 10: {"pid": 10, "ppid": 1, "cmdline": ["/usr/bin/shpool", "daemon"]}, + 123: scanned[123], + } + _, diagnostics = process_inventory.shpool_roots( + ["other"], + table, + is_shpool_daemon=process_inventory._is_shpool_daemon, + ) + self.assertFalse( + any("unreadable environment" in line for line in diagnostics["other"]), + diagnostics, + ) + + def test_darwin_process_scan_retains_exact_provider_account_environment( + self, + ) -> None: + info = inventory_core._DarwinBsdInfo() + info.pbi_pid = 123 + info.pbi_ppid = 10 + info.pbi_start_tvsec = 1_752_000_000 + info.pbi_start_tvusec = 123_456 + info.pbi_name = b"codex" + values = [ + b"/opt/homebrew/bin/codex", + b"", + b"/opt/homebrew/bin/codex", + b"CLAUDE_CONFIG_DIR=/profiles/claude-a", + b"CODEX_HOME=/profiles/codex-a", + b"SESSION_KIT_ACCOUNT_ALIAS=paid-a", + b"SESSION_KIT_ACCOUNT_CAPABLE=1", + ] + payload = struct.pack("=i", 1) + b"\0".join(values) + b"\0" + scanned = inventory_core.scan_darwin_process_table( + 4, + pids=[123], + bsd_reader=lambda _pid: info, + args_reader=lambda _pid: payload, + ) + self.assertEqual("/profiles/claude-a", scanned[123]["claude_config_dir"]) + self.assertEqual("/profiles/codex-a", scanned[123]["codex_home"]) + self.assertEqual("paid-a", scanned[123]["account_alias"]) + self.assertEqual("1", scanned[123]["account_capable"]) + + def test_account_evidence_requires_an_exact_provider_process(self) -> None: + fixture = list(inventory_fixture(2)) + provider_pids = { + row["comm"]: pid + for pid, row in fixture[2].items() + if row["comm"] in {"claude", "codex"} + } + fixture[2][provider_pids["claude"]].update( + {"account_alias": " claude_a ", "account_capable": "1"} + ) + fixture[2][provider_pids["codex"]].update( + {"account_alias": "codex-paid", "account_capable": "yes"} + ) + exact = inventory_core.build_inventory(*fixture, now=1_800_000_000) + rows = {row["provider"]: row for row in exact["sessions"]} + self.assertEqual("claude_a", rows["claude"]["account_alias"]) + self.assertTrue(rows["claude"]["account_switch_capable"]) + self.assertEqual("codex-paid", rows["codex"]["account_alias"]) + self.assertFalse(rows["codex"]["account_switch_capable"]) + + unresolved_fixture = list(inventory_fixture(1, providers=("claude",))) + unresolved_fixture[2][2001].update( + {"account_alias": "no-leak", "account_capable": "1"} + ) + unresolved_fixture[1] = [] + unresolved = inventory_core.build_inventory( + *unresolved_fixture, now=1_800_000_000 + )["sessions"][0] + self.assertEqual("unknown", unresolved["provider"]) + self.assertNotIn("account_alias", unresolved) + self.assertFalse(unresolved["account_switch_capable"]) + + def test_codex_process_index_uses_each_exact_live_profile(self) -> None: + default_home = REPO / ".codex-default-fixture" + custom_home = REPO / ".codex-custom-fixture" + proc_root = REPO / ".proc-fixture" + table = { + 20: { + **process(20, 10, "codex"), + "codex_thread_id": uuid_for(20), + }, + 21: { + **process(21, 10, "codex"), + "codex_home": os.fspath(custom_home), + "codex_thread_id": uuid_for(21), + }, + } + with ( + mock.patch.object( + inventory_core, "_runtime_platform", return_value="linux" + ), + mock.patch.object( + inventory_core, "codex_open_rollouts", return_value=[] + ) as open_rollouts, + ): + inventory_core.index_codex_processes(table, proc_root, default_home) + open_rollouts.assert_has_calls( + [ + mock.call(20, proc_root, default_home, table[20]), + mock.call(21, proc_root, custom_home, table[21]), + ] + ) + with ( + mock.patch.object( + inventory_core, "_runtime_platform", return_value="darwin" + ), + mock.patch.object( + inventory_core, "codex_rollout_by_uuid", return_value=[] + ) as rollout_by_uuid, + ): + inventory_core.index_codex_processes(table, proc_root, default_home) + rollout_by_uuid.assert_has_calls( + [ + mock.call(default_home, uuid_for(20)), + mock.call(custom_home, uuid_for(21)), + ] + ) + + def test_claude_enrichment_reads_the_exact_custom_profile(self) -> None: + with tempfile.TemporaryDirectory(prefix=".claude-profile-", dir=REPO) as raw: + custom_root = Path(raw) + exact_uuid = uuid_for(25) + transcript = custom_root / "projects" / "fixture" / f"{exact_uuid}.jsonl" + transcript.parent.mkdir(parents=True) + transcript.write_text( + json.dumps( + { + "type": "ai-title", + "sessionId": exact_uuid, + "aiTitle": "Custom profile title", + } + ) + + "\n", + encoding="utf-8", + ) + sessions = custom_root / "sessions" + sessions.mkdir() + (sessions / "225.json").write_text( + json.dumps({"sessionId": exact_uuid, "nameSource": "derived"}), + encoding="utf-8", + ) + payload = [ + { + "pid": 225, + "sessionId": exact_uuid, + "_session_kit_claude_config_dir": os.fspath(custom_root), + } + ] + enriched = claude_inventory._enrich_claude_payload( + payload, + environ=os.environ, + home_factory=Path.home, + palette=inventory_core.CLAUDE_SESSION_COLORS, + transcript_signals=inventory_core.read_claude_transcript_signals, + ) + self.assertEqual("Custom profile title", enriched[0]["aiTitle"]) + self.assertEqual("derived", enriched[0]["nameSource"]) + + def test_claude_enrichment_ignores_unreadable_or_symlinked_pid_record(self) -> None: + with tempfile.TemporaryDirectory(prefix=".claude-profile-", dir=REPO) as raw: + custom_root = Path(raw) + sessions = custom_root / "sessions" + sessions.mkdir() + exact_uuid = uuid_for(26) + payload = [ + { + "pid": 226, + "sessionId": exact_uuid, + "name": "Ordinary Native Name", + "_session_kit_claude_config_dir": os.fspath(custom_root), + } + ] + record = sessions / "226.json" + record.write_text("not json", encoding="utf-8") + unreadable = claude_inventory._enrich_claude_payload( + [dict(payload[0])], + environ=os.environ, + home_factory=Path.home, + palette=inventory_core.CLAUDE_SESSION_COLORS, + transcript_signals=inventory_core.read_claude_transcript_signals, + ) + self.assertNotIn("nameSource", unreadable[0]) + + external = custom_root / "external.json" + external.write_text( + json.dumps({"sessionId": exact_uuid, "nameSource": "derived"}), + encoding="utf-8", + ) + record.unlink() + record.symlink_to(external) + symlinked = claude_inventory._enrich_claude_payload( + [dict(payload[0])], + environ=os.environ, + home_factory=Path.home, + palette=inventory_core.CLAUDE_SESSION_COLORS, + transcript_signals=inventory_core.read_claude_transcript_signals, + ) + self.assertNotIn("nameSource", symlinked[0]) + + def test_live_collection_keeps_claude_and_codex_profiles_isolated(self) -> None: + with tempfile.TemporaryDirectory(prefix=".profiles-", dir=REPO) as raw: + base = Path(raw) + account_home = base / "home" + custom_claude = base / "claude-custom" + default_codex = base / "codex-default" + custom_codex = base / "codex-custom" + account_home.mkdir() + custom_claude.mkdir() + default_codex.mkdir() + custom_codex.mkdir() + fixture = list( + inventory_fixture(4, providers=("claude", "claude", "codex", "codex")) + ) + claude_pids = sorted( + pid for pid, row in fixture[2].items() if row["comm"] == "claude" + ) + codex_pids = sorted( + pid for pid, row in fixture[2].items() if row["comm"] == "codex" + ) + fixture[2][claude_pids[0]].update( + {"account_alias": "cld-main", "account_capable": "1"} + ) + fixture[2][claude_pids[1]].update( + { + "claude_config_dir": os.fspath(custom_claude), + "account_alias": "cld-alt", + "account_capable": "0", + } + ) + fixture[2][codex_pids[0]].update( + {"account_alias": "cdx-main", "account_capable": "1"} + ) + fixture[2][codex_pids[1]].update( + { + "codex_home": os.fspath(custom_codex), + "account_alias": "cdx-alt", + "account_capable": "", + } ) + for home, exact_uuid, title in ( + (default_codex, uuid_for(3), "Default Codex title"), + (custom_codex, uuid_for(4), "Custom Codex title"), + ): + connection = sqlite3.connect(home / "state_5.sqlite") + connection.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT, cwd TEXT)" + ) + connection.execute( + "INSERT INTO threads VALUES (?, ?, ?)", + ( + exact_uuid, + title, + f"/srv/project-{3 if home == default_codex else 4}", + ), + ) + connection.commit() + connection.close() + + commands: list[tuple[str, ...]] = [] + + def command_json(**kwargs: object) -> object: + command = tuple(kwargs["default_command"]) + commands.append(command) + if "shpool" in Path(command[0]).name: + return fixture[0] + if command == ("claude", "agents", "--json"): + return fixture[1] + if command[:1] == ("env",): + return fixture[1] + self.fail(f"unexpected command: {command!r}") + with ( + mock.patch.dict( + os.environ, + {"HOME": os.fspath(account_home), "CLAUDE_CONFIG_DIR": ""}, + clear=False, + ), + mock.patch.object( + inventory_core, "_command_json", side_effect=command_json + ), + mock.patch.object( + inventory_core, "scan_process_table", return_value=fixture[2] + ), + mock.patch.object( + inventory_core, + "_payload_daemon_identity", + return_value={"pid": 10, "process_start_ticks": 100}, + ), + mock.patch.object( + inventory_core, + "_codex_paths", + return_value=(default_codex, default_codex / "state_5.sqlite"), + ), + mock.patch.object( + inventory_core, "index_codex_processes", return_value=fixture[3] + ), + mock.patch.object( + inventory_core, "recent_output_times", return_value={} + ), + ): + collected = inventory_core.collect_live(fixture[5]) + + rows = {row["shpool_id"]: row for row in collected["sessions"]} + self.assertEqual("Claude task 1", rows["main"]["native_title"]) + self.assertEqual("Claude task 2", rows["main2"]["native_title"]) + self.assertEqual("Default Codex title", rows["main3"]["native_title"]) + self.assertEqual("Custom Codex title", rows["main4"]["native_title"]) + self.assertEqual("cld-main", rows["main"]["account_alias"]) + self.assertEqual("cld-alt", rows["main2"]["account_alias"]) + self.assertEqual("cdx-main", rows["main3"]["account_alias"]) + self.assertEqual("cdx-alt", rows["main4"]["account_alias"]) + self.assertTrue(rows["main"]["account_switch_capable"]) + self.assertFalse(rows["main2"]["account_switch_capable"]) + self.assertTrue(rows["main3"]["account_switch_capable"]) + self.assertFalse(rows["main4"]["account_switch_capable"]) + self.assertIn( + ( + "env", + f"CLAUDE_CONFIG_DIR={custom_claude}", + "claude", + "agents", + "--json", + ), + commands, + ) + self.assertTrue(collected["_complete"]) -class InventoryIdentityTests(unittest.TestCase): def test_default_proc_bound_covers_peak_and_remains_bounded(self) -> None: with tempfile.TemporaryDirectory(prefix=".config-bound-", dir=REPO) as raw: missing = Path(raw) / "missing.json" @@ -310,9 +2364,7 @@ def test_default_proc_bound_covers_peak_and_remains_bounded(self) -> None: "descendants", wraps=inventory_core.descendants, ) as descend: - inventory_core.build_inventory( - *partial_fixture, now=1_800_000_000 - ) + inventory_core.build_inventory(*partial_fixture, now=1_800_000_000) self.assertEqual( inventory_core.DEFAULT_MAX_PROC_NODES, descend.call_args.kwargs["max_nodes"], @@ -340,54 +2392,825 @@ class ScanReached(RuntimeError): scan.call_args.args[1], ) - def test_duplicate_cwd_does_not_conflate_claude_and_codex(self) -> None: - fixture = list(inventory_fixture(2)) - shared = "/srv/shared" - fixture[1][0]["cwd"] = shared - fixture[2][2001]["cwd"] = shared - fixture[2][1001]["cwd"] = shared - fixture[2][2002]["cwd"] = shared - fixture[2][1002]["cwd"] = shared - fixture[4][0][uuid_for(2)]["cwd"] = shared - result = inventory_core.build_inventory(*fixture, now=1_800_000_000) - by_provider = {row["provider"]: row for row in result["sessions"]} - self.assertEqual(uuid_for(1), by_provider["claude"]["identity"]["uuid"]) - self.assertEqual(uuid_for(2), by_provider["codex"]["identity"]["uuid"]) - self.assertEqual( - ["claude", "codex"], sorted(row["provider"] for row in result["sessions"]) + def test_live_list_is_bracketed_and_revalidated_before_build(self) -> None: + fixture = inventory_fixture(0) + table = { + 10: process( + 10, + 1, + "shpool", + cmdline=["/usr/bin/shpool", "daemon"], + ) + } + events: list[str] = [] + + def scan(*args, **kwargs): + events.append("scan") + return table + + def command_json(**kwargs): + command = tuple(kwargs["default_command"]) + if "shpool" in Path(command[0]).name: + events.append("list") + return fixture[0] + events.append("provider") + return fixture[1] + + with ( + mock.patch.object( + inventory_core, "_command_json", side_effect=command_json + ), + mock.patch.object(inventory_core, "scan_process_table", side_effect=scan), + mock.patch.object( + inventory_core, + "_codex_paths", + return_value=(REPO, REPO / "missing.sqlite"), + ), + mock.patch.object( + inventory_core, "read_codex_session_index", return_value={} + ), + mock.patch.object(inventory_core, "read_codex_db", return_value=({}, {})), + mock.patch.object(inventory_core, "recent_output_times", return_value={}), + ): + inventory_core.collect_live(fixture[5]) + + self.assertEqual(["scan", "list", "scan", "provider", "scan"], events) + + def test_live_payload_binding_requires_three_matching_identities(self) -> None: + fixture = inventory_fixture(1, providers=("claude",)) + operator = {"pid": 10, "process_start_ticks": 100} + foreign = {"pid": 20, "process_start_ticks": 200} + + def command_json(**kwargs): + command = tuple(kwargs["default_command"]) + return fixture[0] if "shpool" in Path(command[0]).name else fixture[1] + + for label, observations, expected_provider in ( + ("stable", (operator, operator, operator), "claude"), + ("changed-around-list", (operator, foreign, operator), "unknown"), + ("changed-before-publish", (operator, operator, foreign), "unknown"), + ("unknown-before-publish", (operator, operator, None), "unknown"), + ): + with self.subTest(case=label): + with ( + mock.patch.object( + inventory_core, "_command_json", side_effect=command_json + ), + mock.patch.object( + inventory_core, "scan_process_table", return_value=fixture[2] + ), + mock.patch.object( + inventory_core, + "_payload_daemon_identity", + side_effect=observations, + ) as identity, + mock.patch.object( + inventory_core, + "_codex_paths", + return_value=(REPO, REPO / "missing.sqlite"), + ), + mock.patch.object( + inventory_core, "read_codex_session_index", return_value={} + ), + mock.patch.object( + inventory_core, "read_codex_db", return_value=({}, {}) + ), + mock.patch.object( + inventory_core, "recent_output_times", return_value={} + ), + ): + document = inventory_core.collect_live(fixture[5]) + self.assertEqual(3, identity.call_count) + row = document["sessions"][0] + self.assertEqual(expected_provider, row["provider"]) + if expected_provider == "unknown": + self.assertIsNone(document["daemon_generation"]) + self.assertIsNone(row["shpool_shell"]) + self.assertIs(False, row["mutation_allowed"]) + self.assertEqual("Unresolved provider session", row["title"]) + else: + self.assertEqual(operator, document["daemon_generation"]) + self.assertIs(True, row["mutation_allowed"]) + + def test_linux_scan_retains_only_unreadable_shpool_argv_candidate(self) -> None: + with tempfile.TemporaryDirectory(prefix=".proc-argv-", dir=REPO) as raw: + root = Path(raw) + for pid, comm in ((10, "shpool"), (20, "bash")): + entry = root / str(pid) + entry.mkdir() + (entry / "stat").write_text("fixture\n", encoding="utf-8") + (entry / "comm").write_text(f"{comm}\n", encoding="utf-8") + (entry / "cmdline").mkdir() + + def proc_stat(path: Path) -> tuple[int, int, int]: + return int(path.parent.name), 1, int(path.parent.name) * 10 + + with mock.patch.object( + vars(inventory_core)["_processes"], + "_readable_by_us", + return_value=False, + ): + table = vars(inventory_core)["_processes"].scan_process_table( + root, + 10, + proc_stat=proc_stat, + proc_environ=lambda path: {}, + ) + self.assertEqual([10, 20], sorted(table)) + self.assertEqual("shpool", table[10]["comm"]) + self.assertEqual([], table[10]["cmdline"]) + self.assertIs(False, table[10]["args_available"]) + self.assertIs(True, table[10]["argv_unreadable"]) + self.assertEqual("", table[20]["comm"]) + self.assertEqual(-1, table[20]["start_ticks"]) + + def test_duplicate_cwd_does_not_conflate_claude_and_codex(self) -> None: + fixture = list(inventory_fixture(2)) + shared = "/srv/shared" + fixture[1][0]["cwd"] = shared + fixture[2][2001]["cwd"] = shared + fixture[2][1001]["cwd"] = shared + fixture[2][2002]["cwd"] = shared + fixture[2][1002]["cwd"] = shared + fixture[4][0][uuid_for(2)]["cwd"] = shared + result = inventory_core.build_inventory(*fixture, now=1_800_000_000) + by_provider = {row["provider"]: row for row in result["sessions"]} + self.assertEqual(uuid_for(1), by_provider["claude"]["identity"]["uuid"]) + self.assertEqual(uuid_for(2), by_provider["codex"]["identity"]["uuid"]) + self.assertEqual( + ["claude", "codex"], sorted(row["provider"] for row in result["sessions"]) + ) + + def test_subagents_are_nested_and_not_promoted_to_sessions(self) -> None: + fixture = list(inventory_fixture(1, providers=("claude",))) + parent_uuid = uuid_for(1) + fixture[2][3001] = process( + 3001, + 2001, + "claude", + cmdline=[ + "/usr/bin/claude", + "--parent-session-id", + parent_uuid, + "--agent-name", + "Verifier", + ], + ) + fixture[1].append( + { + "pid": 3001, + "sessionId": uuid_for(9001), + "cwd": "/srv/project-1", + "kind": "subagent", + "name": "Verifier", + "status": "busy", + } + ) + result = inventory_core.build_inventory(*fixture, now=1_800_000_000) + self.assertEqual(1, len(result["sessions"])) + self.assertEqual("claude", result["sessions"][0]["provider"]) + self.assertEqual(parent_uuid, result["sessions"][0]["identity"]["uuid"]) + self.assertEqual( + ["Verifier"], [x["title"] for x in result["sessions"][0]["subagents"]] + ) + self.assertEqual([], result["outside_agents"]) + + def test_separately_managed_claude_children_record_the_exact_parent(self) -> None: + fixture = list(inventory_fixture(3, providers=("claude",))) + parent_uuid = uuid_for(1) + for index in (2, 3): + child_pid = 2000 + index + fixture[2][child_pid]["cmdline"] = [ + "/usr/bin/claude", + "--session-id", + uuid_for(index), + "--parent-session-id", + parent_uuid, + "--agent-name", + f"Builder {index}", + ] + + result = inventory_core.build_inventory(*fixture, now=1_800_000_000) + + parent_row = next( + row for row in result["sessions"] if row["identity"]["uuid"] == parent_uuid + ) + self.assertNotIn("is_subagent", parent_row) + children = [row for row in result["sessions"] if row is not parent_row] + self.assertEqual(2, len(children)) + for child in children: + self.assertTrue(child["is_subagent"]) + self.assertEqual( + {"provider": "claude", "uuid": parent_uuid}, + { + "provider": child["parent_session"]["provider"], + "uuid": child["parent_session"]["uuid"], + }, + ) + + def test_every_claude_parent_flag_shape_proves_the_process_is_a_child(self) -> None: + parent_uuid = uuid_for(1) + cases = { + "truncated separate value": ["--parent-session-id", parent_uuid[:-4]], + "truncated joined value": [f"--parent-session-id={parent_uuid[:-4]}"], + "flag without a value": ["--parent-session-id"], + "valid joined value": [f"--parent-session-id={parent_uuid}"], + } + for label, parent_args in cases.items(): + with self.subTest(label=label): + fixture = list(inventory_fixture(2, providers=("claude",))) + fixture[2][2002]["cmdline"] = [ + "/usr/bin/claude", + "--session-id", + uuid_for(2), + *parent_args, + ] + + result = inventory_core.build_inventory(*fixture, now=1_800_000_000) + by_uuid = {row["identity"]["uuid"]: row for row in result["sessions"]} + child = by_uuid[uuid_for(2)] + people, machines, orphans = classify_top_level_sessions( + result["sessions"] + ) + + self.assertTrue(child.get("is_subagent")) + self.assertNotIn(child, people) + self.assertNotIn(child, machines) + if label == "valid joined value": + self.assertEqual(parent_uuid, child["parent_session"]["uuid"]) + self.assertEqual([], orphans) + self.assertEqual(1, len(people)) + self.assertEqual(1, people[0]["active_subagent_count"]) + else: + self.assertNotIn("parent_session", child) + self.assertEqual( + [uuid_for(2)], [row["identity"]["uuid"] for row in orphans] + ) + + def test_codex_spawn_edge_and_headless_exec_classify_child_rows(self) -> None: + fixture = list(inventory_fixture(3, providers=("codex",))) + parent, child = uuid_for(1), uuid_for(2) + fixture[4] = ( + fixture[4][0], + { + parent: [ + { + "provider": "codex", + "uuid": child, + "title": "Builder", + "status": "working", + } + ] + }, + ) + fixture[2][2003]["cmdline"] = ["/usr/bin/codex", "exec", "build"] + + result = inventory_core.build_inventory(*fixture, now=1_800_000_000) + by_uuid = {row["identity"]["uuid"]: row for row in result["sessions"]} + + self.assertEqual(parent, by_uuid[child]["parent_session"]["uuid"]) + self.assertTrue(by_uuid[child]["is_subagent"]) + self.assertTrue(by_uuid[uuid_for(3)]["is_subagent"]) + self.assertNotIn("parent_session", by_uuid[uuid_for(3)]) + + def test_codex_exec_is_headless_only_at_the_real_subcommand_position( + self, + ) -> None: + cases = ( + ("profile value named exec", ["codex", "--profile", "exec"], False), + ("direct exec", ["codex", "exec", "-s", "workspace-write"], True), + ( + "leading sandbox and directory", + ["codex", "-s", "workspace-write", "-C", "/tmp/x", "exec"], + True, + ), + ( + "profile value before exec", + ["codex", "--profile", "exec", "exec"], + True, + ), + ("unknown option", ["codex", "--unknown-flag", "exec"], False), + ("joined profile value", ["codex", "--profile=exec"], False), + ( + "variadic image values", + ["codex", "--image", "image.png", "exec"], + False, + ), + ( + "malformed profile value", + ["codex", "--profile", "--no-alt-screen", "exec"], + False, + ), + ) + for label, argv, expected_headless in cases: + with self.subTest(label=label): + fixture = list(inventory_fixture(1, providers=("codex",))) + fixture[2][2001]["cmdline"] = argv + + result = inventory_core.build_inventory(*fixture, now=1_800_000_000) + session = result["sessions"][0] + people, machines, orphans = classify_top_level_sessions( + result["sessions"] + ) + + self.assertEqual(expected_headless, session.get("is_subagent", False)) + self.assertEqual([] if expected_headless else [session], people) + self.assertEqual([], machines) + self.assertEqual([session] if expected_headless else [], orphans) + + def test_machine_driven_codex_modes_never_render_as_a_person(self) -> None: + """Tour finding X16: session 87 was a live counterexample. + + A Codex nobody converses with -- a headless run, a non-interactive + review, or a server another program drives over a socket -- must fold + behind the machine count, not sit in the list as a person's window. + The wrapper cases close X13 (argv[0]=node hid the grammar entirely). + app-server stays VISIBLE by argv: it is the kit's own plumbing for + every managed Codex session, human windows included -- classifying it + machine hid two real sessions (X17). Machine-vs-human for app-server + needs driver evidence, a separate fix. + """ + vendor = ( + "/home/x/.npm-global/lib/node_modules/@openai/codex/node_modules/" + "@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex" + ) + cases = ( + ( + "app-server is the kit's own plumbing, never machine by argv", + [ + vendor, + "-c", + "check_for_update_on_startup=false", + "app-server", + "--listen", + "unix:///run/x/app.sock", + ], + False, + ), + ( + "wrapper + app-server stays visible too", + [ + "node", + "/home/x/.npm-global/bin/codex", + "-c", + "k=v", + "app-server", + "--listen", + "unix:///run/x/app.sock", + ], + False, + ), + ( + "npm wrapper + exec (X13 closed)", + ["node", "/home/x/.npm-global/bin/codex", "exec", "build"], + True, + ), + ("non-interactive review", ["codex", "review"], True), + ("mcp server mode", ["codex", "mcp-server"], True), + ( + "wrapper, no subcommand: a person's window", + ["node", "/home/x/.npm-global/bin/codex"], + False, + ), + ( + "wrapper + profile named exec stays a person", + ["node", "/home/x/.npm-global/bin/codex", "--profile", "exec"], + False, + ), + ( + "profile value named app-server stays a person", + ["codex", "--profile", "app-server"], + False, + ), + ( + "wrapper of some other script is not resolved", + ["node", "/home/x/bin/codex-lookalike", "exec"], + False, + ), + ( + "unproven management verb errs visible", + ["codex", "remote-control", "enable"], + False, + ), + ) + for label, argv, expected_machine in cases: + with self.subTest(label=label): + fixture = list(inventory_fixture(1, providers=("codex",))) + fixture[2][2001]["cmdline"] = argv + + result = inventory_core.build_inventory(*fixture, now=1_800_000_000) + session = result["sessions"][0] + people, machines, orphans = classify_top_level_sessions( + result["sessions"] + ) + + self.assertEqual(expected_machine, session.get("is_subagent", False)) + self.assertEqual([] if expected_machine else [session], people) + self.assertEqual([], machines) + self.assertEqual([session] if expected_machine else [], orphans) + + def app_server_fixture( + self, + *, + window: bool = True, + broker: bool = True, + socket: str = "/run/x/s1/app.sock", + listen: str | None = None, + server_ticks: int | None = None, + wrapper: bool = False, + ) -> list: + """One managed Codex session in App Server shape, wired as asked. + + The live estate builds every managed Codex session this way: the login + shell starts the server, the coordination broker on the same socket, + and -- for a person -- the window resumed against it. Sessions 68/80 + and the machine-driven 87 differ in exactly one of those processes. + """ + vendor = ( + "/home/x/.npm-global/lib/node_modules/@openai/codex/node_modules/" + "@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex" + ) + fixture = list(inventory_fixture(1, providers=("codex",))) + fixture[2][2001]["cmdline"] = [ + *(["node", "/home/x/.npm-global/bin/codex"] if wrapper else [vendor]), + "-c", + "check_for_update_on_startup=false", + "app-server", + "--listen", + f"unix://{socket}" if listen is None else listen, + ] + if server_ticks is not None: + fixture[2][2001]["start_ticks"] = server_ticks + if broker: + fixture[2][2100] = process( + 2100, + 1001, + "python3", + cmdline=[ + "python3", + "/srv/x/.claude/coordination/provider_broker.py", + "--socket", + socket, + "--server-pid", + "2001", + ], + ) + if window: + fixture[2][2101] = process( + 2101, + 1001, + "codex", + cmdline=[ + vendor, + "--remote", + f"unix://{socket}", + "--no-alt-screen", + "resume", + uuid_for(1), + ], + ) + return fixture + + def app_server_lists(self, fixture: list) -> tuple[dict, tuple]: + """Build the row, stamp origins from an empty store, then project it.""" + result = inventory_core.build_inventory(*fixture, now=1_800_000_000) + with tempfile.TemporaryDirectory(prefix=".origins-") as directory: + origins_state.apply_session_origins(result, state_dir=Path(directory)) + return result["sessions"][0], classify_top_level_sessions(result["sessions"]) + + def test_app_server_driven_by_a_program_is_never_a_persons_row(self) -> None: + """Tour finding X16: session 87, the Matrix lead's Codex worker. + + Its argv is byte-for-byte the argv of the operator's own sessions 68 and 80 -- + every managed Codex session runs an App Server -- so the answer is in + who holds the socket. A broker holds 87's and no window does. + """ + row, (people, machines, orphans) = self.app_server_lists( + self.app_server_fixture(window=False) + ) + + self.assertEqual([], people) + self.assertEqual([row], machines) + self.assertEqual("machine", row["origin"]) + self.assertTrue(row["machine_driven"]) + # Not a child of a session nothing can name: an unnameable child is + # projected into the orphan class, which no list and no count shows. + self.assertEqual([], orphans) + self.assertNotIn("is_subagent", row) + + def test_snapshot_binds_a_window_sighting_to_older_receipts(self) -> None: + """The orchestration captures generations before process collection.""" + with tempfile.TemporaryDirectory( + prefix=".receipt-generation-", dir=REPO + ) as raw: + state = Path(raw) + state.chmod(0o700) + markers = state / "provider-bounce" + markers.mkdir(mode=0o700) + pending = markers / "main" + receipt = markers / ( + f"main{origins_state.TAKEN_GENERATION_SEPARATOR}newgeneration" + ) + live = inventory_core.build_inventory( + *self.app_server_fixture(window=True), now=1_800_000_000 + ) + calls = 0 + + def collect(_settings): + nonlocal calls + calls += 1 + if calls == 1: + pending.write_text(f"{uuid_for(1)}\n", encoding="utf-8") + os.replace(pending, receipt) + return copy.deepcopy(live) + + config = { + "state_dir": state, + "aliases": {}, + "max_proc_nodes": 8192, + "max_proc_depth": 32, + } + with ( + mock.patch.object(inventory_core, "collect_live", side_effect=collect), + mock.patch.object(inventory_core, "_boot_id", return_value="boot-a"), + ): + stale = inventory_core.snapshot(config=config) + self.assertTrue(receipt.is_file()) + fresh = inventory_core.snapshot(config=config) + + self.assertEqual(origins_state.HUMAN, stale["sessions"][0]["origin"]) + self.assertEqual(origins_state.HUMAN, fresh["sessions"][0]["origin"]) + self.assertFalse(receipt.exists()) + + def test_stale_snapshot_preserves_a_newer_lifecycle_keep_record(self) -> None: + """A publishing pass cannot erase a keep written after its reading.""" + threshold = lifecycle_state.EXACT_RECOVERY_RETENTION_SECONDS + clock_cases = { + "older-than-threshold": time.time() - threshold - 1, + "future-after-clock-move": time.time() + threshold + 1, + } + for label, record_mtime in clock_cases.items(): + with ( + self.subTest(label=label), + tempfile.TemporaryDirectory( + prefix=".lifecycle-stale-snapshot-", dir=REPO + ) as raw, + ): + state = Path(raw) + state.chmod(0o700) + newer_session = "s20260815-170000-19" + boot_id = "11111111-2222-3333-4444-555555555555" + live = inventory_core.build_inventory( + *inventory_fixture(1), now=1_800_000_000 + ) + config = { + "state_dir": state, + "aliases": {}, + "max_proc_nodes": 8192, + "max_proc_depth": 32, + } + + def keep_after_process_reading() -> str: + lifecycle_state.record_provider_exit( + state, + session_id=newer_session, + boot_id=boot_id, + shell_pid=123, + shell_start_ticks=456, + provider="codex", + exit_code=0, + input_tracking=True, + now_monotonic_ns=100, + ) + lifecycle_state.update_state( + state, + session_id=newer_session, + boot_id=boot_id, + shell_pid=123, + shell_start_ticks=456, + event="keep", + keep=True, + ) + path = lifecycle_state.lifecycle_path(state, newer_session) + assert path is not None + os.utime(path, (record_mtime, record_mtime)) + return boot_id + + with ( + mock.patch.object( + inventory_core, "collect_live", return_value=live + ), + mock.patch.object( + inventory_core, + "_boot_id", + side_effect=keep_after_process_reading, + ), + ): + inventory_core.snapshot(config=config) + + self.assertEqual( + {newer_session}, + lifecycle_state.cleanup_protected(state, [newer_session]), + ) + + def test_the_npm_wrapper_does_not_hide_the_app_server_from_the_driver( + self, + ) -> None: + """X13 and X16 meet here: 87 was launched through the npm wrapper. + + With argv[0] = node, subcommand parsing never reached `app-server`, + so the driver was never asked about. A path that merely contains the + word codex still resolves nothing. + """ + row, (people, machines, _) = self.app_server_lists( + self.app_server_fixture(window=False, wrapper=True) + ) + self.assertEqual([], people) + self.assertEqual([row], machines) + self.assertTrue(row["machine_driven"]) + + lookalike = self.app_server_fixture(window=False, wrapper=True) + lookalike[2][2001]["cmdline"][1] = "/home/x/bin/codex-lookalike" + row, (people, machines, _) = self.app_server_lists(lookalike) + self.assertNotIn("machine_driven", row) + self.assertEqual([row], people) + self.assertEqual([], machines) + + def test_app_server_with_a_window_stays_a_visible_person(self) -> None: + """X17: this shape is sessions 68 and 80, and it must never move. + + A previous fix read `app-server` as machine by argv and hid both of + the operator's real sessions for four minutes. This case passes at that commit + too -- it has to, or the regression it guards is not being guarded. + """ + row, (people, machines, orphans) = self.app_server_lists( + self.app_server_fixture() + ) + + self.assertNotIn("machine_driven", row) + self.assertEqual("human", row["origin"]) + self.assertEqual([row], people) + self.assertEqual([], machines) + self.assertEqual([], orphans) + + def test_unreadable_app_server_evidence_keeps_the_row_visible(self) -> None: + """Failing visible: every reading short of proof is a person's row.""" + hz = os.sysconf(os.sysconf_names["SC_CLK_TCK"]) + uptime = float(Path("/proc/uptime").read_text().split()[0]) + cases = { + "no client on the socket at all": self.app_server_fixture( + window=False, broker=False + ), + "a broker on some other socket": self.app_server_fixture( + window=False, + socket="/run/x/s1/app.sock", + listen="unix:///run/x/s2/app.sock", + ), + "no listen endpoint to compare": self.app_server_fixture( + window=False, listen="" + ), + "a server still booting its window": self.app_server_fixture( + window=False, server_ticks=int(uptime * hz) + ), + } + for label, fixture in cases.items(): + with self.subTest(label=label): + row, (people, machines, orphans) = self.app_server_lists(fixture) + + self.assertNotIn("machine_driven", row) + self.assertEqual([row], people) + self.assertEqual([], machines) + self.assertEqual([], orphans) + + def test_a_window_the_reader_could_not_read_keeps_the_row_visible(self) -> None: + """The other unreadable case: the window is THERE, and unread. + + "A program drives this" is reached by NOT finding a window, so it is + only ever as strong as the reading of the tree. A process whose argv + the scan could not take -- denied, or gone between the listing and the + read -- leaves a tree that looks exactly like a tree with no window in + it. A person is sitting in that session, and the previous reading + filed it behind the machine count. + """ + fixture = self.app_server_fixture() + fixture[2][2101] = { + **fixture[2][2101], + "cmdline": [], + "comm": "", + "argv_unreadable": True, + } + + row, (people, machines, orphans) = self.app_server_lists(fixture) + + self.assertNotIn("machine_driven", row) + self.assertEqual([row], people) + self.assertEqual([], machines) + self.assertEqual([], orphans) + + def test_a_window_lost_before_its_parent_was_read_keeps_the_row_visible( + self, + ) -> None: + """A hole anywhere on the machine is a hole in every tree. + + When the FIRST stat read fails there is no parent, so the lost process + cannot be placed in any tree and no slot can hold it. It is still a + process, and it may be the very window whose absence is about to be + read as proof that a program drives this session. The scan is driven + end to end here, from a /proc where the window exits between the + listing and the first stat, so what reaches the classifier is the real + table a real dropout produces. + """ + with tempfile.TemporaryDirectory(prefix=".proc-drop-", dir=REPO) as raw: + proc_root = Path(raw) + for name in ("1001", "2001", "2100", "2101"): + entry = proc_root / name + entry.mkdir() + (entry / "stat").write_text("fixture\n", encoding="utf-8") + (entry / "comm").write_text("codex\n", encoding="utf-8") + (entry / "environ").write_bytes(b"") + (proc_root / "1001" / "cmdline").write_bytes(b"bash\0-i\0") + (proc_root / "2001" / "cmdline").write_bytes( + b"codex\0app-server\0--listen\0unix:///run/x/s1/app.sock\0" + ) + (proc_root / "2100" / "cmdline").write_bytes( + b"python3\0broker.py\0--socket\0/run/x/s1/app.sock\0" + ) + # The window, on the same socket, and about to vanish. + (proc_root / "2101" / "cmdline").write_bytes( + b"codex\0--remote\0unix:///run/x/s1/app.sock\0" + ) + with mock.patch.object( + process_inventory, + "_proc_stat", + side_effect=[ + (1001, 1, 900), + (1001, 1, 900), + (2001, 1001, 1000), + (2001, 1001, 1000), + (2100, 1001, 1000), + (2100, 1001, 1000), + OSError(errno.ESRCH, "the window exited mid-scan"), + ], + ): + scanned = process_inventory.scan_process_table( + proc_root, + 64, + proc_stat=process_inventory._proc_stat, + proc_environ=process_inventory._proc_environ, + ) + + # The tree is walked from the table exactly as collection walks it, so + # the lost pid is not merely missing from the table -- it is missing + # from the tree as well, and there is nothing left in the tree to + # notice it by. + tree = process_inventory.descendants( + 1001, + process_inventory._children_index(scanned), + max_nodes=64, + max_depth=8, ) + self.assertNotIn(2101, tree) - def test_subagents_are_nested_and_not_promoted_to_sessions(self) -> None: - fixture = list(inventory_fixture(1, providers=("claude",))) - parent_uuid = uuid_for(1) - fixture[2][3001] = process( - 3001, + verdict = collector_inventory._codex_app_server_driver( 2001, - "claude", - cmdline=[ - "/usr/bin/claude", - "--parent-session-id", - parent_uuid, - "--agent-name", - "Verifier", - ], - ) - fixture[1].append( - { - "pid": 3001, - "sessionId": uuid_for(9001), - "cwd": "/srv/project-1", - "kind": "subagent", - "name": "Verifier", - "status": "busy", - } + ["codex", "app-server", "--listen", "unix:///run/x/s1/app.sock"], + tree, + scanned, + 600, ) + + # Not "program". The broker is there and no window is visible, but the + # reading that would say so has a hole in it. + self.assertEqual("unknown", verdict) + + def test_every_app_server_row_lands_in_exactly_one_list(self) -> None: + """No row may fall out of both the lists and the counts.""" + for label, fixture in ( + ("program-driven", self.app_server_fixture(window=False)), + ("person's window", self.app_server_fixture()), + ("no client", self.app_server_fixture(window=False, broker=False)), + ): + with self.subTest(label=label): + row, (people, machines, orphans) = self.app_server_lists(fixture) + + self.assertEqual( + 1, + [row in people, row in machines, row in orphans].count(True), + ) + self.assertEqual([], orphans) + + def test_foreign_codex_spawn_edge_shape_does_not_abort_inventory(self) -> None: + fixture = list(inventory_fixture(2, providers=("codex",))) + parent, child = uuid_for(1), uuid_for(2) + fixture[4] = (fixture[4][0], {parent: [child]}) + fixture[2][2002]["cmdline"] = ["/usr/bin/codex", "exec", "build"] + result = inventory_core.build_inventory(*fixture, now=1_800_000_000) - self.assertEqual(1, len(result["sessions"])) - self.assertEqual("claude", result["sessions"][0]["provider"]) - self.assertEqual(parent_uuid, result["sessions"][0]["identity"]["uuid"]) - self.assertEqual(["Verifier"], [x["title"] for x in result["sessions"][0]["subagents"]]) - self.assertEqual([], result["outside_agents"]) + by_uuid = {row["identity"]["uuid"]: row for row in result["sessions"]} + + self.assertEqual([], by_uuid[parent]["subagents"]) + self.assertTrue(by_uuid[child]["is_subagent"]) + self.assertNotIn("parent_session", by_uuid[child]) def test_provider_roots_outside_shpool_are_visible_separately(self) -> None: fixture = list(inventory_fixture(1, providers=("codex",))) @@ -411,6 +3234,24 @@ def test_provider_roots_outside_shpool_are_visible_separately(self) -> None: self.assertEqual(outside_uuid, result["outside_agents"][0]["identity"]["uuid"]) self.assertTrue(result["outside_agents"][0]["needs_you"]) + def test_stale_claude_agent_without_live_process_is_not_outside(self) -> None: + fixture = list(inventory_fixture(1, providers=("codex",))) + fixture[1].append( + { + "pid": 9000, + "sessionId": uuid_for(9000), + "cwd": "/srv/exiting", + "kind": "interactive", + "name": "Exiting task", + "status": "idle", + } + ) + + result = inventory_core.build_inventory(*fixture, now=1_800_000_000) + + self.assertEqual([], result["outside_agents"]) + self.assertTrue(inventory_core.guard_live_inventory(result)) + def test_pid_loss_never_carries_an_old_identity_forward(self) -> None: fixture = list(inventory_fixture(1, providers=("codex",))) exact = inventory_core.build_inventory(*fixture, now=1_800_000_000) @@ -425,10 +3266,17 @@ def test_pid_reuse_changes_exact_identity_and_start_ticks(self) -> None: first = inventory_core.build_inventory(*fixture, now=1_800_000_000) new_uuid = uuid_for(777) fixture[2][2001]["start_ticks"] += 50_000 - fixture[3][2001] = [ - {"source": "cli", "id": new_uuid, "session_id": new_uuid} - ] - fixture[4] = ({new_uuid: {"id": new_uuid, "title": "Replacement", "cwd": "/srv/project-1"}}, {}) + fixture[3][2001] = [{"source": "cli", "id": new_uuid, "session_id": new_uuid}] + fixture[4] = ( + { + new_uuid: { + "id": new_uuid, + "title": "Replacement", + "cwd": "/srv/project-1", + } + }, + {}, + ) second = inventory_core.build_inventory(*fixture, now=1_800_000_001) self.assertNotEqual( first["sessions"][0]["identity"]["process_start_ticks"], @@ -439,12 +3287,15 @@ def test_pid_reuse_changes_exact_identity_and_start_ticks(self) -> None: def test_aliases_are_bound_to_provider_and_uuid_not_cwd_or_pid(self) -> None: fixture = list(inventory_fixture(2)) fixture[5]["aliases"] = { - f"claude:{uuid_for(1)}": "Lyrics audit", + f"claude:{uuid_for(1)}": "Config audit", f"codex:{uuid_for(2)}": "Deploy review", } result = inventory_core.build_inventory(*fixture, now=1_800_000_000) - titles = {(row["provider"], row["identity"]["uuid"]): row["title"] for row in result["sessions"]} - self.assertEqual("Lyrics audit", titles[("claude", uuid_for(1))]) + titles = { + (row["provider"], row["identity"]["uuid"]): row["title"] + for row in result["sessions"] + } + self.assertEqual("Config audit", titles[("claude", uuid_for(1))]) self.assertEqual("Deploy review", titles[("codex", uuid_for(2))]) def test_codex_session_index_latest_exact_name_precedes_database_title( @@ -485,15 +3336,131 @@ def test_codex_session_index_latest_exact_name_precedes_database_title( self.assertEqual("Exact menu name", threads[exact]["session_index_name"]) self.assertEqual(1, len(warnings)) fixture = list(inventory_fixture(2)) - fixture[5]["automatic_titles"] = { - f"codex:{exact}": "Automatic Menu Name" - } + fixture[5]["automatic_titles"] = {f"codex:{exact}": "Automatic Menu Name"} fixture[4] = (threads, {}) result = inventory_core.build_inventory(*fixture, now=1_800_000_000) codex = next(row for row in result["sessions"] if row["provider"] == "codex") self.assertEqual("Exact menu name", codex["native_title"]) self.assertEqual("native", codex["title_source"]) + def test_completed_codex_child_disappears_from_the_subagent_list(self) -> None: + """Completion means closure (operator ruling X20, 2026-08-14): a + finished pid-less child must not keep rendering as an open sub-agent. + This replaces the earlier retained-but-not-active behaviour.""" + with tempfile.TemporaryDirectory(prefix=".codex-child-", dir=REPO) as raw: + codex_home = Path(raw) + db = codex_home / "state_5.sqlite" + parent = uuid_for(1) + child = uuid_for(2) + rollout = codex_home / "sessions" / "rollout-child.jsonl" + rollout.parent.mkdir() + rollout.write_text( + "\n".join( + ( + json.dumps( + { + "type": "event_msg", + "payload": {"type": "task_started"}, + } + ), + json.dumps( + { + "type": "event_msg", + "payload": {"type": "task_complete"}, + } + ), + ) + ) + + "\n", + encoding="utf-8", + ) + connection = sqlite3.connect(db) + connection.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT, cwd TEXT, " + "rollout_path TEXT, agent_nickname TEXT)" + ) + connection.execute( + "CREATE TABLE thread_spawn_edges (parent_thread_id TEXT, " + "child_thread_id TEXT, status TEXT)" + ) + connection.executemany( + "INSERT INTO threads VALUES (?, ?, ?, ?, ?)", + ( + (parent, "Parent", "/srv/project-1", "", None), + (child, "Child", "/srv/project-1", str(rollout), "Verifier"), + ), + ) + connection.execute( + "INSERT INTO thread_spawn_edges VALUES (?, ?, ?)", + (parent, child, "open"), + ) + connection.commit() + connection.close() + + threads, edges = inventory_core.read_codex_db(db) + + self.assertEqual("idle", edges[parent][0]["status"]) + fixture = list(inventory_fixture(1, providers=("codex",))) + fixture[4] = (threads, edges) + result = inventory_core.build_inventory(*fixture, now=1_800_000_000) + row = result["sessions"][0] + self.assertEqual([], row["subagents"]) + self.assertEqual(0, row["active_subagent_count"]) + + def test_a_running_codex_child_still_renders_while_pid_less(self) -> None: + """Codex children executing inside the parent process have no pid of + their own mid-turn; "running" is their only liveness evidence and + must keep them on screen.""" + with tempfile.TemporaryDirectory(prefix=".codex-child-", dir=REPO) as raw: + codex_home = Path(raw) + db = codex_home / "state_5.sqlite" + parent = uuid_for(1) + child = uuid_for(2) + rollout = codex_home / "sessions" / "rollout-child.jsonl" + rollout.parent.mkdir() + rollout.write_text( + json.dumps( + { + "type": "event_msg", + "payload": {"type": "task_started"}, + } + ) + + "\n", + encoding="utf-8", + ) + connection = sqlite3.connect(db) + connection.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT, cwd TEXT, " + "rollout_path TEXT, agent_nickname TEXT)" + ) + connection.execute( + "CREATE TABLE thread_spawn_edges (parent_thread_id TEXT, " + "child_thread_id TEXT, status TEXT)" + ) + connection.executemany( + "INSERT INTO threads VALUES (?, ?, ?, ?, ?)", + ( + (parent, "Parent", "/srv/project-1", "", None), + (child, "Child", "/srv/project-1", str(rollout), "Verifier"), + ), + ) + connection.execute( + "INSERT INTO thread_spawn_edges VALUES (?, ?, ?)", + (parent, child, "open"), + ) + connection.commit() + connection.close() + + threads, edges = inventory_core.read_codex_db(db) + + self.assertEqual("working", edges[parent][0]["status"]) + fixture = list(inventory_fixture(1, providers=("codex",))) + fixture[4] = (threads, edges) + result = inventory_core.build_inventory(*fixture, now=1_800_000_000) + row = result["sessions"][0] + self.assertEqual(["Verifier"], [x["title"] for x in row["subagents"]]) + self.assertEqual(1, row["active_subagent_count"]) + def test_codex_database_prompt_never_outranks_automatic_or_context_title( self, ) -> None: @@ -514,23 +3481,15 @@ def test_codex_database_prompt_never_outranks_automatic_or_context_title( }, {}, ) - fixture[5]["automatic_titles"] = { - f"codex:{exact}": "Session Kit Updates" - } - automatic = inventory_core.build_inventory( - *fixture, now=1_800_000_000 - ) - codex = next( - row for row in automatic["sessions"] if row["provider"] == "codex" - ) + fixture[5]["automatic_titles"] = {f"codex:{exact}": "Session Kit Updates"} + automatic = inventory_core.build_inventory(*fixture, now=1_800_000_000) + codex = next(row for row in automatic["sessions"] if row["provider"] == "codex") self.assertEqual("Session Kit Updates", codex["title"]) self.assertEqual("automatic", codex["title_source"]) self.assertEqual("ready", codex["automatic_name_state"]) fixture[5]["automatic_titles"] = {} pending = inventory_core.build_inventory(*fixture, now=1_800_000_000) - codex = next( - row for row in pending["sessions"] if row["provider"] == "codex" - ) + codex = next(row for row in pending["sessions"] if row["provider"] == "codex") self.assertEqual("context", codex["title_source"]) self.assertNotIn("i need you", codex["title"].casefold()) self.assertEqual("pending", codex["automatic_name_state"]) @@ -577,6 +3536,11 @@ def test_codex_index_titles_survive_missing_or_corrupt_database(self) -> None: "scan_process_table", return_value=fixture[2], ), + mock.patch.object( + inventory_core, + "_payload_daemon_identity", + return_value={"pid": 10, "process_start_ticks": 100}, + ), mock.patch.object( inventory_core, "_codex_paths", @@ -645,7 +3609,9 @@ def test_title_precedence_and_context_fallback_are_explicit(self) -> None: self.assertNotIn("/srv/", title) self.assertNotIn(exact[:8], title) - def test_operational_id_policy_preserves_raw_ids_and_bounds_display_only(self) -> None: + def test_operational_id_policy_preserves_raw_ids_and_bounds_display_only( + self, + ) -> None: safe = ( "main", "main10", @@ -654,7 +3620,9 @@ def test_operational_id_policy_preserves_raw_ids_and_bounds_display_only(self) - ) for raw in safe: with self.subTest(raw=raw): - self.assertEqual((True, None), inventory_core.shpool_id_mutation_policy(raw)) + self.assertEqual( + (True, None), inventory_core.shpool_id_mutation_policy(raw) + ) rejected = { "main-template": "template", @@ -709,7 +3677,19 @@ def test_operational_id_policy_preserves_raw_ids_and_bounds_display_only(self) - ) ) - def test_process_scan_discards_pid_when_pre_and_post_stat_identity_differs(self) -> None: + def test_process_scan_keeps_a_changed_pid_but_publishes_nothing_about_it( + self, + ) -> None: + """A pid that changed identity mid-scan is unread, not absent. + + Nothing it said may be published -- the fields were read from whatever + it was before -- but the pid itself must stay in the table. Dropping + it told every reader "there is no such process", and a reader asking + whether anything in a tree holds a socket then answers "no" about a + process it never saw, which is how a person's window becomes a + machine row. The start tick is impossible on purpose, so no identity + check can confirm against it. + """ with tempfile.TemporaryDirectory(prefix=".proc-race-", dir=REPO) as raw: proc_root = Path(raw) entry = proc_root / "123" @@ -724,7 +3704,11 @@ def test_process_scan_discards_pid_when_pre_and_post_stat_identity_differs(self) side_effect=[(123, 10, 1000), (123, 10, 2000)], ): scanned = inventory_core.scan_process_table(proc_root, 64) - self.assertEqual({}, scanned) + self.assertEqual([123], sorted(scanned)) + self.assertTrue(scanned[123]["argv_unreadable"]) + self.assertEqual([], scanned[123]["cmdline"]) + self.assertEqual("", scanned[123]["comm"]) + self.assertEqual(-1, scanned[123]["start_ticks"]) def test_codex_rollout_read_discards_metadata_on_pid_identity_change(self) -> None: with tempfile.TemporaryDirectory(prefix=".codex-race-", dir=REPO) as raw: @@ -796,32 +3780,343 @@ def test_codex_metadata_is_read_through_proc_fd_not_reopened_pathname(self) -> N real_fstat = os.fstat opened_paths: list[Path] = [] - def open_spy(path: object, flags: int, *args: object, **kwargs: object) -> int: + def open_spy( + path: object, flags: int, *args: object, **kwargs: object + ) -> int: candidate = Path(os.fspath(path)) opened_paths.append(candidate) if candidate == rollout: raise AssertionError("rollout pathname must never be reopened") return real_open(path, flags, *args, **kwargs) + with ( + mock.patch.object( + inventory_core, + "_expected_proc_identity", + return_value=stable, + ), + mock.patch.object( + inventory_core.os, + "open", + side_effect=open_spy, + ), + mock.patch.object( + inventory_core.os, + "fstat", + wraps=real_fstat, + ) as fstat_spy, + ): + metadata = inventory_core.codex_open_rollouts( + 654, proc_root, codex_home, expected_process + ) + self.assertEqual(uuid_for(55), metadata[0]["session_id"]) + self.assertEqual([proc_descriptor], opened_paths) + self.assertGreaterEqual(fstat_spy.call_count, 2) + + def test_app_server_open_rollout_is_exact_managed_codex_identity(self) -> None: + fixture = list(inventory_fixture(1, providers=("codex",))) + exact = uuid_for(1) + app_pid = 2001 + fixture[2][app_pid]["cmdline"] = [ + "/usr/bin/codex", + "-c", + "check_for_update_on_startup=false", + "app-server", + "--listen", + "unix:///run/user/1000/session-kit/app.sock", + ] + fixture[3][app_pid] = [ + { + "source": "vscode", + "originator": "example_coordination_broker", + "id": exact, + "session_id": exact, + "_turn_state": "idle", + } + ] + fixture[4][0][exact]["title"] = "How deep is deepest ocean" + fixture[4][0][exact]["first_user_message"] = "how deep is deepest ocean" + fixture[4][0][exact]["session_index_name"] = "How deep is deepest ocean" + fixture[5]["colors"] = {f"codex:{exact}": "sky"} + + result = inventory_core.build_inventory(*fixture, now=1_800_000_000) + + row = result["sessions"][0] + self.assertEqual("codex", row["provider"]) + self.assertEqual(exact, row["identity"]["uuid"]) + self.assertEqual(app_pid, row["identity"]["pid"]) + self.assertEqual( + "native Codex PID open exact rollout", + row["identity"]["provenance"], + ) + self.assertEqual("How deep is deepest ocean", row["title"]) + self.assertEqual("sky", row["display_color"]) + self.assertEqual("idle", row["agent_status"]) + + def test_app_server_refresh_targets_one_matching_remote_tui(self) -> None: + socket = "unix:///run/user/1000/session-kit/app.sock" + table = { + 100: process(100, 10, "bash"), + 200: process( + 200, + 100, + "codex", + cmdline=["/usr/bin/codex", "app-server", "--listen", socket], + ), + 300: process( + 300, + 100, + "codex", + cmdline=[ + "/usr/bin/codex", + "--remote", + socket, + "--no-alt-screen", + ], + ), + } + self.assertEqual( + (300, 3000), + inventory_core.codex_refresh_target(table, 100, 1000, 200, 2000), + ) + + table[301] = process( + 301, + 100, + "codex", + cmdline=["/usr/bin/codex", "--remote", socket, "--no-alt-screen"], + ) + with self.assertRaisesRegex( + inventory_core.CollectionError, "expected one remote Codex TUI" + ): + inventory_core.codex_refresh_target(table, 100, 1000, 200, 2000) + + def test_direct_codex_refresh_targets_provider_generation(self) -> None: + table = { + 100: process(100, 10, "bash"), + 200: process(200, 100, "codex", cmdline=["/usr/bin/codex"]), + } + self.assertEqual( + (200, 2000), + inventory_core.codex_refresh_target(table, 100, 1000, 200, 2000), + ) + + def test_refresh_target_platform_command_has_no_single_pid_assumption(self) -> None: + table = { + 100: process(100, 10, "bash"), + 200: process(200, 100, "codex", cmdline=["/usr/bin/codex"]), + } + args = argparse.Namespace( + platform_action="codex-refresh-target", + shell_pid=100, + shell_generation=1000, + provider_pid=200, + provider_generation=2000, + ) + output = io.StringIO() + with ( + mock.patch.object( + inventory_core, "_require_supported_platform", return_value="linux" + ), + mock.patch.object( + inventory_core, "platform_process_table", return_value=table + ), + contextlib.redirect_stdout(output), + ): + self.assertEqual(0, inventory_core._platform_command(args)) + self.assertEqual("200\t2000", output.getvalue().strip()) + + def test_editor_rollout_requires_app_server_and_one_exact_thread(self) -> None: + fixture = list(inventory_fixture(1, providers=("codex",))) + exact = uuid_for(1) + app_pid = 2001 + editor_meta = { + "source": "vscode", + "id": exact, + "session_id": exact, + } + fixture[3][app_pid] = [editor_meta] + + direct = inventory_core.build_inventory(*fixture, now=1_800_000_000) + self.assertEqual("unknown", direct["sessions"][0]["provider"]) + self.assertIsNone(direct["sessions"][0]["identity"]["uuid"]) + + fixture[2][app_pid]["cmdline"] = [ + "/usr/bin/codex", + "app-server", + "--listen", + "unix:///run/user/1000/session-kit/app.sock", + ] + second = uuid_for(2) + fixture[3][app_pid].append( + {"source": "vscode", "id": second, "session_id": second} + ) + ambiguous = inventory_core.build_inventory(*fixture, now=1_800_000_000) + self.assertEqual("unknown", ambiguous["sessions"][0]["provider"]) + self.assertIsNone(ambiguous["sessions"][0]["identity"]["uuid"]) + + +class ChurnedCensusIdentityTests(unittest.TestCase): + """One unrelated process dying mid-scan must not unresolve any session. + + ``scan_process_table`` marks the whole table incomplete whenever any + process exits between readdir and stat, which on a busy host is most + scans. A session whose own subtree yielded exactly one provider claim has + nothing to adjudicate, so that hole elsewhere on the machine is not + evidence against it; only the multi-claim rival adjudication, which rests + on "no other claimant exists", still demands the full census. + """ + + @staticmethod + def churned(table: dict) -> process_inventory.ProcessTable: + holed = process_inventory.ProcessTable(table) + holed.complete = False + return holed + + def test_a_sole_provider_claim_survives_a_churned_census(self) -> None: + fixture = list(inventory_fixture(2)) + fixture[2] = self.churned(fixture[2]) + + result = inventory_core.build_inventory( + *fixture, + now=1_800_000_000, + daemon_binding={"pid": 10, "process_start_ticks": 100}, + ) + + first, second = result["sessions"][0], result["sessions"][1] + self.assertEqual("claude", first["provider"]) + self.assertEqual(uuid_for(1), first["identity"]["uuid"]) + self.assertEqual("codex", second["provider"]) + self.assertEqual(uuid_for(2), second["identity"]["uuid"]) + + def test_aged_child_shell_and_worker_survive_a_churned_census(self) -> None: + """Known children do not disappear because another process vanished.""" + + def collect(*, churned: bool) -> list[dict]: + fixture = list(inventory_fixture(1, providers=("claude",))) + parent_uuid = uuid_for(1) + fixture[2][3000] = process(3000, 2001, "bash") + fixture[2][3001] = process( + 3001, + 2001, + "claude", + cmdline=[ + "/usr/bin/claude", + "--parent-session-id", + parent_uuid, + "--agent-name", + "Verifier", + ], + ) + fixture[2][3002] = process(3002, 2001, "bash") + fixture[1].append( + { + "pid": 3001, + "sessionId": uuid_for(9001), + "cwd": "/srv/project-1", + "kind": "subagent", + "name": "Verifier", + "status": "busy", + } + ) + if churned: + fixture[2] = self.churned(fixture[2]) + ages = { + 1001: 99_999, # The managed root is not a child shell. + 2001: 200, + 3000: 2 * 3600 + 11 * 60, + 3001: 3600, + 3002: 3599, + } with mock.patch.object( - inventory_core, - "_expected_proc_identity", - return_value=stable, - ), mock.patch.object( - inventory_core.os, - "open", - side_effect=open_spy, - ), mock.patch.object( - inventory_core.os, - "fstat", - wraps=real_fstat, - ) as fstat_spy: - metadata = inventory_core.codex_open_rollouts( - 654, proc_root, codex_home, expected_process + collector_inventory, + "_process_age", + side_effect=lambda pid, _table, _now: ages.get(pid), + ): + result = inventory_core.build_inventory( + *fixture, + now=1_800_000_000, + daemon_binding={"pid": 10, "process_start_ticks": 100}, ) - self.assertEqual(uuid_for(55), metadata[0]["session_id"]) - self.assertEqual([proc_descriptor], opened_paths) - self.assertGreaterEqual(fstat_spy.call_count, 2) + return result["sessions"][0]["aged_children"] + + expected = [ + {"kind": "shell", "title": "bash", "age_seconds": 7860}, + { + "kind": "worker", + "provider": "claude", + "title": "Verifier", + "age_seconds": 3600, + }, + ] + self.assertEqual(expected, collect(churned=False)) + self.assertEqual(expected, collect(churned=True)) + + def test_a_pidless_codex_edge_has_no_invented_process_age(self) -> None: + fixture = list(inventory_fixture(1, providers=("codex",))) + fixture[4] = ( + fixture[4][0], + { + uuid_for(1): [ + { + "provider": "codex", + "uuid": uuid_for(2), + "pid": None, + "title": "Verifier", + "status": "working", + } + ] + }, + ) + + with mock.patch.object( + collector_inventory, "_process_age", return_value=24 * 3600 + ): + result = inventory_core.build_inventory(*fixture, now=1_800_000_000) + + row = result["sessions"][0] + self.assertEqual(1, row["active_subagent_count"]) + self.assertEqual([], row["aged_children"]) + self.assertEqual( + "Codex worker", + collector_inventory._safe_worker_title( + { + "provider": "codex", + "uuid": uuid_for(2), + "title": uuid_for(2)[:8], + }, + None, + ), + ) + + def test_lineage_owner_returns_the_sole_claim_despite_census_churn( + self, + ) -> None: + table = self.churned( + { + 100: {"pid": 100, "ppid": 1, "start_ticks": 50}, + 200: {"pid": 200, "ppid": 100, "start_ticks": 60}, + } + ) + claim = ("claude-agent", 200, uuid_for(9)) + self.assertEqual( + (claim, []), + collector_inventory._lineage_owner(100, [claim], table), + ) + + def test_rival_adjudication_still_requires_a_complete_census(self) -> None: + table = self.churned( + { + 100: {"pid": 100, "ppid": 1, "start_ticks": 50}, + 200: {"pid": 200, "ppid": 100, "start_ticks": 60}, + 300: {"pid": 300, "ppid": 200, "start_ticks": 70}, + } + ) + claims = [ + ("claude-agent", 200, uuid_for(9)), + ("codex", 300, uuid_for(8)), + ] + self.assertIsNone(collector_inventory._lineage_owner(100, claims, table)) class CodexTurnStateTests(unittest.TestCase): @@ -902,6 +4197,42 @@ def test_new_structured_turn_retires_unanswered_prior_question(self) -> None: aborted = self._event("event_msg", {"type": "turn_aborted"}) self.assertEqual("idle", self._state([question, aborted])) + def test_completed_turn_ending_in_a_prose_question_is_reply_optional( + self, + ) -> None: + started = self._event("event_msg", {"type": "task_started"}) + asking = self._event( + "response_item", + { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "I checked both configs.\n\nWhich hostname should I use?", + } + ], + }, + ) + completed = self._event("event_msg", {"type": "task_complete"}) + # A finished turn whose last words ask the human something is a soft + # wait, not idle, but never the hard "needs your reply". + self.assertEqual("reply optional", self._state([started, asking, completed])) + # A statement ending the turn stays idle. + stating = self._event( + "response_item", + { + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "Done. Both configs match."} + ], + }, + ) + self.assertEqual("idle", self._state([started, stating, completed])) + # A NEW turn clears the stale question. + self.assertEqual("working", self._state([started, asking, completed, started])) + def test_timed_question_is_optional_and_never_blocking(self) -> None: state = self._state( [ @@ -1020,12 +4351,8 @@ def test_state_propagates_to_managed_and_outside_inventory(self) -> None: with self.subTest(state=state): fixture = list(inventory_fixture(1, providers=("codex",))) fixture[3][2001][0]["_turn_state"] = state - managed = inventory_core.build_inventory( - *fixture, now=1_800_000_000 - ) - self.assertEqual( - state, managed["sessions"][0]["agent_status"] - ) + managed = inventory_core.build_inventory(*fixture, now=1_800_000_000) + self.assertEqual(state, managed["sessions"][0]["agent_status"]) self.assertEqual( expected_needs_you, managed["sessions"][0]["needs_you"] ) @@ -1039,9 +4366,7 @@ def test_state_propagates_to_managed_and_outside_inventory(self) -> None: fixture[5], now=1_800_000_000, ) - self.assertEqual( - state, outside["outside_agents"][0]["agent_status"] - ) + self.assertEqual(state, outside["outside_agents"][0]["agent_status"]) self.assertEqual( expected_needs_you, outside["outside_agents"][0]["needs_you"], @@ -1056,11 +4381,11 @@ def test_waiting_count_includes_only_blocking_reply_state(self) -> None: ] } output = io.StringIO() - with mock.patch.object( - inventory_core, "load_config", return_value={} - ), mock.patch.object( - inventory_core, "snapshot", return_value=snapshot - ), contextlib.redirect_stdout(output): + with ( + mock.patch.object(inventory_core, "load_config", return_value={}), + mock.patch.object(inventory_core, "snapshot", return_value=snapshot), + contextlib.redirect_stdout(output), + ): return_code = inventory_core.main(["waiting-count"]) self.assertEqual(0, return_code) self.assertEqual("1\n", output.getvalue()) @@ -1151,14 +4476,15 @@ def test_shell_less_orphan_is_quarantined_without_blocking_exact_rows( self.assertIsNone(orphan["terminal_number"]) self.assertIs(orphan["mutation_allowed"], False) - self.assertIs(inventory_core.lookup(inventory, "1"), exact) self.assertIsNone(inventory_core.lookup(inventory, "2")) + self.assertIs(inventory_core.lookup(inventory, "1"), exact) self.assertIs( inventory_core.lookup(inventory, orphan["shpool_id_raw"]), orphan, ) rendered = inventory_core.render_inventory(inventory) - self.assertRegex(rendered, r"(?m)^\s+-\s+Unresolved provider session") + self.assertNotIn("Unresolved provider session", rendered) + self.assertIn("1 session: 1 ready", rendered) with tempfile.TemporaryDirectory(prefix=".mixed-input-", dir=REPO) as raw: path = Path(raw) / "inventory.json" @@ -1167,10 +4493,163 @@ def test_shell_less_orphan_is_quarantined_without_blocking_exact_rows( self.assertEqual(1, loaded["sessions"][0]["terminal_number"]) self.assertIsNone(loaded["sessions"][1]["terminal_number"]) - def test_only_exact_inert_orphan_shape_can_skip_numbering(self) -> None: - base = inventory_core.build_inventory( + def test_recycled_numbers_respect_the_quarantine_and_continuity(self) -> None: + quarantine = inventory_core.TERMINAL_NUMBER_QUARANTINE_SECONDS + now = 1_800_000_000.0 + # Pass 1: terminal 1 is an ordinary number now; sessions take 1 and 2. + inventory = inventory_core.build_inventory( *inventory_fixture(2), now=1_800_000_000 ) + retired: dict[int, float] = {} + registry = inventory_core.apply_terminal_numbers( + inventory, + inventory_core._empty_terminal_registry("boot-a"), + boot_id="boot-a", + allocate=True, + retired=retired, + current_time=now, + ) + self.assertEqual( + [1, 2], [item["terminal_number"] for item in inventory["sessions"]] + ) + self.assertEqual({}, retired) + + # Pass 2: session 1 died, ordinary number 1 enters quarantine. + survivor = inventory_core.build_inventory( + *inventory_fixture(2), now=1_800_000_100 + ) + survivor["sessions"] = [survivor["sessions"][1]] + registry = inventory_core.apply_terminal_numbers( + survivor, + registry, + boot_id="boot-a", + allocate=True, + retired=retired, + current_time=now + 100, + ) + self.assertEqual(2, survivor["sessions"][0]["terminal_number"]) + self.assertEqual({1: now + 100}, retired) + + # Pass 3, inside the quarantine: a NEW session must not take 1. + fresh = inventory_core.build_inventory(*inventory_fixture(3), now=1_800_000_200) + fresh["sessions"] = fresh["sessions"][1:] + registry = inventory_core.apply_terminal_numbers( + fresh, + registry, + boot_id="boot-a", + allocate=True, + retired=retired, + current_time=now + 200, + ) + numbers = { + item["shpool_id_raw"]: item["terminal_number"] for item in fresh["sessions"] + } + self.assertEqual(2, numbers["main2"]) + self.assertEqual(3, numbers["main3"]) + self.assertIn(1, retired) + + # Pass 4: the dead conversation RECOVERS inside the window, its AI + # binding hands its old number back and the retirement clears. + revived = inventory_core.build_inventory( + *inventory_fixture(3), now=1_800_000_300 + ) + registry = inventory_core.apply_terminal_numbers( + revived, + registry, + boot_id="boot-a", + allocate=True, + retired=retired, + current_time=now + 300, + ) + revived_numbers = { + item["shpool_id_raw"]: item["terminal_number"] + for item in revived["sessions"] + } + self.assertEqual(1, revived_numbers["main"]) + self.assertNotIn(1, retired) + + # Pass 5: dead again, and the quarantine EXPIRES, the number frees, + # its bindings prune, and the next new session takes the lowest gap. + after = inventory_core.build_inventory(*inventory_fixture(3), now=1_800_000_400) + after["sessions"] = after["sessions"][1:] + registry = inventory_core.apply_terminal_numbers( + after, + registry, + boot_id="boot-a", + allocate=True, + retired=retired, + current_time=now + 400, + ) + self.assertEqual({1: now + 400}, {n: retired[n] for n in retired if n == 1}) + expired_pass = inventory_core.build_inventory( + *inventory_fixture(4), now=1_800_000_500 + ) + expired_pass["sessions"] = expired_pass["sessions"][1:] + registry = inventory_core.apply_terminal_numbers( + expired_pass, + registry, + boot_id="boot-a", + allocate=True, + retired=retired, + current_time=now + 400 + quarantine + 1, + ) + expired_numbers = { + item["shpool_id_raw"]: item["terminal_number"] + for item in expired_pass["sessions"] + } + # main4 is new; 1 finished quarantine and is the lowest ordinary number. + self.assertEqual(1, expired_numbers["main4"]) + self.assertNotIn(1, retired) + self.assertNotIn(f"ai:claude:{uuid_for(1)}", registry["bindings"]) + # Legacy invariant for pinned releases stays intact. + self.assertGreater(registry["next_number"], max(registry["bindings"].values())) + + def test_retirement_ledger_round_trip_and_boot_reset(self) -> None: + with tempfile.TemporaryDirectory() as raw: + path = Path(raw) / "terminal-numbers-retired.json" + payload = inventory_core._terminal_retirement_payload( + {3: 1_800_000_000.0, 1: 1_700_000_000.5}, "boot-a" + ) + path.write_text(json.dumps(payload), encoding="utf-8") + os.chmod(path, 0o600) + self.assertEqual( + {1: 1_700_000_000.5, 3: 1_800_000_000.0}, + inventory_core._read_terminal_retirements(path, "boot-a"), + ) + # A different boot voids the ledger (numbers restart anyway). + self.assertEqual( + {}, inventory_core._read_terminal_retirements(path, "boot-b") + ) + # Junk keys/values never surface. + path.write_text( + json.dumps( + { + "schema_version": payload["schema_version"], + "boot_id": "boot-a", + "retired": {"x": 5, "2": True, "4": -1, "5": 9.5}, + } + ), + encoding="utf-8", + ) + self.assertEqual( + {5: 9.5}, + inventory_core._read_terminal_retirements(path, "boot-a"), + ) + + def test_a_row_that_cannot_be_numbered_never_takes_the_estate_with_it( + self, + ) -> None: + """One unprovable session used to refuse the WHOLE collection. + + Numbering raised, the census fell back to cache, and the guard + snapshot every mutation depends on stopped answering: no close, no + switch, no repair, and no launch, because arming a launch needs a + snapshot that is live, fresh and warning-free. The operator's whole + machine went read-only over one row, with a message that named no + session. Each shape below is a row whose generation cannot be proven; + each one is now quarantined and named, and the estate keeps working. + """ + base = inventory_core.build_inventory(*inventory_fixture(2), now=1_800_000_000) self._orphan(base["sessions"][1]) def known_provider(item: dict) -> None: @@ -1218,16 +4697,83 @@ def recoverable(item: dict) -> None: with self.subTest(case=label): inventory = copy.deepcopy(base) mutate(inventory["sessions"][1]) - with self.assertRaisesRegex( - inventory_core.CollectionError, - "lacks an exact generation", - ): - inventory_core.apply_terminal_numbers( - inventory, - inventory_core._empty_terminal_registry("boot-a"), - boot_id="boot-a", - allocate=True, - ) + inventory_core.apply_terminal_numbers( + inventory, + inventory_core._empty_terminal_registry("boot-a"), + boot_id="boot-a", + allocate=True, + ) + unprovable = inventory["sessions"][1] + # The row that cannot be proven is named and made + # unactionable, not numbered, and it does not take the rest + # of the estate down with it. + self.assertIsNone(unprovable["terminal_number"]) + self.assertIs(False, unprovable["mutation_allowed"]) + self.assertIn( + unprovable["mutation_rejection_reason"], + ("unprovable-generation", "missing-shell-generation"), + ) + self.assertNotIn("_terminal_identity_hint", unprovable) + healthy = inventory["sessions"][0] + self.assertIsInstance(healthy["terminal_number"], int) + self.assertGreater(healthy["terminal_number"], 0) + # And the gate every mutation passes through still opens: the + # guard snapshot is what `sk_capture_session_generation` waits + # for before arming a launch, and what every close, switch and + # repair loads first. + self.assertTrue(inventory_core.guard_live_inventory(inventory)) + + def test_an_unprovable_row_still_lets_a_new_launch_arm(self) -> None: + """The arm's own gate, in the state that used to close it. + + `sk_capture_session_generation`, the step `sp new` runs between + creating a session and arming its launch record, refuses to arm + unless the guard snapshot is live, unstale and warning-free, and then + resolves the new session in it with mutation allowed. Before this fix + one unprovable row anywhere made that snapshot unavailable, so the + arm never happened and the session sat as a shell with no provider. + """ + inventory = inventory_core.build_inventory( + *inventory_fixture(2), now=1_800_000_000 + ) + # One row nobody can prove: a live-looking session with no shell. + broken = inventory["sessions"][1] + broken["shpool_shell"] = {"pid": 123, "process_start_ticks": None} + inventory_core.apply_terminal_numbers( + inventory, + inventory_core._empty_terminal_registry("boot-a"), + boot_id="boot-a", + allocate=True, + ) + self.assertTrue(inventory_core.guard_live_inventory(inventory)) + self.assertFalse(inventory.get("stale")) + self.assertEqual([], list(inventory.get("warnings", []))) + # The session an arm would resolve is numbered and actionable. + healthy = inventory["sessions"][0] + self.assertIsInstance(healthy["terminal_number"], int) + self.assertIs(True, healthy["mutation_allowed"]) + # And the unprovable one is refused by name rather than by silence. + self.assertEqual("unprovable-generation", broken["mutation_rejection_reason"]) + + def test_unprovable_generation_stays_on_the_board(self) -> None: + """Only a PROVEN missing shell quarantines; doubt stays visible. + + Two review lanes demonstrated a real, actionable human session + vanishing from the shell and curses lists because the shared + predicate also matched unprovable-generation. The kit's rule is the + opposite: what it cannot judge, a person must be able to see.""" + unprovable = { + "terminal_number": None, + "mutation_allowed": False, + "mutation_rejection_reason": "unprovable-generation", + } + proven_dead = { + "terminal_number": None, + "mutation_allowed": False, + "mutation_rejection_reason": "missing-shell-generation", + } + self.assertFalse(session_is_unavailable(unprovable)) + self.assertTrue(session_is_unavailable(proven_dead)) def test_orphan_quarantine_marker_is_required_by_guard_and_frozen_input( self, @@ -1298,9 +4844,7 @@ def test_snapshot_writes_live_exact_rows_alongside_quarantined_orphan( self.assertEqual([], result["warnings"]) self.assertEqual(1, result["sessions"][0]["terminal_number"]) self.assertIsNone(result["sessions"][1]["terminal_number"]) - stored = json.loads( - (state / "inventory.json").read_text(encoding="utf-8") - ) + stored = json.loads((state / "inventory.json").read_text(encoding="utf-8")) self.assertEqual(result, stored) def test_provisional_generation_promotes_without_renumbering(self) -> None: @@ -1332,9 +4876,7 @@ def test_provisional_generation_promotes_without_renumbering(self) -> None: inventory, registry, boot_id="boot-a", allocate=True ) self.assertEqual(1, item["terminal_number"]) - self.assertEqual( - 1, promoted["bindings"][f"ai:claude:{exact_identity['uuid']}"] - ) + self.assertEqual(1, promoted["bindings"][f"ai:claude:{exact_identity['uuid']}"]) item["shpool_id"] = item["shpool_id_raw"] = "main20" item["started_at_unix_ms"] += 100 @@ -1379,9 +4921,7 @@ def test_exact_recovery_overrides_a_new_generation_provisional_number( ) self.assertEqual(1, item["terminal_number"]) self.assertEqual(3, recovered["next_number"]) - generation = inventory_core._terminal_generation_key( - inventory, item, "boot-a" - ) + generation = inventory_core._terminal_generation_key(inventory, item, "boot-a") self.assertEqual(1, recovered["bindings"][generation]) self.assertEqual( [generation], @@ -1445,9 +4985,7 @@ def test_unrelated_uuid_replaces_same_generation_with_a_new_number( updated = inventory_core.apply_terminal_numbers( inventory, registry, boot_id="boot-a", allocate=True ) - generation = inventory_core._terminal_generation_key( - inventory, item, "boot-a" - ) + generation = inventory_core._terminal_generation_key(inventory, item, "boot-a") self.assertEqual(2, item["terminal_number"]) self.assertEqual(2, updated["bindings"][generation]) @@ -1477,9 +5015,7 @@ def test_registry_rejects_duplicate_generations_and_active_exact_duplicates( boot_id="boot-a", allocate=True, ) - inventory["sessions"][1]["provider"] = inventory["sessions"][0][ - "provider" - ] + inventory["sessions"][1]["provider"] = inventory["sessions"][0]["provider"] inventory["sessions"][1]["identity"] = copy.deepcopy( inventory["sessions"][0]["identity"] ) @@ -1559,26 +5095,16 @@ def test_registry_corruption_and_same_boot_disappearance_fail_closed( encoding="utf-8", ) epoch.chmod(0o600) - with self.assertRaisesRegex( - inventory_core.CollectionError, "disappeared" - ): - inventory_core._read_terminal_registry( - registry, "boot-a", epoch - ) + with self.assertRaisesRegex(inventory_core.CollectionError, "disappeared"): + inventory_core._read_terminal_registry(registry, "boot-a", epoch) registry.write_text("{broken", encoding="utf-8") registry.chmod(0o600) - with self.assertRaisesRegex( - inventory_core.CollectionError, "invalid JSON" - ): - inventory_core._read_terminal_registry( - registry, "boot-a", epoch - ) + with self.assertRaisesRegex(inventory_core.CollectionError, "invalid JSON"): + inventory_core._read_terminal_registry(registry, "boot-a", epoch) registry.unlink() os.symlink(epoch, registry) with self.assertRaises(inventory_core.CollectionError): - inventory_core._read_terminal_registry( - registry, "boot-a", epoch - ) + inventory_core._read_terminal_registry(registry, "boot-a", epoch) def test_registry_receipt_requires_exact_schema_but_allows_old_boot( self, @@ -1614,9 +5140,7 @@ def test_registry_receipt_requires_exact_schema_but_allows_old_boot( epoch.chmod(0o600) self.assertEqual( inventory_core._empty_terminal_registry("boot-a"), - inventory_core._read_terminal_registry( - registry, "boot-a", epoch - ), + inventory_core._read_terminal_registry(registry, "boot-a", epoch), ) def test_verified_new_boot_resets_monotonic_registry(self) -> None: @@ -1629,9 +5153,7 @@ def test_verified_new_boot_resets_monotonic_registry(self) -> None: }, } reset = inventory_core._validate_terminal_registry(registry, "boot-b") - self.assertEqual( - inventory_core._empty_terminal_registry("boot-b"), reset - ) + self.assertEqual(inventory_core._empty_terminal_registry("boot-b"), reset) class AutomaticTitleTests(unittest.TestCase): @@ -1667,13 +5189,9 @@ def _caller_fixture( }, {}, ) - fixture[3] = { - 2001: [{"source": "cli", "id": exact, "session_id": exact}] - } + fixture[3] = {2001: [{"source": "cli", "id": exact, "session_id": exact}]} fixture[2][3001] = process(3001, 2001, "python3") - inventory = inventory_core.build_inventory( - *fixture, now=1_800_000_000 - ) + inventory = inventory_core.build_inventory(*fixture, now=1_800_000_000) environment = { "SHPOOL_SESSION_NAME": "main", "CODEX_THREAD_ID": exact, @@ -1683,6 +5201,62 @@ def _caller_fixture( } return inventory, fixture[2], environment, 3001 + def test_self_name_revalidation_never_collects_under_the_name_lock(self) -> None: + """The 2026-08-17 estate jam, as a call-count contract. + + ``mutate_self_name`` runs ``revalidate`` inside the name-store locks. + The old revalidate took a fresh full collection there, and collecting + can re-acquire config.lock on a second descriptor, a process waiting + on its own flock, which no other process can break; every collector on + the machine queued behind it. The contract: one snapshot total, taken + before any lock; revalidation re-reads only the process table. + """ + from lib.sessionkit_inventory import self_name as low_level + + snapshots: list[dict] = [] + tables: list[object] = [] + + def fake_snapshot(**kwargs: object) -> dict: + snapshots.append(dict(kwargs)) + return {"sessions": []} + + def fake_table(root: object, cap: int) -> dict: + tables.append(root) + return {} + + def fake_mutate( + config: dict, provider: str, uuid: str, title: str, *, revalidate=None + ) -> dict: + self.assertIsNotNone(revalidate) + revalidate() # exactly what names.py does inside its locks + return {"aliases": {}, "automatic_titles": {}} + + result = low_level.self_name_automatic_title( + {"max_proc_nodes": 10}, + "Session Kit Updates", + environ={}, + default_max_proc_nodes=10, + record_retry=lambda *a, **k: None, + canonical_colors=lambda config: {}, + mutate_self_name=fake_mutate, + normalize_title=lambda title: title, + process_table_reader=fake_table, + propagate_color=lambda *a, **k: {}, + propagate_title=lambda *a, **k: {}, + prove_caller=lambda live, table, environ, pid: { + "provider": "codex", + "uuid": uuid_for(91), + }, + record_title_failure=lambda *a, **k: 0, + session_color=lambda *a, **k: None, + snapshot_inventory=fake_snapshot, + ) + self.assertEqual( + 1, len(snapshots), "a revalidation re-collected under the name lock" + ) + self.assertEqual(2, len(tables)) + self.assertEqual("ready", result["automatic_name_state"]) + def test_title_validation_kill_switch_and_provenance_precedence(self) -> None: self.assertEqual( "Session Kit Updates", @@ -1700,14 +5274,16 @@ def test_title_validation_kill_switch_and_provenance_precedence(self) -> None: with self.assertRaises(inventory_core.CollectionError): inventory_core.normalize_automatic_title(rejected) self.assertFalse( - inventory_core.automatic_naming_enabled( - {"SESSION_KIT_AUTO_NAME": "0"} - ) + inventory_core.automatic_naming_enabled({"SESSION_KIT_AUTO_NAME": "0"}) ) exact = uuid_for(81) automatic = {f"codex:{exact}": "Session Kit Updates"} + # A native rename outranks the alias tier, `sp name` included. The + # kit last pushed "Session Kit Updates", so a native store reading + # "Provider Rename" can only have been typed after that push: it is + # the newest thing a person said about this name, and it wins. self.assertEqual( - ("Manual Override", "alias"), + ("Provider Rename", "native"), inventory_core._provider_title_info( "codex", exact, @@ -1716,6 +5292,18 @@ def test_title_validation_kill_switch_and_provenance_precedence(self) -> None: automatic_titles=automatic, ), ) + # The alias keeps its place against a native title the kit itself + # pushed, that one is the kit's own echo, not somebody's rename. + self.assertEqual( + ("Manual Override", "alias"), + inventory_core._provider_title_info( + "codex", + exact, + "Session Kit Updates", + {f"codex:{exact}": "Manual Override"}, + automatic_titles=automatic, + ), + ) self.assertEqual( ("Provider Rename", "native"), inventory_core._provider_title_info( @@ -1726,9 +5314,7 @@ def test_title_validation_kill_switch_and_provenance_precedence(self) -> None: automatic_titles=automatic, ), ) - with mock.patch.dict( - os.environ, {"SESSION_KIT_AUTO_NAME": "0"}, clear=False - ): + with mock.patch.dict(os.environ, {"SESSION_KIT_AUTO_NAME": "0"}, clear=False): title, source = inventory_core._provider_title_info( "codex", exact, @@ -1782,9 +5368,7 @@ def test_atomic_write_manual_alias_race_and_mixed_release_retention(self) -> Non exact, "Raw prompt", inventory_core.canonical_aliases(config), - automatic_titles=inventory_core.canonical_automatic_titles( - config - ), + automatic_titles=inventory_core.canonical_automatic_titles(config), provider_title_is_explicit=False, ) self.assertEqual(("Manual Override", "alias"), (title, source)) @@ -1794,9 +5378,7 @@ def test_atomic_write_manual_alias_race_and_mixed_release_retention(self) -> Non config, "codex", exact, None ) document = json.loads(config_path.read_text(encoding="utf-8")) - self.assertNotIn( - f"codex:{exact}", document.get("automatic_titles", {}) - ) + self.assertNotIn(f"codex:{exact}", document.get("automatic_titles", {})) self.assertEqual( "Manual Override", document["aliases"][f"codex:{exact}"] ) @@ -1846,9 +5428,7 @@ def manual_writer() -> None: raced, "Raw prompt", inventory_core.canonical_aliases(config), - automatic_titles=inventory_core.canonical_automatic_titles( - config - ), + automatic_titles=inventory_core.canonical_automatic_titles(config), provider_title_is_explicit=False, ) self.assertEqual(("Concurrent Manual Name", "alias"), (title, source)) @@ -1905,9 +5485,41 @@ def test_self_name_proves_root_generation_refuses_bad_callers_and_tracks_failure environ=environment, current_pid=current_pid, ) - self.assertEqual("ready", result["automatic_name_state"]) + self.assertEqual("pending", result["automatic_name_state"]) + self.assertTrue(result["provider_title_warnings"]) + retry = config["state_dir"] / "provider-title-retries.json" + self.assertTrue(retry.is_file()) document = json.loads(config_path.read_text(encoding="utf-8")) self.assertNotIn("automatic_title_failures", document) + # A self-name is an explicit rename: it must land in the alias + # tier too, or a provider's own ai-title keeps outranking it + # in every display. + self.assertEqual( + "Session Kit Updates", + result["aliases"][f"codex:{exact}"], + ) + self.assertEqual( + "Session Kit Updates", + document["aliases"][f"codex:{exact}"], + ) + repeated = inventory_core.self_name_automatic_title( + config, + "Session Kit Release", + inventory=inventory, + process_table=table, + environ=environment, + current_pid=current_pid, + ) + document = json.loads(config_path.read_text(encoding="utf-8")) + self.assertEqual("Session Kit Release", repeated["title"]) + self.assertEqual( + "Session Kit Release", + document["aliases"][f"codex:{exact}"], + ) + self.assertEqual( + "Session Kit Release", + document["automatic_titles"][f"codex:{exact}"], + ) bad_thread = dict(environment) bad_thread["CODEX_THREAD_ID"] = uuid_for(99) @@ -1933,9 +5545,7 @@ def test_self_name_proves_root_generation_refuses_bad_callers_and_tracks_failure ] child_environment = dict(environment) child_environment["CODEX_THREAD_ID"] = child_uuid - with self.assertRaisesRegex( - inventory_core.CollectionError, "subagent" - ): + with self.assertRaisesRegex(inventory_core.CollectionError, "subagent"): inventory_core.self_name_automatic_title( config, "Another Task Name", @@ -1976,9 +5586,7 @@ def test_self_name_proves_root_generation_refuses_bad_callers_and_tracks_failure "--parent-session-id", exact, ] - with self.assertRaisesRegex( - inventory_core.CollectionError, "subagent" - ): + with self.assertRaisesRegex(inventory_core.CollectionError, "subagent"): inventory_core.prove_self_name_caller( inventory, subagent_table, @@ -1986,6 +5594,140 @@ def test_self_name_proves_root_generation_refuses_bad_callers_and_tracks_failure current_pid, ) + def test_provider_title_pending_requires_a_private_regular_marker(self) -> None: + with tempfile.TemporaryDirectory(prefix=".title-state-", dir=REPO) as raw: + state = Path(raw) + marker_root = state / "provider-untitled" + marker_root.mkdir(mode=0o700) + marker_root.chmod(0o700) + live = { + "sessions": [ + { + "provider": "codex", + "shpool_id_raw": "main2", + }, + { + "provider": "claude", + "shpool_id_raw": "main3", + }, + ] + } + (marker_root / "main2").touch() + inventory_core.apply_provider_title_states(live, {"state_dir": state}) + self.assertEqual("pending", live["sessions"][0]["provider_title_state"]) + self.assertNotIn("provider_title_state", live["sessions"][1]) + + live["sessions"][0].update( + {"availability": "attached", "agent_status": "working"} + ) + inventory_core.apply_provider_title_states(live, {"state_dir": state}) + self.assertEqual("deferred", live["sessions"][0]["provider_title_state"]) + + (marker_root / "main2").unlink() + (marker_root / "target").touch() + (marker_root / "main2").symlink_to(marker_root / "target") + inventory_core.apply_provider_title_states(live, {"state_dir": state}) + self.assertEqual("ready", live["sessions"][0]["provider_title_state"]) + + old_active = marker_root / "main" + old_active.write_text("activegeneration\n", encoding="utf-8") + old_orphan = marker_root / "gone" + old_orphan.write_text("orphangeneration\n", encoding="utf-8") + old = time.time() - 8 * 86400 + os.utime(old_active, (old, old)) + os.utime(old_orphan, (old, old)) + exact_live = inventory_core.build_inventory( + *inventory_fixture(1, providers=("codex",)), + now=1_800_000_000, + ) + with ( + mock.patch.object( + inventory_core, "collect_live", return_value=exact_live + ), + mock.patch.object(inventory_core, "_boot_id", return_value="boot-a"), + ): + result = inventory_core.snapshot( + write_state=True, + config={ + "state_dir": state, + "max_proc_nodes": 8192, + "max_proc_depth": 32, + }, + ) + self.assertTrue(old_active.exists()) + self.assertFalse(old_orphan.exists()) + self.assertEqual( + "gone", result["provider_untitled_quarantine"][0]["marker"] + ) + self.assertTrue( + Path(result["provider_untitled_quarantine"][0]["quarantine"]).is_file() + ) + exact_live.pop("provider_untitled_quarantine", None) + + unsafe_state = state / "unsafe-state" + unsafe_state.mkdir(mode=0o700) + external = state / "external-markers" + external.mkdir(mode=0o700) + external_marker = external / "foreign" + external_marker.touch() + os.utime(external_marker, (old, old)) + (unsafe_state / "provider-untitled").symlink_to( + external, target_is_directory=True + ) + with ( + mock.patch.object( + inventory_core, "collect_live", return_value=exact_live + ), + mock.patch.object(inventory_core, "_boot_id", return_value="boot-a"), + ): + unsafe_result = inventory_core.snapshot( + write_state=True, + config={ + "state_dir": unsafe_state, + "max_proc_nodes": 8192, + "max_proc_depth": 32, + }, + ) + self.assertTrue(external_marker.exists()) + self.assertNotIn("provider_untitled_quarantine", unsafe_result) + + def test_untitled_quarantine_cannot_reach_a_newer_marker(self) -> None: + with tempfile.TemporaryDirectory(prefix=".title-generation-", dir=REPO) as raw: + state = Path(raw) + marker_root = state / "provider-untitled" + marker_root.mkdir(mode=0o700) + marker = marker_root / "gone" + marker.write_text("oldgeneration\n", encoding="utf-8") + captured = inventory_core.capture_provider_untitled_generations(state) + marker.write_text("newgeneration\n", encoding="utf-8") + old = time.time() - 8 * 86400 + os.utime(marker, (old, old)) + inventory = { + "source": "live", + "stale": False, + "sessions": [{"shpool_id_raw": "still-here"}], + } + config = {"state_dir": state} + + self.assertEqual( + [], + inventory_core._quarantine_orphaned_provider_untitled_markers( + config, + inventory, + retire_generations=captured, + ), + ) + self.assertEqual("newgeneration\n", marker.read_text(encoding="utf-8")) + + fresh = inventory_core.capture_provider_untitled_generations(state) + moved = inventory_core._quarantine_orphaned_provider_untitled_markers( + config, + inventory, + retire_generations=fresh, + ) + self.assertEqual("gone", moved[0]["marker"]) + self.assertFalse(marker.exists()) + def test_retained_titles_audit_and_dry_run_token_gate_prune(self) -> None: with tempfile.TemporaryDirectory(prefix=".automatic-prune-", dir=REPO) as raw: base = Path(raw) @@ -2008,9 +5750,7 @@ def test_retained_titles_audit_and_dry_run_token_gate_prune(self) -> None: with self.assertRaisesRegex( inventory_core.CollectionError, "stale or does not match" ): - inventory_core.prune_automatic_titles( - config, live, "0" * 64 - ) + inventory_core.prune_automatic_titles(config, live, "0" * 64) pruned = inventory_core.prune_automatic_titles( config, live, audit["prune_token"] ) @@ -2020,6 +5760,171 @@ def test_retained_titles_audit_and_dry_run_token_gate_prune(self) -> None: inventory_core.canonical_automatic_titles(config), ) + def test_title_prune_recollects_before_applying_the_dry_run_token(self) -> None: + with tempfile.TemporaryDirectory(prefix=".automatic-prune-", dir=REPO) as raw: + base = Path(raw) + config_path, config = self._private_config(base) + first = uuid_for(88) + newly_active = uuid_for(89) + with mock.patch.dict( + os.environ, {"SESSION_KIT_CONFIG": str(config_path)}, clear=False + ): + inventory_core.mutate_canonical_automatic_title( + config, "codex", newly_active, "Retained New Session" + ) + old_inventory, _, _, _ = self._caller_fixture(config, first) + current_inventory, _, _, _ = self._caller_fixture(config, newly_active) + audit = inventory_core.audit_automatic_titles(config, old_inventory) + calls: list[str] = [] + + def revalidate() -> dict: + calls.append("under-lock") + return current_inventory + + with self.assertRaisesRegex( + inventory_core.CollectionError, "stale or does not match" + ): + inventory_core.prune_automatic_titles( + config, + old_inventory, + audit["prune_token"], + revalidate_inventory=revalidate, + ) + + self.assertEqual(["under-lock"], calls) + self.assertEqual( + {f"codex:{newly_active}": "Retained New Session"}, + inventory_core.canonical_automatic_titles(config), + ) + + def test_a_human_rename_refuses_every_later_automatic_rename(self) -> None: + with tempfile.TemporaryDirectory(prefix=".name-owner-", dir=REPO) as raw: + base = Path(raw) + config_path, config = self._private_config(base) + exact = uuid_for(91) + inventory, table, environment, current_pid = self._caller_fixture( + config, exact + ) + with mock.patch.dict( + os.environ, {"SESSION_KIT_CONFIG": str(config_path)}, clear=False + ): + inventory_core.mutate_canonical_alias( + config, "codex", exact, "the operator named this" + ) + document = json.loads(config_path.read_text(encoding="utf-8")) + self.assertEqual( + "human", + document["name_ownership"][f"codex:{exact}"]["owner"], + ) + self.assertEqual( + { + "owner": "human", + "at": document["name_ownership"][f"codex:{exact}"]["at"], + }, + inventory_core.canonical_name_ownership(config)[f"codex:{exact}"], + ) + for message, call in ( + ( + "explicit local alias", + lambda: inventory_core.mutate_canonical_automatic_title( + config, "codex", exact, "Some Automatic Name" + ), + ), + ( + "a human name owns this session", + lambda: inventory_core.self_name_automatic_title( + config, + "Some Automatic Name", + inventory=inventory, + process_table=table, + environ=environment, + current_pid=current_pid, + ), + ), + ): + with self.assertRaisesRegex( + inventory_core.CollectionError, message + ): + call() + # The reset drops the alias. The override is not a display + # tier, it outlives the name it was recorded for. + inventory_core.mutate_canonical_alias(config, "codex", exact, None) + document = json.loads(config_path.read_text(encoding="utf-8")) + self.assertEqual({}, document["aliases"]) + self.assertEqual( + "human", + document["name_ownership"][f"codex:{exact}"]["owner"], + ) + for call in ( + lambda: inventory_core.mutate_canonical_automatic_title( + config, "codex", exact, "Some Automatic Name", overwrite=True + ), + lambda: inventory_core.self_name_automatic_title( + config, + "Some Automatic Name", + inventory=inventory, + process_table=table, + environ=environment, + current_pid=current_pid, + ), + ): + with self.assertRaisesRegex( + inventory_core.CollectionError, + "a human name owns this session", + ): + call() + self.assertEqual( + {}, + json.loads(config_path.read_text(encoding="utf-8")).get( + "automatic_titles", {} + ), + ) + + def test_a_self_name_keeps_naming_itself_and_prunes_its_own_claim(self) -> None: + with tempfile.TemporaryDirectory(prefix=".name-claim-", dir=REPO) as raw: + base = Path(raw) + config_path, config = self._private_config(base) + exact = uuid_for(92) + inventory, table, environment, current_pid = self._caller_fixture( + config, exact + ) + with mock.patch.dict( + os.environ, {"SESSION_KIT_CONFIG": str(config_path)}, clear=False + ): + for title in ("Session Kit Updates", "Session Kit Release"): + inventory_core.self_name_automatic_title( + config, + title, + inventory=inventory, + process_table=table, + environ=environment, + current_pid=current_pid, + ) + document = json.loads(config_path.read_text(encoding="utf-8")) + self.assertEqual( + "automatic", + document["name_ownership"][f"codex:{exact}"]["owner"], + ) + # A dead conversation's spent claim is pruned with its title; + # a human override in the same sweep is not. + human = uuid_for(93) + inventory_core.mutate_canonical_alias( + config, "codex", human, "the operator named this" + ) + empty = inventory_core.build_inventory( + *inventory_fixture(0), now=1_800_000_000 + ) + audit = inventory_core.audit_automatic_titles(config, empty) + inventory_core.prune_automatic_titles( + config, empty, audit["prune_token"] + ) + document = json.loads(config_path.read_text(encoding="utf-8")) + self.assertNotIn(f"codex:{exact}", document.get("name_ownership", {})) + self.assertEqual( + "human", + document["name_ownership"][f"codex:{human}"]["owner"], + ) + class CanonicalAliasTests(unittest.TestCase): def test_new_alias_writer_serializes_with_pinned_config_lock_writer( @@ -2069,9 +5974,7 @@ def new_writer() -> None: thread.start() self.assertTrue(started.wait(1)) self.assertFalse(finished.wait(0.1)) - old_document = json.loads( - config_path.read_text(encoding="utf-8") - ) + old_document = json.loads(config_path.read_text(encoding="utf-8")) old_document["aliases"][old_key] = "Pinned writer" inventory_core.atomic_write_json(config_path, old_document) finally: @@ -2083,9 +5986,7 @@ def new_writer() -> None: self.assertEqual([], errors) stored = json.loads(config_path.read_text(encoding="utf-8")) self.assertEqual("Pinned writer", stored["aliases"][old_key]) - self.assertEqual( - "New writer", stored["aliases"][f"codex:{new_uuid}"] - ) + self.assertEqual("New writer", stored["aliases"][f"codex:{new_uuid}"]) def test_explicit_runtime_migration_preserves_runtime_wins_then_retires_overlay( self, @@ -2277,9 +6178,9 @@ def test_runtime_migration_accepts_only_byte_exact_rollback_retry( self.assertFalse(runtime.exists()) self.assertEqual( "Rollback value", - json.loads(config_path.read_text(encoding="utf-8"))[ - "aliases" - ][f"codex:{uuid_for(41)}"], + json.loads(config_path.read_text(encoding="utf-8"))["aliases"][ + f"codex:{uuid_for(41)}" + ], ) archive_preimage = archive.read_bytes() runtime.write_text( @@ -2294,9 +6195,7 @@ def test_runtime_migration_accepts_only_byte_exact_rollback_retry( encoding="utf-8", ) runtime.chmod(0o600) - with self.assertRaisesRegex( - inventory_core.CollectionError, "differ" - ): + with self.assertRaisesRegex(inventory_core.CollectionError, "differ"): inventory_core.migrate_runtime_aliases(config) self.assertEqual(archive_preimage, archive.read_bytes()) self.assertTrue(runtime.exists()) @@ -2405,19 +6304,15 @@ def test_absent_config_backup_is_a_non_publishable_rollback_sentinel( self.assertTrue(retried["migrated"]) self.assertEqual( "Only runtime", - json.loads(config_path.read_text(encoding="utf-8"))[ - "aliases" - ][f"claude:{uuid_for(42)}"], - ) - config_path.write_bytes( - inventory_core.ABSENT_ALIAS_CONFIG_BACKUP + json.loads(config_path.read_text(encoding="utf-8"))["aliases"][ + f"claude:{uuid_for(42)}" + ], ) + config_path.write_bytes(inventory_core.ABSENT_ALIAS_CONFIG_BACKUP) config_path.chmod(0o600) runtime.write_bytes(archive.read_bytes()) runtime.chmod(0o600) - with self.assertRaisesRegex( - inventory_core.CollectionError, "diverged" - ): + with self.assertRaisesRegex(inventory_core.CollectionError, "diverged"): inventory_core.migrate_runtime_aliases(config) self.assertEqual( inventory_core.ABSENT_ALIAS_CONFIG_BACKUP, @@ -2425,6 +6320,65 @@ def test_absent_config_backup_is_a_non_publishable_rollback_sentinel( ) +class CensusSelfExclusionTests(unittest.TestCase): + """The census must never count its own process chain as a session's title. + + A collection verb runs inside the managed shell it is titling; before the + exclusion, its python3 became native_title, the Idle-shell overlay never + applied, and provider-exit reopen could never pass its gate.""" + + TABLE = { + 50: {"comm": "shpool", "ppid": 1}, + 100: {"comm": "bash", "ppid": 50, "cwd": "/srv/x"}, + 200: {"comm": "bash", "ppid": 100}, + } + + def table_with_self(self) -> dict: + table = {pid: dict(row) for pid, row in self.TABLE.items()} + table[os.getpid()] = {"comm": "python3", "ppid": 200} + return table + + def test_the_census_chain_walks_self_and_ancestors(self) -> None: + from lib.sessionkit_inventory.collector import _census_chain + + chain = _census_chain(self.table_with_self()) + self.assertIn(os.getpid(), chain) + self.assertIn(200, chain) + self.assertIn(100, chain) + + def test_the_census_never_titles_the_shell_it_runs_in(self) -> None: + from lib.sessionkit_inventory.collector import _census_chain + from lib.sessionkit_inventory.model import _shell_title + + table = self.table_with_self() + chain = _census_chain(table) + tree = [100, 200, os.getpid()] + self.assertEqual( + ("Idle shell", "/srv/x", 100), _shell_title(tree, 100, table, chain) + ) + # Without the exclusion the census's own python3 wins, the exact + # mechanism that made reopen structurally impossible. + self.assertEqual("python3", _shell_title(tree, 100, table)[0]) + + def test_a_real_program_still_titles_the_shell(self) -> None: + from lib.sessionkit_inventory.collector import _census_chain + from lib.sessionkit_inventory.model import _shell_title + + table = self.table_with_self() + table[300] = {"comm": "vim", "ppid": 100, "cwd": "/srv/x"} + chain = _census_chain(table) + self.assertEqual( + ("vim", "/srv/x", 300), + _shell_title([100, 200, os.getpid(), 300], 100, table, chain), + ) + + def test_a_ppid_cycle_terminates(self) -> None: + from lib.sessionkit_inventory.collector import _census_chain + + table = {os.getpid(): {"comm": "python3", "ppid": os.getpid()}} + self.assertEqual(frozenset({os.getpid()}), _census_chain(table)) + + class InventoryInputAndRecoveryTests(unittest.TestCase): def test_retained_launch_modes_accept_legacy_resume_and_fork_without_aliasing_source( self, @@ -2439,9 +6393,7 @@ def unknown_fixture() -> dict: item["identity"] = { "uuid": None, "pid": item["shpool_shell"]["pid"], - "process_start_ticks": item["shpool_shell"][ - "process_start_ticks" - ], + "process_start_ticks": item["shpool_shell"]["process_start_ticks"], "provenance": "process tree", "confidence": "unknown", } @@ -2509,18 +6461,14 @@ def test_exact_retained_start_proofs_add_display_only_attribution(self) -> None: item["identity"] = { "uuid": None, "pid": item["shpool_shell"]["pid"], - "process_start_ticks": item["shpool_shell"][ - "process_start_ticks" - ], + "process_start_ticks": item["shpool_shell"]["process_start_ticks"], "provenance": "process tree", "confidence": "unknown", } item["title"] = "Unresolved provider session" item["display_title"] = item["title"] - with tempfile.TemporaryDirectory( - prefix=".retained-setup-", dir=REPO - ) as raw: + with tempfile.TemporaryDirectory(prefix=".retained-setup-", dir=REPO) as raw: start_dir = Path(raw) start_dir.chmod(0o700) for item, (_, provider) in zip(fixture["sessions"], expected): @@ -2553,7 +6501,7 @@ def test_exact_retained_start_proofs_add_display_only_attribution(self) -> None: self.assertEqual(provider, item["display_provider"]) self.assertTrue(item["setup_incomplete"]) self.assertEqual( - f"{provider.title()} setup incomplete", + f"{provider.title()} pending", item["display_title"], ) @@ -2570,17 +6518,13 @@ def unknown_fixture() -> dict: item["identity"] = { "uuid": None, "pid": item["shpool_shell"]["pid"], - "process_start_ticks": item["shpool_shell"][ - "process_start_ticks" - ], + "process_start_ticks": item["shpool_shell"]["process_start_ticks"], "provenance": "process tree", "confidence": "unknown", } return fixture - with tempfile.TemporaryDirectory( - prefix=".retained-reject-", dir=REPO - ) as raw: + with tempfile.TemporaryDirectory(prefix=".retained-reject-", dir=REPO) as raw: start_dir = Path(raw) start_dir.chmod(0o700) @@ -2589,9 +6533,7 @@ def write_records(fixture: dict, *, boot: str = "boot-a") -> None: generation = fixture["daemon_generation"] start = start_dir / "main" sidecar = start_dir / "main.expected" - start.write_text( - f"claude\t{item['cwd']}\t\n", encoding="utf-8" - ) + start.write_text(f"claude\t{item['cwd']}\t\n", encoding="utf-8") sidecar.write_text( f"claude\t{item['cwd']}\t{boot}\t" f"{item['started_at_unix_ms']}\t" @@ -2697,9 +6639,7 @@ def test_guard_live_permits_unrelated_unknown_provider_row(self) -> None: unknown["identity"] = { "uuid": None, "pid": unknown["shpool_shell"]["pid"], - "process_start_ticks": unknown["shpool_shell"][ - "process_start_ticks" - ], + "process_start_ticks": unknown["shpool_shell"]["process_start_ticks"], "provenance": "process tree", "confidence": "unknown", } @@ -2712,7 +6652,7 @@ def test_guard_live_permits_unrelated_unknown_provider_row(self) -> None: self.assertEqual("claude", known["provider"]) self.assertEqual("exact", known["identity"]["confidence"]) - def test_guard_rejects_invalid_outside_identity_and_managed_duplicate( + def test_guard_allows_managed_outside_duplicate_but_strict_rejects_it( self, ) -> None: fixture = inventory_core.build_inventory( @@ -2735,12 +6675,16 @@ def test_guard_rejects_invalid_outside_identity_and_managed_duplicate( duplicate = copy.deepcopy(fixture) duplicate["outside_agents"][0]["provider"] = managed["provider"] - duplicate["outside_agents"][0]["identity"] = copy.deepcopy( - managed["identity"] - ) - self.assertFalse(inventory_core.guard_live_inventory(duplicate)) + duplicate["outside_agents"][0]["identity"] = copy.deepcopy(managed["identity"]) + self.assertTrue(inventory_core.guard_live_inventory(duplicate)) self.assertFalse(inventory_core.strict_live_inventory(duplicate)) + duplicate_outside = copy.deepcopy(duplicate) + duplicate_outside["outside_agents"].append( + copy.deepcopy(duplicate_outside["outside_agents"][0]) + ) + self.assertFalse(inventory_core.guard_live_inventory(duplicate_outside)) + mutations = ( lambda item: item.update(provider="shell"), lambda item: item["identity"].update(uuid="bad"), @@ -2777,9 +6721,7 @@ def changed(label: str, mutate: object) -> None: ) changed( "daemon generation", - lambda value: value["daemon_generation"].update( - process_start_ticks=0 - ), + lambda value: value["daemon_generation"].update(process_start_ticks=0), ) changed( "shell generation", @@ -2787,9 +6729,7 @@ def changed(label: str, mutate: object) -> None: ) changed( "known confidence", - lambda value: value["sessions"][0]["identity"].update( - confidence="unknown" - ), + lambda value: value["sessions"][0]["identity"].update(confidence="unknown"), ) changed( "known uuid", @@ -2797,9 +6737,7 @@ def changed(label: str, mutate: object) -> None: ) changed( "duplicate row", - lambda value: value["sessions"][1].update( - row=value["sessions"][0]["row"] - ), + lambda value: value["sessions"][1].update(row=value["sessions"][0]["row"]), ) changed( "duplicate raw id", @@ -2816,12 +6754,8 @@ def changed(label: str, mutate: object) -> None: for label, candidate in malformed: with self.subTest(label=label): - self.assertFalse( - inventory_core.guard_live_inventory(candidate) - ) - self.assertFalse( - inventory_core.strict_live_inventory(candidate) - ) + self.assertFalse(inventory_core.guard_live_inventory(candidate)) + self.assertFalse(inventory_core.strict_live_inventory(candidate)) def test_malformed_json_and_duplicate_selectors_fail_closed(self) -> None: with tempfile.TemporaryDirectory(prefix=".inventory-input-", dir=REPO) as raw: @@ -2872,9 +6806,7 @@ def test_recovery_commands_require_and_preserve_exact_uuid(self) -> None: claude = inventory_core.recovery_spec("claude", exact, "/srv/a b") codex = inventory_core.recovery_spec("codex", exact) self.assertEqual(["claude", "--resume", exact], claude["argv"]) - self.assertEqual( - ["codex", "--no-alt-screen", "resume", exact], codex["argv"] - ) + self.assertEqual(["codex", "--no-alt-screen", "resume", exact], codex["argv"]) joined = json.dumps([claude, codex]).casefold() for fallback in ("continue", "latest", "newest", "--last"): self.assertNotIn(fallback, joined) @@ -2900,9 +6832,7 @@ def test_strict_live_refuses_a_last_known_good_cache_without_json(self) -> None: cached = inventory_core.build_inventory( *inventory_fixture(1), now=1_800_000_000 ) - (state / "inventory.json").write_text( - json.dumps(cached), encoding="utf-8" - ) + (state / "inventory.json").write_text(json.dumps(cached), encoding="utf-8") config = base / "inventory.json" config.write_text( json.dumps({"schema_version": 1, "state_dir": str(state)}), @@ -2946,24 +6876,199 @@ def test_strict_live_refuses_a_last_known_good_cache_without_json(self) -> None: class InventoryRenderingTests(unittest.TestCase): + def test_list_screen_in_a_real_80_column_terminal_hides_machine_titles( + self, + ) -> None: + fixture = inventory_core.build_inventory( + *inventory_fixture(2), now=1_800_000_000 + ) + person, machine = fixture["sessions"] + person.update( + { + "account_alias": "account-name-that-is-too-long", + "model": "model-name-that-is-much-too-long-for-a-terminal", + } + ) + machine.update( + { + "origin": "machine", + "title": "SECRET MACHINE SESSION TITLE", + "display_title": "SECRET MACHINE SESSION TITLE", + } + ) + + with tempfile.TemporaryDirectory(prefix=".list-pty-", dir=REPO) as raw: + snapshot = Path(raw) / "inventory.json" + snapshot.write_text(json.dumps(fixture), encoding="utf-8") + environment = os.environ.copy() + environment.update( + { + "SESSION_KIT_NO_COLOR": "1", + "PYTHONDONTWRITEBYTECODE": "1", + } + ) + environment.pop("COLUMNS", None) + + pid, descriptor = pty.fork() + if pid == 0: + try: + os.execve( + REPO / "bin" / "shpool_status", + [ + os.fspath(REPO / "bin" / "shpool_status"), + "--render-file", + os.fspath(snapshot), + ], + environment, + ) + finally: + os._exit(127) + + fcntl.ioctl( + descriptor, + termios.TIOCSWINSZ, + struct.pack("HHHH", 24, 80, 0, 0), + ) + output = bytearray() + deadline = time.monotonic() + 10 + status = None + try: + while time.monotonic() < deadline: + ready, _, _ = select.select([descriptor], [], [], 0.05) + if ready: + try: + chunk = os.read(descriptor, 65536) + except OSError as exc: + if exc.errno != errno.EIO: + raise + chunk = b"" + if chunk: + output.extend(chunk) + waited_pid, status = os.waitpid(pid, os.WNOHANG) + if waited_pid == pid: + break + if status is None: + os.kill(pid, 9) + _, status = os.waitpid(pid, 0) + self.fail("list renderer did not exit within ten seconds") + finally: + os.close(descriptor) + + self.assertEqual(0, os.waitstatus_to_exitcode(status)) + visible = output.decode(errors="replace").replace("\r\n", "\n") + self.assertNotIn("SECRET MACHINE SESSION TITLE", visible) + self.assertIn("1 machine session", visible) + self.assertIn("| CLD |", visible) + # Both columns remain present, and the compact time gives their + # values more room without ever deleting the time column. + self.assertIn("account-nam…", visible) + self.assertIn("model-nam…", visible) + self.assertIn("| working | pending", visible) + self.assertTrue( + all( + inventory_core._display_width(line) <= 79 + for line in visible.splitlines() + ), + visible, + ) + + def test_a_fresh_render_does_not_rewrite_the_inventory_cache(self) -> None: + with tempfile.TemporaryDirectory(prefix=".render-read-only-", dir=REPO) as raw: + base = Path(raw) + state = base / "state" + state.mkdir() + inventory_path = state / "inventory.json" + inventory_path.write_text( + json.dumps( + { + "schema_version": 1, + "source": "cache", + "stale": False, + "sessions": [], + "outside_agents": [], + } + ), + encoding="utf-8", + ) + (state / "sentinel").write_bytes(b"listing must not touch this tree\n") + config = base / "config.json" + config.write_text( + json.dumps({"schema_version": 1, "state_dir": str(state)}), + encoding="utf-8", + ) + + proc = base / "proc" + daemon = proc / "10" + daemon.mkdir(parents=True) + stat_fields = ["S", "1", *("0" for _ in range(17)), "700"] + (daemon / "stat").write_text( + f"10 (shpool) {' '.join(stat_fields)}\n", encoding="utf-8" + ) + (daemon / "comm").write_text("shpool\n", encoding="utf-8") + (daemon / "cmdline").write_bytes(b"/usr/bin/shpool\0daemon\0") + (daemon / "environ").write_bytes(b"") + (daemon / "cwd").symlink_to(base) + shpool_json = base / "shpool.json" + shpool_json.write_text('{"sessions":[]}\n', encoding="utf-8") + claude_json = base / "claude.json" + claude_json.write_text("[]\n", encoding="utf-8") + boot_id = base / "boot-id" + boot_id.write_text("fixture-boot\n", encoding="utf-8") + fake_shpool = base / "shpool" + fake_shpool.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") + fake_shpool.chmod(0o755) + + def tree_snapshot() -> dict[str, tuple[int, int, bytes]]: + return { + str(path.relative_to(state)): ( + path.stat().st_mode, + path.stat().st_mtime_ns, + path.read_bytes(), + ) + for path in sorted(state.rglob("*")) + if path.is_file() + } + + before = tree_snapshot() + rendered = run( + [REPO / "bin" / "sp", "list"], + env={ + "SESSION_KIT_CONFIG": str(config), + "SESSION_KIT_SHPOOL_CMD": str(fake_shpool), + "SESSION_KIT_SHPOOL_JSON_FILE": str(shpool_json), + "SESSION_KIT_CLAUDE_JSON_FILE": str(claude_json), + "SESSION_KIT_PROC_ROOT": str(proc), + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_BOOT_ID_FILE": str(boot_id), + "SESSION_KIT_STATE_DIR": str(state), + "SESSION_KIT_AUTO_NAME": "0", + "SESSION_KIT_NO_COLOR": "1", + "PYTHONDONTWRITEBYTECODE": "1", + }, + ) + self.assertIn("Sessions: none.", rendered.stdout) + self.assertNotIn("Warning:", rendered.stdout) + self.assertEqual(before, tree_snapshot()) + def test_rows_hide_operational_ids_and_use_semantic_colors(self) -> None: fixture = inventory_core.build_inventory( *inventory_fixture(2), now=1_800_000_000 ) fixture["sessions"][0]["needs_you"] = True fixture["sessions"][0]["agent_status"] = "needs your reply" - with mock.patch.object( - inventory_core, "_color_enabled", return_value=True - ): + with mock.patch.object(inventory_core, "_color_enabled", return_value=True): rendered = inventory_core.render_inventory(fixture) visible = re.sub(r"\x1b\[[0-9;]*m", "", rendered) for item in fixture["sessions"]: self.assertNotIn(f"[{item['display_shpool_id']}]", visible) - self.assertIn("\x1b[36mClaude", rendered) - self.assertIn("\x1b[36mCodex", rendered) + self.assertIn("\x1b[36mCLD", rendered) + self.assertIn("\x1b[36mCDX", rendered) self.assertIn("\x1b[32m 1\x1b[0m", rendered) self.assertIn("\x1b[32m 2\x1b[0m", rendered) - self.assertIn("\x1b[33mneeds your reply", rendered) + # The collector keeps the provider's own words; the screen renders + # the one term for the state. + self.assertIn("\x1b[33mneeds you", rendered) + self.assertNotIn("needs your reply", rendered) def test_renderer_used_by_picker_strips_csi_osc_and_all_control_bytes(self) -> None: malicious = { @@ -2990,8 +7095,10 @@ def test_renderer_used_by_picker_strips_csi_osc_and_all_control_bytes(self) -> N rendered = inventory_core.render_inventory(malicious) self.assertIn("safe", rendered) self.assertIn("owned", rendered) - self.assertIn("recent output 1m ago", rendered) - self.assertIn("process age 0m", rendered) + self.assertIn("last active 1m ago", rendered) + # One time on a row, not two: the process age moved to `sp detail`. + self.assertNotIn("process age", rendered) + self.assertNotIn("recent output", rendered) self.assertNotIn("project", rendered) self.assertNotIn("\x1b", rendered) self.assertFalse( @@ -3026,11 +7133,13 @@ def test_non_tty_no_color_rendering_at_supported_widths(self) -> None: else: os.environ["SESSION_KIT_NO_COLOR"] = old_no_color self.assertNotIn("\x1b", rendered) - self.assertIn("Available to open", rendered) - self.assertIn("Already open in another window", rendered) + self.assertIn("Ready", rendered) + self.assertIn("Open elsewhere", rendered) self.assertIn("Claude", rendered) self.assertIn("Codex", rendered) - self.assertNotIn("open elsewhere", rendered) + # The retired headings must not come back on any width. + self.assertNotIn("Available to open", rendered) + self.assertNotIn("Already open in another window", rendered) self.assertNotIn("in /srv/", rendered) self.assertTrue( all( @@ -3048,9 +7157,7 @@ def test_terminal_width_is_used_when_columns_is_not_exported(self) -> None: "A screenshot-sized title that must not wrap in the SSH picker" ) with ( - mock.patch.dict( - os.environ, {"SESSION_KIT_NO_COLOR": "1"}, clear=False - ), + mock.patch.dict(os.environ, {"SESSION_KIT_NO_COLOR": "1"}, clear=False), mock.patch.object( inventory_core.shutil, "get_terminal_size", @@ -3075,10 +7182,10 @@ def test_color_sequences_do_not_consume_the_display_cell_budget(self) -> None: "Styled wide 漢字 title that reaches the terminal boundary" ) for width in (60, 80, 100, 160): - with self.subTest(width=width), mock.patch.dict( - os.environ, {"COLUMNS": str(width)}, clear=False - ), mock.patch.object( - inventory_core, "_color_enabled", return_value=True + with ( + self.subTest(width=width), + mock.patch.dict(os.environ, {"COLUMNS": str(width)}, clear=False), + mock.patch.object(inventory_core, "_color_enabled", return_value=True), ): rendered = inventory_core.render_inventory(fixture) self.assertIn("\x1b[", rendered) @@ -3093,6 +7200,155 @@ def test_color_sequences_do_not_consume_the_display_cell_budget(self) -> None: class ProviderLifecycleStateTests(unittest.TestCase): + def test_provider_exit_requires_exact_committed_intake_generation(self) -> None: + with tempfile.TemporaryDirectory(prefix=".lifecycle-commit-", dir=REPO) as raw: + root = Path(raw) + root.chmod(0o700) + marker = root / "prompt.intake_committed" + conversation = uuid_for(91) + generation = "aaaaaaaa-bbbb:123:456:789" + payload = { + "schema_version": 2, + "status": "intake_committed", + "provider": "codex", + "session_id": conversation, + "submission_key": "turn-1", + "prompt_sha256": "a" * 64, + "bytes": 12, + "source_event_id": "b" * 64, + "intake_msg_id": "c" * 8, + "requirements_revision": 0, + "requirements_digest": "d" * 64, + "managed_generation": generation, + "committed_unix_ms": 1_800_000_000_000, + } + marker.write_text(json.dumps(payload) + "\n", encoding="utf-8") + marker.chmod(0o600) + environment = { + "SESSION_KIT_LIFECYCLE_CONVERSATION_UUID": conversation, + "SESSION_KIT_LIFECYCLE_INTAKE_COMMIT": os.fspath(marker), + "SESSION_KIT_MANAGED_GENERATION": generation, + } + with mock.patch.dict(os.environ, environment, clear=False): + exact = inventory_core._lifecycle_committed_conversation( + provider="codex", + boot_id="aaaaaaaa-bbbb", + shell_pid=123, + shell_start=456, + ) + self.assertEqual(conversation, exact) + + for field, changed in ( + ("provider", "claude"), + ("session_id", uuid_for(92)), + ("managed_generation", "aaaaaaaa-bbbb:123:999:789"), + ("prompt_sha256", "not-a-digest"), + ): + broken = dict(payload) + broken[field] = changed + marker.write_text(json.dumps(broken) + "\n", encoding="utf-8") + marker.chmod(0o600) + with ( + mock.patch.dict(os.environ, environment, clear=False), + self.assertRaises(inventory_core.CollectionError), + ): + inventory_core._lifecycle_committed_conversation( + provider="codex", + boot_id="aaaaaaaa-bbbb", + shell_pid=123, + shell_start=456, + ) + + marker.unlink() + with ( + mock.patch.dict(os.environ, environment, clear=False), + self.assertRaisesRegex(inventory_core.CollectionError, "unavailable"), + ): + inventory_core._lifecycle_committed_conversation( + provider="codex", + boot_id="aaaaaaaa-bbbb", + shell_pid=123, + shell_start=456, + ) + + def test_conversation_uuid_is_persisted_in_provider_exit_state(self) -> None: + with tempfile.TemporaryDirectory( + prefix=".lifecycle-conversation-", dir=REPO + ) as raw: + value = lifecycle_state.record_provider_exit( + Path(raw), + session_id="main", + boot_id="aaaaaaaa-bbbb", + shell_pid=123, + shell_start_ticks=456, + provider="codex", + conversation_uuid=uuid_for(93), + exit_code=0, + input_tracking=True, + now_monotonic_ns=999, + ) + self.assertEqual(uuid_for(93), value["conversation_uuid"]) + self.assertEqual(4, value["schema_version"]) + self.assertRegex(value["record_generation"], r"^[0-9a-f]{64}$") + with self.assertRaisesRegex( + inventory_core.CollectionError, "conversation changed" + ): + lifecycle_state.record_provider_exit( + Path(raw), + session_id="main", + boot_id="aaaaaaaa-bbbb", + shell_pid=123, + shell_start_ticks=456, + provider="codex", + conversation_uuid=uuid_for(94), + exit_code=0, + input_tracking=True, + now_monotonic_ns=1000, + ) + + +class WorkerLaunchGateTests(unittest.TestCase): + def test_inventory_row_carries_only_bound_process_model_and_launch_key( + self, + ) -> None: + fixture = list(inventory_fixture(1)) + table = fixture[2] + provider_pid = next( + pid for pid, row in table.items() if row["comm"] == "claude" + ) + table[provider_pid]["cmdline"] = [ + "/usr/bin/claude", + "--model", + "claude-opus-test", + ] + table[provider_pid]["requested_model"] = "claude-opus-test" + table[provider_pid]["launch_idempotency_key"] = "worker:research:1" + inventory = inventory_core.build_inventory(*fixture) + row = inventory["sessions"][0] + self.assertEqual("claude-opus-test", row["actual_model"]) + self.assertEqual("worker:research:1", row["launch_idempotency_key"]) + + def test_inventory_binds_codex_cli_evidence_under_exact_managed_shell(self) -> None: + fixture = list(inventory_fixture(1, providers=("codex",))) + table = fixture[2] + provider_pid = next(pid for pid, row in table.items() if row["comm"] == "codex") + shell_pid = table[provider_pid]["ppid"] + table[provider_pid]["cmdline"] = ["/usr/bin/codex", "app-server"] + cli_pid = max(table) + 1 + table[cli_pid] = process(cli_pid, shell_pid, "node") + table[cli_pid]["cmdline"] = [ + "/usr/bin/node", + "/opt/codex", + "--model", + "gpt-codex-test", + ] + table[cli_pid]["requested_model"] = "gpt-codex-test" + table[cli_pid]["launch_idempotency_key"] = "worker:implementation:1" + inventory = inventory_core.build_inventory(*fixture) + row = inventory["sessions"][0] + self.assertEqual("gpt-codex-test", row["actual_model"]) + self.assertEqual("worker:implementation:1", row["launch_idempotency_key"]) + def test_lifecycle_caller_must_descend_from_exact_managed_shell(self) -> None: current_pid = os.getpid() shell_pid = current_pid + 10_000 @@ -3110,31 +7366,21 @@ def test_lifecycle_caller_must_descend_from_exact_managed_shell(self) -> None: mock.patch.object( inventory_core, "_require_supported_platform", return_value="linux" ), - mock.patch.object( - inventory_core, "scan_process_table", return_value=table - ), + mock.patch.object(inventory_core, "scan_process_table", return_value=table), ): - inventory_core._prove_lifecycle_caller( - "main2", shell_pid, 987_654 - ) + inventory_core._prove_lifecycle_caller("main2", shell_pid, 987_654) with self.assertRaisesRegex( inventory_core.CollectionError, "outside the exact" ): - inventory_core._prove_lifecycle_caller( - "main2", shell_pid, 987_655 - ) + inventory_core._prove_lifecycle_caller("main2", shell_pid, 987_655) table[shell_pid]["session_name"] = "main3" with self.assertRaisesRegex( inventory_core.CollectionError, "outside the exact" ): - inventory_core._prove_lifecycle_caller( - "main2", shell_pid, 987_654 - ) + inventory_core._prove_lifecycle_caller("main2", shell_pid, 987_654) def test_state_is_private_minimal_and_first_input_is_permanent(self) -> None: - with tempfile.TemporaryDirectory( - prefix=".lifecycle-state-", dir=REPO - ) as raw: + with tempfile.TemporaryDirectory(prefix=".lifecycle-state-", dir=REPO) as raw: state_dir = Path(raw) session_id = "s20260730-220500-19" boot_id = "11111111-2222-3333-4444-555555555555" @@ -3202,35 +7448,96 @@ def test_state_is_private_minimal_and_first_input_is_permanent(self) -> None: shell_start_ticks=456, event="user-input", ) + captured = lifecycle_state.capture_lifecycle_generations(state_dir) + self.assertEqual( + 0, + lifecycle_state.prune_inactive_state( + state_dir, + [session_id], + retire_generations=captured, + ), + ) + rewritten = lifecycle_state.update_state( + state_dir, + session_id=session_id, + boot_id=boot_id, + shell_pid=123, + shell_start_ticks=456, + event="keep", + keep=True, + ) + self.assertNotEqual( + kept["record_generation"], rewritten["record_generation"] + ) self.assertEqual( 0, lifecycle_state.prune_inactive_state( - state_dir, [session_id] + state_dir, + [], + retire_generations=captured, ), ) + self.assertTrue(path.exists()) + current = lifecycle_state.capture_lifecycle_generations(state_dir) self.assertEqual( 1, - lifecycle_state.prune_inactive_state(state_dir, []), + lifecycle_state.prune_inactive_state( + state_dir, + [], + retire_generations=current, + ), ) self.assertFalse(path.exists()) + def test_exact_recovery_retention_is_separate_from_generation_guard(self) -> None: + """Only exact recovery has an age-based housekeeping lifetime.""" + with tempfile.TemporaryDirectory(prefix="session-kit-lifecycle-retain.") as raw: + state_dir = Path(raw) + lifecycle_dir = state_dir / "lifecycle" + lifecycle_dir.mkdir(mode=0o700, parents=True) + self.assertIsNotNone( + lifecycle_state._lifecycle_secret(state_dir, create=True) + ) + key = "ab" * 32 + plain_recent = lifecycle_dir / f"{key}.json" + exact_recent = lifecycle_dir / f"{key}.exact.json" + old_key = "cd" * 32 + plain_old = lifecycle_dir / f"{old_key}.json" + exact_old = lifecycle_dir / f"{old_key}.exact.json" + for index, path in enumerate( + (plain_recent, exact_recent, plain_old, exact_old), start=1 + ): + path.write_text( + json.dumps({"record_generation": f"{index:064x}"}) + "\n", + encoding="utf-8", + ) + path.chmod(0o600) + aged = time.time() - (lifecycle_state.EXACT_RECOVERY_RETENTION_SECONDS + 1) + for path in (plain_old, exact_old): + os.utime(path, (aged, aged)) + captured = lifecycle_state.capture_lifecycle_generations(state_dir) + removed = lifecycle_state.prune_inactive_state( + state_dir, + [], + retire_generations=captured, + ) + self.assertEqual(3, removed) + self.assertFalse(plain_recent.exists()) + self.assertTrue(exact_recent.exists()) + self.assertFalse(plain_old.exists()) + self.assertFalse(exact_old.exists()) + def test_exact_exit_overlay_keeps_shell_live_and_recovery_available(self) -> None: fixture = list(inventory_fixture(1, providers=("codex",))) - active = inventory_core.build_inventory( - *fixture, now=1_800_000_000 - ) + active = inventory_core.build_inventory(*fixture, now=1_800_000_000) expected_uuid = uuid_for(1) root_pid = 1001 provider_pid = 2001 del fixture[2][provider_pid] fixture[3] = {} - idle = inventory_core.build_inventory( - *fixture, now=1_800_000_001 - ) + idle = inventory_core.build_inventory(*fixture, now=1_800_000_001) self.assertEqual("shell", idle["sessions"][0]["provider"]) - with tempfile.TemporaryDirectory( - prefix=".lifecycle-overlay-", dir=REPO - ) as raw: + with tempfile.TemporaryDirectory(prefix=".lifecycle-overlay-", dir=REPO) as raw: state_dir = Path(raw) lifecycle_state.persist_last_exact( idle, @@ -3268,10 +7575,214 @@ def test_exact_exit_overlay_keeps_shell_live_and_recovery_available(self) -> Non ) self.assertFalse(item["needs_you"]) - def test_reopen_executes_only_generation_bound_exact_recovery(self) -> None: + def test_exit_overlay_survives_the_inventorys_own_helper_process(self) -> None: + # Reported 2026-08-11: `r` at the provider-exit menu answered "exact + # provider-exit recovery is unavailable" for a session whose retained + # record held `claude --resume <uuid>`. The menu runs the collector + # from inside the managed shell, so the collector saw its own helper + # as the session's work, called the row running, and the overlay, + # which only annotates idle shells, never attached the recovery. + fixture = list(inventory_fixture(1, providers=("codex",))) + active = inventory_core.build_inventory(*fixture, now=1_800_000_000) + expected_uuid = uuid_for(1) + root_pid = 1001 + del fixture[2][2001] + fixture[3] = {} + # Exactly what the reopen looks like on the process table: this very + # process, running under the managed shell. + fixture[2][os.getpid()] = process( + os.getpid(), root_pid, "python3", cwd="/srv/project-1" + ) + idle = inventory_core.build_inventory(*fixture, now=1_800_000_001) + self.assertEqual("shell", idle["sessions"][0]["provider"]) + self.assertEqual("Idle shell", idle["sessions"][0]["native_title"]) + self.assertEqual("idle", idle["sessions"][0]["agent_status"]) + boot_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + with tempfile.TemporaryDirectory( + prefix=".lifecycle-self-helper-", dir=REPO + ) as raw: + state_dir = Path(raw) + lifecycle_state.persist_last_exact( + idle, active, state_dir=state_dir, boot_id=boot_id + ) + lifecycle_state.record_provider_exit( + state_dir, + session_id="main", + boot_id=boot_id, + shell_pid=root_pid, + shell_start_ticks=fixture[2][root_pid]["start_ticks"], + provider="codex", + exit_code=0, + input_tracking=True, + now_monotonic_ns=1_800_000_000_500, + ) + lifecycle_state.apply_provider_exit_states( + idle, None, state_dir=state_dir, boot_id=boot_id + ) + item = idle["sessions"][0] + self.assertEqual("codex", item["exited_provider"]) + self.assertEqual(expected_uuid, item["recovery"]["uuid"]) + self.assertTrue(item["recovery"]["available"]) + + def _overlay_fixture(self, state_dir: Path, boot_id: str) -> dict[str, Any]: + """An idle shell row plus the retained exact record `r` depends on.""" + fixture = list(inventory_fixture(1, providers=("codex",))) + active = inventory_core.build_inventory(*fixture, now=1_800_000_000) + del fixture[2][2001] + fixture[3] = {} + idle = inventory_core.build_inventory(*fixture, now=1_800_000_001) + lifecycle_state.persist_last_exact( + idle, active, state_dir=state_dir, boot_id=boot_id + ) + lifecycle_state.record_provider_exit( + state_dir, + session_id="main", + boot_id=boot_id, + shell_pid=1001, + shell_start_ticks=fixture[2][1001]["start_ticks"], + provider="codex", + exit_code=3, + input_tracking=True, + now_monotonic_ns=1_800_000_000_500, + ) + return idle + + def _exact_records(self, state_dir: Path) -> list[Path]: + return sorted((state_dir / "lifecycle").glob("*.exact.json")) + + def test_a_removed_exact_record_refuses_rather_than_guessing(self) -> None: + # The retained record is the only thing that knows which conversation + # this terminal held. Without it the row is still honestly marked as + # provider-exited, but nothing may be offered to reopen. + boot_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + with tempfile.TemporaryDirectory( + prefix=".lifecycle-exact-gone-", dir=REPO + ) as raw: + state_dir = Path(raw) + idle = self._overlay_fixture(state_dir, boot_id) + records = self._exact_records(state_dir) + self.assertEqual(1, len(records), records) + records[0].unlink() + lifecycle_state.apply_provider_exit_states( + idle, None, state_dir=state_dir, boot_id=boot_id + ) + item = idle["sessions"][0] + self.assertEqual("codex", item["exited_provider"]) + self.assertFalse(item["recovery"]["available"]) + self.assertIsNone(item["recovery"]["uuid"]) + # No identity hint at all, rather than a hopeful one. + self.assertNotIn("exited_identity", item) + self.assertNotIn("_terminal_identity_hint", item) + + def test_a_corrupt_exact_record_is_refused_not_read_past(self) -> None: + # A record that cannot be parsed is not evidence of anything, and the + # collection says so rather than continuing with a partial answer. + boot_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + with tempfile.TemporaryDirectory( + prefix=".lifecycle-exact-corrupt-", dir=REPO + ) as raw: + state_dir = Path(raw) + idle = self._overlay_fixture(state_dir, boot_id) + records = self._exact_records(state_dir) + self.assertEqual(1, len(records), records) + records[0].write_text("{not json", encoding="utf-8") + with self.assertRaises(inventory_core.CollectionError) as caught: + lifecycle_state.apply_provider_exit_states( + idle, None, state_dir=state_dir, boot_id=boot_id + ) + self.assertIn("invalid JSON", str(caught.exception)) + + def test_a_truncated_exact_record_is_refused_not_read_past(self) -> None: + # Half a record parses as JSON and still is not the record. + boot_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + with tempfile.TemporaryDirectory( + prefix=".lifecycle-exact-partial-", dir=REPO + ) as raw: + state_dir = Path(raw) + idle = self._overlay_fixture(state_dir, boot_id) + records = self._exact_records(state_dir) + document = json.loads(records[0].read_text(encoding="utf-8")) + document.pop("recovery") + records[0].write_text(json.dumps(document), encoding="utf-8") + lifecycle_state.apply_provider_exit_states( + idle, None, state_dir=state_dir, boot_id=boot_id + ) + item = idle["sessions"][0] + self.assertEqual("codex", item["exited_provider"]) + self.assertFalse(item["recovery"]["available"]) + + def test_a_foreign_process_still_names_the_shell_as_running(self) -> None: + # The suppression above is exactly the collector's own process and the + # processes it started. A workload the terminal itself is running is a + # sibling of the collector, not a descendant, and it is still reported, + # otherwise the row would lie about a busy terminal. + fixture = list(inventory_fixture(1, providers=("codex",))) + root_pid = 1001 + del fixture[2][2001] + fixture[3] = {} + fixture[2][3001] = process(3001, root_pid, "vim", cwd="/srv/project-1") + idle = inventory_core.build_inventory(*fixture, now=1_800_000_001) + self.assertEqual("vim", idle["sessions"][0]["native_title"]) + self.assertEqual("running", idle["sessions"][0]["agent_status"]) + + def test_the_collectors_own_subtree_hides_but_a_sibling_still_shows( + self, + ) -> None: + # The realistic shape of a reopen: the collector runs under the managed + # shell and has helpers of its own, while the operator's work sits + # beside it. Only the collector's own tree may disappear. + fixture = list(inventory_fixture(1, providers=("codex",))) + root_pid = 1001 + del fixture[2][2001] + fixture[3] = {} + helper = os.getpid() + fixture[2][helper] = process(helper, root_pid, "python3", cwd="/srv/project-1") + fixture[2][3101] = process(3101, helper, "timeout", cwd="/srv/project-1") + fixture[2][3102] = process(3102, 3101, "shpool_status", cwd="/srv/project-1") + busy = inventory_core.build_inventory(*fixture, now=1_800_000_001) + self.assertEqual("Idle shell", busy["sessions"][0]["native_title"]) + fixture[2][3200] = process(3200, root_pid, "vim", cwd="/srv/project-1") + with_sibling = inventory_core.build_inventory(*fixture, now=1_800_000_002) + self.assertEqual("vim", with_sibling["sessions"][0]["native_title"]) + self.assertEqual("running", with_sibling["sessions"][0]["agent_status"]) + + def test_fast_exit_synthesizes_recovery_from_committed_conversation(self) -> None: + fixture = list(inventory_fixture(1, providers=("codex",))) + root_pid = 1001 + del fixture[2][2001] + fixture[3] = {} + idle = inventory_core.build_inventory(*fixture, now=1_800_000_001) + expected_uuid = uuid_for(81) with tempfile.TemporaryDirectory( - prefix=".lifecycle-reopen-", dir=REPO + prefix=".lifecycle-fast-exit-", dir=REPO ) as raw: + state_dir = Path(raw) + lifecycle_state.record_provider_exit( + state_dir, + session_id="main", + boot_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + shell_pid=root_pid, + shell_start_ticks=fixture[2][root_pid]["start_ticks"], + provider="codex", + conversation_uuid=expected_uuid, + exit_code=0, + input_tracking=True, + now_monotonic_ns=1_800_000_000_500, + ) + lifecycle_state.apply_provider_exit_states( + idle, + None, + state_dir=state_dir, + boot_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ) + item = idle["sessions"][0] + self.assertEqual(expected_uuid, item["exited_identity"]["uuid"]) + self.assertIn("committed provider-exit", item["exited_identity"]["provenance"]) + self.assertEqual(expected_uuid, item["recovery"]["uuid"]) + self.assertTrue(item["recovery"]["available"]) + + def test_reopen_executes_only_generation_bound_exact_recovery(self) -> None: + with tempfile.TemporaryDirectory(prefix=".lifecycle-reopen-", dir=REPO) as raw: state_dir = Path(raw) boot_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" lifecycle_state.record_provider_exit( @@ -3306,18 +7817,15 @@ def test_reopen_executes_only_generation_bound_exact_recovery(self) -> None: "recovery": recovery, } args = argparse.Namespace(lifecycle_action="reopen") - completed = subprocess.CompletedProcess( - recovery["argv"], 0 - ) + completed = subprocess.CompletedProcess(recovery["argv"], 0) with ( mock.patch.object( inventory_core, "_lifecycle_environment", return_value=(state_dir, "main2", boot_id, 123, 456), ), - mock.patch.object( - inventory_core, "_prove_lifecycle_caller" - ), + mock.patch.object(inventory_core, "_prove_lifecycle_caller"), + mock.patch.object(inventory_core, "_prove_unchanged_daemon_generation"), mock.patch.object( inventory_core, "load_config", return_value={"state_dir": state_dir} ), @@ -3327,9 +7835,7 @@ def test_reopen_executes_only_generation_bound_exact_recovery(self) -> None: mock.patch.object( inventory_core, "guard_live_inventory", return_value=True ), - mock.patch.object( - inventory_core, "lookup", return_value=item - ), + mock.patch.object(inventory_core, "lookup", return_value=item), mock.patch.object( inventory_core.subprocess, "run", @@ -3337,9 +7843,21 @@ def test_reopen_executes_only_generation_bound_exact_recovery(self) -> None: ) as launched, ): self.assertEqual(0, inventory_core._lifecycle_command(args)) + # The session's launch theme rides on every reopen so the window + # keeps its color identity; the color is the stable identity hash + # for this uuid. + expected_theme = inventory_core.session_color("codex", uuid_for(2), {}) launched.assert_called_once_with( [ "codex", + # And the kit's tab-title items (K3): a reopen builds its + # own command line, and without them Codex repaints the tab + # from the personal config over the name the kit just + # wrote, leaving that one window disagreeing with the list. + "-c", + 'tui.terminal_title=["activity", "thread"]', + "-c", + f'tui.theme="sk-{expected_theme}"', "-c", "check_for_update_on_startup=false", "--no-alt-screen", @@ -3353,6 +7871,342 @@ def test_reopen_executes_only_generation_bound_exact_recovery(self) -> None: assert retained is not None self.assertTrue(retained["user_input_after_exit"]) + def _reopen_state(self, state_dir: Path, boot_id: str) -> None: + lifecycle_state.record_provider_exit( + state_dir, + session_id="main2", + boot_id=boot_id, + shell_pid=123, + shell_start_ticks=456, + provider="codex", + exit_code=3, + input_tracking=True, + now_monotonic_ns=500, + ) + + def _reopen_row(self, state_dir: Path, **overrides: Any) -> dict[str, Any]: + item = { + "provider": "shell", + "exited_provider": "codex", + "shpool_shell": {"pid": 123, "process_start_ticks": 456}, + "recovery": inventory_core.recovery_spec( + "codex", uuid_for(2), str(state_dir) + ), + } + item.update(overrides) + return item + + def _run_reopen( + self, + state_dir: Path, + boot_id: str, + item: dict[str, Any] | None, + *, + completed: subprocess.CompletedProcess | None = None, + daemon_generation: Any = "unchanged", + ) -> tuple[int | None, str, mock.Mock]: + """Drive the reopen verb; return its status or the refusal it raised.""" + args = argparse.Namespace(lifecycle_action="reopen") + launched = mock.Mock( + return_value=completed or subprocess.CompletedProcess(["codex"], 0) + ) + generation = ( + mock.DEFAULT if daemon_generation == "unchanged" else daemon_generation + ) + with ( + mock.patch.object( + inventory_core, + "_lifecycle_environment", + return_value=(state_dir, "main2", boot_id, 123, 456), + ), + mock.patch.object(inventory_core, "_prove_lifecycle_caller"), + mock.patch.object( + inventory_core, + "_prove_unchanged_daemon_generation", + side_effect=(None if generation is mock.DEFAULT else generation), + ), + mock.patch.object( + inventory_core, "load_config", return_value={"state_dir": state_dir} + ), + mock.patch.object( + inventory_core, + "snapshot", + return_value={"sessions": [item] if item else []}, + ), + mock.patch.object( + inventory_core, "guard_live_inventory", return_value=True + ), + mock.patch.object(inventory_core, "lookup", return_value=item), + mock.patch.object(inventory_core.subprocess, "run", launched), + ): + try: + return inventory_core._lifecycle_command(args), "", launched + except inventory_core.CollectionError as exc: + return None, str(exc), launched + + def test_reopen_reports_the_reopened_providers_own_outcome(self) -> None: + # The managed shell has to make the same clean-close / crash-menu + # decision for a reopened conversation that it makes for a first exit, + # and it can only make it from what this verb returns. Reporting + # success either way redrew the menu after a clean `/exit`. + boot_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + for provider_status, expected in ( + (0, 0), + (3, inventory_core.LIFECYCLE_REOPENED_PROVIDER_CRASHED), + (137, inventory_core.LIFECYCLE_REOPENED_PROVIDER_CRASHED), + ): + with self.subTest(provider_status=provider_status): + with tempfile.TemporaryDirectory( + prefix=".lifecycle-reopen-status-", dir=REPO + ) as raw: + state_dir = Path(raw) + self._reopen_state(state_dir, boot_id) + status, refusal, launched = self._run_reopen( + state_dir, + boot_id, + self._reopen_row(state_dir), + completed=subprocess.CompletedProcess( + ["codex"], provider_status + ), + ) + self.assertEqual("", refusal) + self.assertEqual(expected, status) + launched.assert_called_once() + retained = lifecycle_state.load_state(state_dir, "main2") + assert retained is not None + self.assertEqual(provider_status, retained["exit_code"]) + + def test_reopen_refuses_a_daemon_that_restarted_before_the_launch( + self, + ) -> None: + # The snapshot is evidence with an age. A daemon restart between it and + # the launch would reopen into a terminal the inventory no longer + # describes, so nothing may start after that check fails. + boot_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + with tempfile.TemporaryDirectory( + prefix=".lifecycle-reopen-daemon-", dir=REPO + ) as raw: + state_dir = Path(raw) + self._reopen_state(state_dir, boot_id) + status, refusal, launched = self._run_reopen( + state_dir, + boot_id, + self._reopen_row(state_dir), + daemon_generation=inventory_core.CollectionError( + "the shpool daemon generation changed; nothing reopened" + ), + ) + self.assertIsNone(status) + self.assertIn("daemon generation changed", refusal) + launched.assert_not_called() + + def test_daemon_revalidation_compares_the_live_generation(self) -> None: + # The guard itself, against a process table rather than a mock. + table = { + 10: process(10, 1, "shpool", cmdline=["/usr/bin/shpool", "daemon"]), + } + with mock.patch.object( + inventory_core, "scan_process_table", return_value=table + ): + inventory_core._prove_unchanged_daemon_generation( + {"daemon_generation": {"pid": 10, "process_start_ticks": 100}} + ) + for changed in ( + {"pid": 10, "process_start_ticks": 999}, + {"pid": 11, "process_start_ticks": 100}, + None, + ): + with self.subTest(changed=changed): + with self.assertRaises(inventory_core.CollectionError) as caught: + inventory_core._prove_unchanged_daemon_generation( + {"daemon_generation": changed} + ) + self.assertIn("daemon generation changed", str(caught.exception)) + + def test_reopen_names_each_refusal_it_can_reach(self) -> None: + # A refusal is the whole answer the operator gets, and the menu prints + # it once before leaving, so each condition has to name itself. + boot_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + cases = ( + (None, "no longer in the live session list"), + ( + { + "provider": "shell", + "exited_provider": "codex", + "shpool_shell": {"pid": 999, "process_start_ticks": 456}, + }, + "not the one that recorded the exit", + ), + ( + { + "provider": "shell", + "shpool_shell": {"pid": 123, "process_start_ticks": 456}, + }, + "the recorded exit was not the provider this terminal last ran", + ), + ( + { + "provider": "shell", + "exited_provider": "codex", + "shpool_shell": {"pid": 123, "process_start_ticks": 456}, + }, + "no conversation was recorded for this terminal", + ), + ) + for item, expected in cases: + with self.subTest(expected=expected): + with tempfile.TemporaryDirectory( + prefix=".lifecycle-reopen-refusal-", dir=REPO + ) as raw: + state_dir = Path(raw) + self._reopen_state(state_dir, boot_id) + status, refusal, launched = self._run_reopen( + state_dir, boot_id, item + ) + self.assertIsNone(status) + self.assertIn(expected, refusal) + self.assertIn("nothing reopened", refusal) + launched.assert_not_called() + + def test_reopen_refuses_without_lifecycle_state(self) -> None: + # Nothing exited here as far as the private record is concerned. + boot_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + with tempfile.TemporaryDirectory( + prefix=".lifecycle-reopen-missing-", dir=REPO + ) as raw: + state_dir = Path(raw) + status, refusal, launched = self._run_reopen( + state_dir, boot_id, self._reopen_row(state_dir) + ) + self.assertIsNone(status) + self.assertIn("lifecycle state is unavailable", refusal) + launched.assert_not_called() + + def test_reopen_refuses_a_corrupt_lifecycle_record(self) -> None: + # A record that cannot be trusted is not evidence that a provider + # exited, and it must not become a launch. + boot_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + with tempfile.TemporaryDirectory( + prefix=".lifecycle-reopen-corrupt-", dir=REPO + ) as raw: + state_dir = Path(raw) + self._reopen_state(state_dir, boot_id) + document = sorted( + path + for path in (state_dir / "lifecycle").glob("*.json") + if path.name != "key.json" and not path.name.endswith(".exact.json") + )[0] + document.write_text("{not json", encoding="utf-8") + status, refusal, launched = self._run_reopen( + state_dir, boot_id, self._reopen_row(state_dir) + ) + self.assertIsNone(status) + self.assertIn("state file is invalid JSON", refusal) + launched.assert_not_called() + + def test_reopen_refuses_a_recovery_whose_uuid_is_missing(self) -> None: + # Exact identity is never synthesized: an absent uuid is a refusal. + boot_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + with tempfile.TemporaryDirectory( + prefix=".lifecycle-reopen-nouuid-", dir=REPO + ) as raw: + state_dir = Path(raw) + self._reopen_state(state_dir, boot_id) + recovery = dict( + inventory_core.recovery_spec("codex", uuid_for(2), str(state_dir)) + ) + recovery["uuid"] = None + status, refusal, launched = self._run_reopen( + state_dir, boot_id, self._reopen_row(state_dir, recovery=recovery) + ) + self.assertIsNone(status) + self.assertIn("the recorded conversation has no usable id", refusal) + launched.assert_not_called() + + def test_claude_reopen_passes_hydrated_name_to_native_cli(self) -> None: + with tempfile.TemporaryDirectory( + prefix=".lifecycle-claude-reopen-", dir=REPO + ) as raw: + base = Path(raw) + state_dir = base / "state" + home = base / "home" + transcript_dir = home / ".claude" / "projects" / "-srv-project" + state_dir.mkdir() + transcript_dir.mkdir(parents=True) + exact = uuid_for(3) + (transcript_dir / f"{exact}.jsonl").write_text( + json.dumps( + { + "type": "agent-name", + "agentName": "Egypt Presidents", + "sessionId": exact, + } + ) + + "\n", + encoding="utf-8", + ) + boot_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + lifecycle_state.record_provider_exit( + state_dir, + session_id="main3", + boot_id=boot_id, + shell_pid=123, + shell_start_ticks=456, + provider="claude", + exit_code=0, + input_tracking=True, + now_monotonic_ns=500, + ) + recovery = inventory_core.recovery_spec("claude", exact, str(state_dir)) + item = { + "provider": "shell", + "exited_provider": "claude", + "shpool_shell": { + "pid": 123, + "process_start_ticks": 456, + }, + "recovery": recovery, + } + args = argparse.Namespace(lifecycle_action="reopen") + completed = subprocess.CompletedProcess(recovery["argv"], 0) + with ( + mock.patch.dict(os.environ, {"HOME": os.fspath(home)}), + mock.patch.object( + inventory_core, + "_lifecycle_environment", + return_value=(state_dir, "main3", boot_id, 123, 456), + ), + mock.patch.object(inventory_core, "_prove_lifecycle_caller"), + mock.patch.object(inventory_core, "_prove_unchanged_daemon_generation"), + mock.patch.object( + inventory_core, "load_config", return_value={"state_dir": state_dir} + ), + mock.patch.object( + inventory_core, "snapshot", return_value={"sessions": [item]} + ), + mock.patch.object( + inventory_core, "guard_live_inventory", return_value=True + ), + mock.patch.object(inventory_core, "lookup", return_value=item), + mock.patch.object( + inventory_core.subprocess, + "run", + return_value=completed, + ) as launched, + ): + self.assertEqual(0, inventory_core._lifecycle_command(args)) + launched.assert_called_once_with( + [ + "claude", + "--name", + "Egypt Presidents", + "--resume", + exact, + ], + cwd=str(state_dir), + check=False, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_journal_render.py b/tests/test_journal_render.py new file mode 100644 index 0000000..ad51c57 --- /dev/null +++ b/tests/test_journal_render.py @@ -0,0 +1,575 @@ +"""Tests for the journal VT renderer. + +The renderer exists because a raw TUI capture played back as plain text +braids repainted drafts of the same screen line into mush. The braid case at +the bottom of this file reproduces an artifact of exactly that shape +("WidgetiSampledTwohwasiflaggedrbyua") from the mechanism that produced it, +and asserts the renderer reads back clean. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import time +import unittest + +from tests.support import REPO + + +MODULE_PATH = REPO / "lib" / "sessionkit_inventory" / "journal_render.py" +_SPEC = importlib.util.spec_from_file_location("journal_render", MODULE_PATH) +journal_render = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(journal_render) + +JournalRenderer = journal_render.JournalRenderer + +# The flattener the renderer replaces: strip the escapes, keep every byte the +# terminal was told to draw. Used as the negative control in the braid tests. +NAIVE_STRIP = re.compile( + rb"\x1b\[[0-9;?]*[ -/]*[@-~]" + rb"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" + rb"|\x1b." +) + + +def naive_text(stream: bytes) -> str: + return NAIVE_STRIP.sub(b"", stream).decode("utf-8", "replace") + + +def render(stream: bytes, width: int = 40, height: int = 8) -> list[str]: + """Everything a reader sees: settled history plus the live screen.""" + renderer = JournalRenderer(width=width, height=height) + renderer.feed(stream) + return renderer.take_settled() + renderer.live_rows() + + +def render_chunked(stream: bytes, size: int, width: int = 40, height: int = 8): + renderer = JournalRenderer(width=width, height=height) + settled = [] + for start in range(0, len(stream), size): + renderer.feed(stream[start : start + size]) + settled.extend(renderer.take_settled()) + return settled, renderer.live_rows() + + +class ScreenBehaviourTests(unittest.TestCase): + def test_repainted_line_keeps_only_the_final_draft(self): + stream = ( + b"keep me\r\n" + b"draft one\r\x1b[K" + b"draft two is longer\r\x1b[K" + b"final draft\r\n" + ) + lines = render(stream) + self.assertIn("final draft", lines) + self.assertNotIn("draft one", lines) + self.assertNotIn("draft two is longer", lines) + # The naive flattener is what braids: every draft survives in it. + self.assertIn("draft one", naive_text(stream)) + + def test_cursor_up_repaint_rewrites_an_earlier_line(self): + stream = ( + b"alpha\r\n" + b"bravo\r\n" + b"charlie\r\n" + b"\x1b[2A\r\x1b[Kbravo repainted\r\n" + ) + lines = render(stream) + self.assertEqual(["alpha", "bravo repainted", "charlie"], lines) + + def test_partial_overwrite_leaves_the_untouched_tail(self): + stream = b"abcdefghij\r\x1b[3CXY\r\n" + self.assertEqual(["abcXYfghij"], render(stream)) + + def test_erase_display_two_clears_and_settles_nothing(self): + stream = b"gone one\r\ngone two\r\n\x1b[2J\x1b[Hkept\r\n" + lines = render(stream) + self.assertEqual(["kept"], lines) + + def test_erase_display_zero_clears_below_the_cursor(self): + stream = b"one\r\ntwo\r\nthree\r\n\x1b[2;1H\x1b[0Jrewritten\r\n" + self.assertEqual(["one", "rewritten"], render(stream)) + + def test_alt_screen_excursion_never_reaches_the_output(self): + stream = ( + b"before the picker\r\n" + b"\x1b[?1049h" + b"PICKER ROW ONE\r\nPICKER ROW TWO\r\n" + b"filler\r\n" * 20 + + b"\x1b[?1049l" + b"after the picker\r\n" + ) + lines = render(stream) + self.assertIn("before the picker", lines) + self.assertIn("after the picker", lines) + for line in lines: + self.assertNotIn("PICKER", line) + self.assertNotIn("filler", line) + + def test_alt_screen_restores_the_main_screen_underneath(self): + stream = b"main row\r\n\x1b[?1049hoverlay\x1b[?1049l\x1b[10;1Hbelow\r\n" + lines = render(stream) + self.assertIn("main row", lines) + self.assertNotIn("overlay", "".join(lines)) + + def test_scrolled_lines_settle_in_order(self): + stream = b"".join(f"line {index}\r\n".encode() for index in range(20)) + renderer = JournalRenderer(width=40, height=8) + renderer.feed(stream) + settled = renderer.take_settled() + self.assertEqual(["line %d" % index for index in range(13)], settled) + self.assertEqual( + ["line %d" % index for index in range(13, 20)], renderer.live_rows() + ) + + def test_live_rows_are_not_settled(self): + renderer = JournalRenderer(width=40, height=8) + renderer.feed(b"still being drawn") + self.assertEqual([], renderer.take_settled()) + self.assertEqual(["still being drawn"], renderer.live_rows()) + + def test_wrap_at_width(self): + stream = b"x" * 45 + b"\r\n" + self.assertEqual(["x" * 40, "x" * 5], render(stream, width=40)) + + def test_scroll_region_lines_do_not_settle(self): + stream = b"\x1b[3;6r\x1b[3;1H" + b"".join( + f"region {index}\r\n".encode() for index in range(12) + ) + renderer = JournalRenderer(width=40, height=8) + renderer.feed(stream) + self.assertEqual([], renderer.take_settled()) + + def test_insert_and_delete_characters(self): + self.assertEqual(["ab cdef"], render(b"abcdef\r\x1b[3G\x1b[2@")) + self.assertEqual(["abef"], render(b"abcdef\r\x1b[3G\x1b[2P")) + + def test_insert_and_delete_lines(self): + stream = b"one\r\ntwo\r\nthree\r\n\x1b[2;1H\x1b[L" + renderer = JournalRenderer(width=40, height=8) + renderer.feed(stream) + self.assertEqual(["one", "", "two", "three"], renderer.live_rows()) + renderer = JournalRenderer(width=40, height=8) + renderer.feed(b"one\r\ntwo\r\nthree\r\n\x1b[2;1H\x1b[M") + self.assertEqual(["one", "three"], renderer.live_rows()) + + def test_tabs_and_backspace(self): + self.assertEqual(["a b"], render(b"a\tb")) + self.assertEqual(["ac"], render(b"ab\bc")) + + def test_save_and_restore_cursor(self): + self.assertEqual(["Xbcd"], render(b"\x1b[sabcd\r\x1b[uX")) + self.assertEqual(["Xbcd"], render(b"\x1b7abcd\r\x1b8X")) + + +class EscapeSwallowingTests(unittest.TestCase): + def test_osc_title_is_swallowed(self): + stream = b"\x1b]0;a window title\x07visible\r\n" + self.assertEqual(["visible"], render(stream)) + + def test_osc_with_string_terminator_is_swallowed(self): + stream = b"\x1b]777;notify;body\x1b\\visible\r\n" + self.assertEqual(["visible"], render(stream)) + + def test_unknown_csi_and_private_modes_are_swallowed(self): + stream = ( + b"\x1b[?2026h\x1b[?25l\x1b[>4;2m\x1b[38;2;153;153;153m" + b"visible\x1b[39m\x1b[?25h\x1b[?2026l\r\n" + ) + self.assertEqual(["visible"], render(stream)) + + def test_charset_and_single_character_escapes_are_swallowed(self): + self.assertEqual(["visible"], render(b"\x1b(Bvis\x1b=ible\x1b>\r\n")) + + def test_dcs_string_is_swallowed(self): + self.assertEqual(["visible"], render(b"\x1bP+q544e\x1b\\visible\r\n")) + + def test_no_escape_byte_ever_reaches_the_output(self): + stream = b"\x1b[1;31mred\x1b[0m \x1b]0;t\x07 \x1bZ tail\r\n" + for line in render(stream): + self.assertNotIn("\x1b", line) + + def test_truncated_escape_at_the_end_is_held_not_leaked(self): + renderer = JournalRenderer(width=40, height=8) + renderer.feed(b"visible\x1b[38;2;1") + self.assertEqual(["visible"], renderer.live_rows()) + renderer.feed(b";2m more\r\n") + self.assertEqual(["visible more"], renderer.take_settled() + renderer.live_rows()) + + def test_stray_escape_byte_is_dropped(self): + self.assertEqual(["ab"], render(b"a\x1b\x00b")) + + +class IncrementalTests(unittest.TestCase): + STREAM = ( + b"\x1b]0;title\x07" + b"header line\r\n" + + b"".join(f"row {index} \xe2\x94\x82 value\r\n".encode() for index in range(30)) + + b"\x1b[5A\r\x1b[Krepainted row\r\n" + b"\x1b[?1049hoverlay\x1b[?1049l" + b"tail \xc3\xa9\xe2\x80\x94 done\r\n" + ) + + def test_seven_byte_chunking_matches_one_shot(self): + one_shot = JournalRenderer(width=60, height=10) + one_shot.feed(self.STREAM) + expected_settled = one_shot.take_settled() + expected_live = one_shot.live_rows() + for size in (1, 2, 3, 7, 13, 64, 997): + settled, live = render_chunked(self.STREAM, size, width=60, height=10) + self.assertEqual(expected_settled, settled, f"chunk size {size}") + self.assertEqual(expected_live, live, f"chunk size {size}") + + def test_utf8_split_across_chunks(self): + stream = "café, naïve ☃\r\n".encode("utf-8") + for size in range(1, 9): + settled, live = render_chunked(stream, size) + self.assertEqual(["café, naïve ☃"], settled + live, f"chunk size {size}") + + def test_checkpoint_resume_matches_uninterrupted_feed(self): + one_shot = JournalRenderer(width=60, height=10) + one_shot.feed(self.STREAM) + expected = one_shot.take_settled() + one_shot.live_rows() + + renderer = JournalRenderer(width=60, height=10) + settled = [] + for start in range(0, len(self.STREAM), 11): + renderer.feed(self.STREAM[start : start + 11]) + settled.extend(renderer.take_settled()) + state = json.loads(json.dumps(renderer.checkpoint())) + renderer = JournalRenderer.resume(state) + self.assertEqual(expected, settled + renderer.live_rows()) + + def test_checkpoint_is_json_safe_and_carries_partial_state(self): + renderer = JournalRenderer(width=60, height=10) + renderer.feed(b"partial \xe2\x94") # incomplete UTF-8 + renderer.feed(b"\x82 \x1b[38;2") # incomplete escape + state = renderer.checkpoint() + json.dumps(state) + self.assertEqual(journal_render.CHECKPOINT_VERSION, state["version"]) + self.assertTrue(state["pending"]) + resumed = JournalRenderer.resume(state) + resumed.feed(b";1;2mtail\r\n") + self.assertEqual(["partial │ tail"], resumed.take_settled() + resumed.live_rows()) + + def test_resume_of_none_starts_fresh(self): + renderer = JournalRenderer.resume(None) + self.assertEqual(journal_render.DEFAULT_WIDTH, renderer.width) + + def test_resume_rejects_a_foreign_version(self): + with self.assertRaises(ValueError): + JournalRenderer.resume({"version": 999}) + + +class CommandLineTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.addCleanup(self.temp.cleanup) + self.journal = self.root / "journal" + self.journal.mkdir() + self.out = self.root / "sidecar.txt" + self.state = self.root / "state.json" + + def run_cli(self, mode: str, *extra: str): + completed = subprocess.run( + [ + sys.executable, + str(MODULE_PATH), + mode, + "--journal", + str(self.journal), + "--out", + str(self.out), + "--state", + str(self.state), + *extra, + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(0, completed.returncode, completed.stderr) + return json.loads(completed.stdout) + + def test_render_appends_only_new_bytes_across_runs(self): + segment = self.journal / "segment-000001.raw" + segment.write_bytes(b"".join(f"line {i}\r\n".encode() for i in range(80))) + first = self.run_cli("render") + self.assertGreater(first["settled_lines"], 0) + body = self.out.read_text(encoding="utf-8") + self.assertIn("line 0\n", body) + + with open(segment, "ab") as handle: + handle.write(b"".join(f"more {i}\r\n".encode() for i in range(80))) + second = self.run_cli("render") + self.assertGreater(second["settled_lines"], 0) + grown = self.out.read_text(encoding="utf-8") + self.assertTrue(grown.startswith(body)) + self.assertIn("more 0\n", grown) + # No duplicated history: each source line settles exactly once. + self.assertEqual(1, grown.count("line 7\n")) + + def test_second_segment_continues_the_same_screen(self): + (self.journal / "segment-000001.raw").write_bytes(b"kept\r\ndraft one\r") + self.run_cli("render") + (self.journal / "segment-000002.raw").write_bytes( + b"\x1b[Kdraft two\r\n" + b"pad\r\n" * 80 + ) + self.run_cli("render") + body = self.out.read_text(encoding="utf-8") + self.assertIn("draft two\n", body) + self.assertNotIn("draft one", body) + + def test_single_file_journal(self): + target = self.root / "one.raw" + target.write_bytes(b"solo\r\n" + b"pad\r\n" * 80) + completed = subprocess.run( + [ + sys.executable, + str(MODULE_PATH), + "render", + "--journal", + str(target), + "--out", + str(self.out), + "--state", + str(self.state), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("solo\n", self.out.read_text(encoding="utf-8")) + + def test_max_bytes_limits_a_single_run(self): + segment = self.journal / "segment-000001.raw" + segment.write_bytes(b"".join(f"line {i}\r\n".encode() for i in range(400))) + first = self.run_cli("render", "--max-bytes", "512") + self.assertEqual(512, first["bytes_read"]) + self.assertEqual(512, first["byte_offset"]) + second = self.run_cli("render") + self.assertEqual(segment.stat().st_size - 512, second["bytes_read"]) + + def test_flush_appends_a_live_block_that_is_not_committed(self): + segment = self.journal / "segment-000001.raw" + segment.write_bytes(b"settled\r\n" * 80 + b"live and still drawing") + result = self.run_cli("flush") + body = self.out.read_text(encoding="utf-8") + self.assertIn(journal_render.LIVE_MARKER_PREFIX, body) + self.assertIn("live and still drawing", body) + self.assertGreaterEqual(result["live_lines"], 1) + state = json.loads(self.state.read_text(encoding="utf-8")) + self.assertLess(state["sidecar_bytes"], len(body.encode("utf-8"))) + + # The next run drops the stale live block instead of duplicating it. + again = self.run_cli("render") + self.assertEqual(0, again["bytes_read"]) + refreshed = self.out.read_text(encoding="utf-8") + self.assertNotIn(journal_render.LIVE_MARKER_PREFIX, refreshed) + self.assertNotIn("live and still drawing", refreshed) + # ...and a run with nothing new to read keeps the settled history. + self.assertIn("settled\n", refreshed) + self.assertEqual( + len(refreshed.encode("utf-8")), + json.loads(self.state.read_text(encoding="utf-8"))["sidecar_bytes"], + ) + + def test_truncated_journal_restarts_cleanly(self): + segment = self.journal / "segment-000001.raw" + segment.write_bytes(b"".join(f"old {i}\r\n".encode() for i in range(80))) + self.run_cli("render") + segment.write_bytes(b"".join(f"new {i}\r\n".encode() for i in range(80))) + result = self.run_cli("render") + self.assertTrue(result["restarted"]) + body = self.out.read_text(encoding="utf-8") + self.assertIn("new 0\n", body) + self.assertNotIn("old 0\n", body) + + def test_missing_journal_reports_an_error(self): + completed = subprocess.run( + [ + sys.executable, + str(MODULE_PATH), + "render", + "--journal", + str(self.root / "nope"), + "--out", + str(self.out), + "--state", + str(self.state), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(2, completed.returncode) + + +# An example line shaped exactly like the one that was seen braided, and the +# row-interleaved repaint that produced the braid. Claude Code draws a block +# of rows by jumping between them mid-line, so a flattener that ignores cursor +# motion splices the neighbouring row's characters into this one at exactly +# the columns the sentence skipped. +CLEAN_SENTENCE = ( + 'Widget Sample Two was flagged by a "repeat entry" routine on 2020-01-02 ' + "(job group B_20200102)." +) +OPERATOR_BRAID = ( + 'WidgetiSampledTwohwasiflaggedrbyua "repeatyentry"lroutine.on 2020-01-02 ' + "(job groupB_20200102)." +) +NEIGHBOUR_CHARS = "idhiruyl." + +# One entry per gap between the sentence's words, aligned against the captured +# paste with difflib. Three real behaviours show up there: +# "<char>" the TUI dropped a row, painted one cell, and came back a column on +# " " an ordinary space byte +# "" the column was skipped with a cursor-forward and never painted +GAP_PLAN = ("i", "d", "h", "i", "r", "u", " ", "y", "l", ".", " ", " ", " ", "") + + +def braided_stream() -> bytes: + """Emit CLEAN_SENTENCE the way the capture that braided it did.""" + out = bytearray(b"context row\r\n") + words = CLEAN_SENTENCE.split(" ") + assert len(words) == len(GAP_PLAN) + 1 + for index, word in enumerate(words): + out += word.encode("utf-8") + if index >= len(GAP_PLAN): + break + action = GAP_PLAN[index] + if action == " ": + out += b" " + elif action == "": + out += b"\x1b[C" + else: + out += b"\x1b[B" + action.encode("utf-8") + b"\x1b[A" + out += b"\r\n\r\n" + return bytes(out) + + +class BraidGoldenTests(unittest.TestCase): + def test_the_generator_reproduces_the_operator_artifact(self): + self.assertIn(OPERATOR_BRAID, naive_text(braided_stream())) + + def test_renderer_reads_the_sentence_back_clean(self): + lines = render(braided_stream(), width=120, height=8) + joined = "\n".join(lines) + self.assertIn("Widget Sample Two was flagged", joined) + self.assertIn(CLEAN_SENTENCE, joined) + self.assertNotIn("WidgetiSampledTwo", joined) + self.assertEqual(0, joined.count("WidgetiSampledTwo")) + + def test_the_neighbouring_row_keeps_its_own_characters(self): + lines = render(braided_stream(), width=120, height=8) + allowed = set(NEIGHBOUR_CHARS) | {" "} + neighbour = [ + line + for line in lines + if line.strip() and set(line.strip()) <= allowed + ] + self.assertTrue(neighbour, lines) + self.assertEqual( + NEIGHBOUR_CHARS, "".join(neighbour[0].split()), neighbour[0] + ) + + +class RealJournalTests(unittest.TestCase): + """Invariants over a real capture, skipped when the box has none.""" + + BUDGET = 8 * 1024 * 1024 + + # A capture only exercises the renderer if it repaints, and cursor-up is + # the idiom that does it. Plain shell logs are skipped, not asserted on. + REPAINT = re.compile(rb"\x1b\[[0-9]*A") + + @classmethod + def journal(cls) -> Path | None: + override = os.environ.get("SESSION_KIT_GOLDEN_JOURNAL") + if override: + candidate = Path(override) + return candidate if candidate.exists() else None + root = Path( + os.environ.get("SESSION_KIT_JOURNAL_DIR") + or Path.home() / ".local" / "state" / "shpool-journal" + ) + if not root.is_dir(): + return None + found = [path for path in root.glob("*.raw") if path.stat().st_size > 1 << 20] + found += [ + path + for path in root.glob("*/segment-*.raw") + if path.stat().st_size > 1 << 20 + ] + for path in sorted(found, key=lambda item: item.stat().st_size): + try: + with open(path, "rb") as handle: + head = handle.read(1 << 20) + except OSError: + continue + if cls.REPAINT.search(head): + return path + return None + + def setUp(self): + path = self.journal() + if path is None: + self.skipTest("no real TUI journal capture on this box") + self.path = path + with open(path, "rb") as handle: + self.data = handle.read(self.BUDGET) + + def test_render_is_fast_enough_for_a_background_tick(self): + renderer = JournalRenderer() + started = time.monotonic() + renderer.feed(self.data) + settled = renderer.take_settled() + elapsed = time.monotonic() - started + rate = len(self.data) / elapsed / 1e6 + self.assertGreater(settled, [], "a real capture should settle lines") + self.assertGreater(rate, 2.0, f"only {rate:.2f} MB/s") + + def test_no_escape_bytes_survive_a_real_capture(self): + renderer = JournalRenderer() + renderer.feed(self.data) + for line in renderer.take_settled() + renderer.live_rows(): + self.assertNotIn("\x1b", line) + self.assertLessEqual(len(line), renderer.width) + + def test_real_capture_is_chunk_invariant(self): + sample = self.data[: 1 << 20] + one_shot = JournalRenderer() + one_shot.feed(sample) + expected = one_shot.take_settled() + chunked, _ = render_chunked( + sample, 7, width=journal_render.DEFAULT_WIDTH, + height=journal_render.DEFAULT_HEIGHT, + ) + self.assertEqual(expected, chunked) + + def test_renderer_recovers_text_the_naive_flattener_destroys(self): + renderer = JournalRenderer() + renderer.feed(self.data) + lines = renderer.take_settled() + flattened = naive_text(self.data) + recovered = [ + line.strip() + for line in lines + if len(line.strip()) >= 55 and line.strip() not in flattened + ] + self.assertTrue( + recovered, + "a real TUI capture should contain lines only cursor emulation recovers", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_launch_log.py b/tests/test_launch_log.py new file mode 100644 index 0000000..e0bf0e2 --- /dev/null +++ b/tests/test_launch_log.py @@ -0,0 +1,233 @@ +"""Every launch decision reaches the action log, named by session. + +Three launches started nothing one night. Each one printed its reason to the +session's own screen and nowhere else, so the only way to learn why was to +attach to the session before somebody closed it, and by the time anyone +asked, all three were closed. Hours went into reconstructing an answer the +machine had already computed and thrown away. + +The shell now records what it decided and which session it decided about, and +`sp new` records that it created one at all, a verb that makes state and +leaves no trace of having run cannot answer "what made this session". +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import tempfile +import unittest + +from tests.support import REPO, run + + +BASHRC = REPO / "bashrc/shpool.bashrc" +CORE = REPO / "lib/session_inventory.py" +SESSION = "s20260812-000000-424242" + + +class LaunchLogTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory( + prefix=".launch-log-", dir=REPO.parent + ) + self.root = Path(self.temporary.name) + self.home = self.root / "home" + self.home.mkdir(mode=0o700) + self.state = self.root / "state" + self.state.mkdir(mode=0o700) + self.start = self.root / "start" + self.start.mkdir(mode=0o700) + self.project = self.root / "project" + self.project.mkdir() + self.bin = self.root / "bin" + self.bin.mkdir() + self.boot = self.root / "boot-id" + self.boot.write_text("fixture-boot\n", encoding="utf-8") + self.provider_log = self.root / "provider.log" + + def tearDown(self) -> None: + self.temporary.cleanup() + + def write(self, name: str, text: str) -> Path: + path = self.start / name + path.write_text(text, encoding="utf-8") + path.chmod(0o600) + return path + + def stub_claude(self) -> None: + executable = self.bin / "claude" + executable.write_text( + "#!/usr/bin/env bash\nprintf 'launched %s\\n' \"$*\" > \"$PROVIDER_LOG\"\n", + encoding="utf-8", + ) + executable.chmod(0o755) + + def environment(self) -> dict: + env = os.environ.copy() + env.update( + { + "HOME": str(self.home), + "PATH": f"{self.bin}:{os.environ['PATH']}", + "PROVIDER_LOG": str(self.provider_log), + "SESSION_KIT_BOOT_ID_FILE": str(self.boot), + "SESSION_KIT_INVENTORY_CORE": str(CORE), + "SESSION_KIT_START_DIR": str(self.start), + "SESSION_KIT_STATE_DIR": str(self.state), + "XDG_STATE_HOME": str(self.state), + "SHPOOL_JOURNAL": "disabled", + "SHPOOL_SESSION_NAME": SESSION, + } + ) + return env + + def launch(self, inner: str) -> None: + completed = run( + [ + "bash", + "-c", + 'bash --noprofile --norc -ic "$1" launch-inner "$2" "$3" "$4"', + "launch-log-test", + inner, + str(BASHRC), + str(self.project), + str(self.start), + ], + env=self.environment(), + ) + self.assertEqual(0, completed.returncode, completed.stderr) + + def entries(self) -> list[dict]: + # SESSION_KIT_STATE_DIR is the state root itself; without it the kit + # falls back to $XDG_STATE_HOME/session-kit. Read whichever exists so + # the test pins the records, not the layout. + path = self.state / "action-events.jsonl" + if not path.exists(): + path = self.state / "session-kit" / "action-events.jsonl" + if not path.exists(): + return [] + return [ + json.loads(line) + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + def launch_outcomes(self) -> list[tuple[str, str]]: + return [ + (item["outcome"], item.get("session", "")) + for item in self.entries() + if item.get("action") == "launch" + ] + + # -- the refusals ----------------------------------------------------- + def test_an_incomplete_record_says_so_on_the_log(self) -> None: + self.write(SESSION, f"claude\t{self.project}\t\tnew\n") + self.launch('source "$1"') + self.assertIn(("refused_record_incomplete", SESSION), self.launch_outcomes()) + + def test_a_shell_launch_is_not_reported_as_an_incomplete_record(self) -> None: + """A plain shell is what `sp new shell` was asked for. + + It arms its record and clears it once the shell is open, so the + sidecar is legitimately absent -- and every healthy shell session + opened with a line calling itself broken. + """ + self.write(SESSION, f"shell\t{self.project}\t\tnew\n") + self.launch('source "$1"') + self.assertEqual([], self.launch_outcomes()) + + def test_a_generation_that_does_not_match_says_so_on_the_log(self) -> None: + self.stub_claude() + self.write(SESSION, f"claude\t{self.project}\t\tnew\n") + # A shell pid that is not this shell: the exact husk shape. + self.write( + f"{SESSION}.expected", + f"claude\t{self.project}\tfixture-boot\t1\t999999\t1\t999998\t1\t\tnew\n", + ) + self.launch('source "$1"') + self.assertIn( + ("refused_generation_mismatch", SESSION), self.launch_outcomes() + ) + self.assertFalse(self.provider_log.exists()) + + def test_an_account_that_cannot_be_verified_says_so_on_the_log(self) -> None: + self.stub_claude() + inner = ( + 'shell_start=$(awk "{print \\$22}" /proc/$$/stat); ' + 'daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat); ' + 'printf "claude\\t%s\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\t\\tnew\\n" ' + f'"$2" "$$" "$shell_start" "$PPID" "$daemon_start" > "$3/{SESSION}.expected"; ' + f'printf "claude\\tnosuch\\n" > "$3/{SESSION}.account"; ' + f'chmod 600 "$3/{SESSION}.expected" "$3/{SESSION}.account"; source "$1"' + ) + self.write(SESSION, f"claude\t{self.project}\t\tnew\n") + self.launch(inner) + self.assertIn(("refused_account_unsafe", SESSION), self.launch_outcomes()) + self.assertFalse(self.provider_log.exists()) + + def test_a_missing_provider_says_so_on_the_log(self) -> None: + # No stub on PATH: `command -v claude` finds nothing. + inner = ( + 'shell_start=$(awk "{print \\$22}" /proc/$$/stat); ' + 'daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat); ' + 'printf "claude\\t%s\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\t\\tnew\\n" ' + f'"$2" "$$" "$shell_start" "$PPID" "$daemon_start" > "$3/{SESSION}.expected"; ' + f'chmod 600 "$3/{SESSION}.expected"; ' + 'PATH=/usr/bin:/bin; source "$1"' + ) + self.write(SESSION, f"claude\t{self.project}\t\tnew\n") + self.launch(inner) + self.assertIn(("refused_provider_missing", SESSION), self.launch_outcomes()) + + # -- and the launch that works --------------------------------------- + def test_a_launch_that_starts_records_that_it_started(self) -> None: + self.stub_claude() + inner = ( + 'shell_start=$(awk "{print \\$22}" /proc/$$/stat); ' + 'daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat); ' + 'printf "claude\\t%s\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\t\\tnew\\n" ' + f'"$2" "$$" "$shell_start" "$PPID" "$daemon_start" > "$3/{SESSION}.expected"; ' + f'chmod 600 "$3/{SESSION}.expected"; source "$1"' + ) + self.write(SESSION, f"claude\t{self.project}\t\tnew\n") + self.launch(inner) + self.assertIn(("started", SESSION), self.launch_outcomes()) + self.assertTrue(self.provider_log.exists()) + + def test_the_log_keeps_records_that_name_a_session(self) -> None: + """The shell logger rewrites the file; a named record must survive it.""" + from tests.support import run as run_command + + core_env = self.environment() + run_command( + [ + "python3", + str(CORE), + "action-log", + "launch", + "refused_generation_mismatch", + "--session", + SESSION, + ], + env=core_env, + ) + # A second writer rewrites the whole file through the shell logger. + run_command( + [ + "bash", + "-c", + 'source "$1"; SK_STATE_DIR="$2" SK_ACTION_LOG="$2/action-events.jsonl" ' + "sk_log_action picker_exit quit", + "log-test", + str(REPO / "bin/session_kit_common"), + str(self.state), + ], + env=core_env, + ) + outcomes = self.launch_outcomes() + self.assertIn(("refused_generation_mismatch", SESSION), outcomes) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lifecycle_shell.py b/tests/test_lifecycle_shell.py index 780c029..a92ae4e 100644 --- a/tests/test_lifecycle_shell.py +++ b/tests/test_lifecycle_shell.py @@ -3,6 +3,7 @@ import json import os from pathlib import Path +import re import stat import subprocess import tempfile @@ -17,12 +18,62 @@ UUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" +class TimeoutPipelineTests(unittest.TestCase): + def test_timeout_preserves_pipeline_stdin(self) -> None: + command = ( + 'source "$1"; printf "native-color-input\\n" | ' + "sk_timeout 5 bash -c 'read -r value; printf \"%s\\n\" \"$value\"'" + ) + completed = subprocess.run( + [ + "bash", + "-c", + command, + "timeout-test", + str(REPO / "bin" / "session_kit_common"), + ], + cwd=REPO, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertEqual("native-color-input\n", completed.stdout) + + +class PortableMktempTests(unittest.TestCase): + def test_runtime_templates_end_with_replacement_characters(self) -> None: + template_pattern = re.compile( + r'mktemp(?:\s+-d)?\s+"([^"\n]*XXXXXX[^"\n]*)"' + ) + runtime_files = [ + path + for path in (REPO / "bin").iterdir() + if path.is_file() + ] + runtime_files.append(REPO / "tests" / "run") + invalid: list[str] = [] + for path in runtime_files: + text = path.read_text(encoding="utf-8") + for line_number, line in enumerate(text.splitlines(), start=1): + for match in template_pattern.finditer(line): + if not match.group(1).endswith("XXXXXX"): + invalid.append( + f"{path.relative_to(REPO)}:{line_number}:" + f" {match.group(1)}" + ) + self.assertEqual([], invalid) + + def write_executable(path: Path, content: str) -> None: path.write_text(content, encoding="utf-8") path.chmod(0o755) -class ProviderExitShellTests(unittest.TestCase): +class ProviderExitShellHarness(unittest.TestCase): + """Fixture only: launches the bashrc against provider stubs. No tests.""" + def setUp(self) -> None: self.temp = tempfile.TemporaryDirectory( prefix=".lifecycle-shell-", dir=REPO @@ -32,15 +83,19 @@ def setUp(self) -> None: self.state = self.base / "state" self.start = self.base / "start" self.project = self.base / "project" - self.bin = self.base / "bin" + # The bashrc prepends $HOME/.cargo/bin:$HOME/.local/bin:/opt/homebrew/bin + # to PATH. On macOS a real provider in the Homebrew prefix would + # therefore shadow a stub placed anywhere later, so the stub lives in + # the fixture HOME, which that prepend puts first on both platforms. + self.bin = self.home / ".local" / "bin" for path in ( self.home, self.state, self.start, self.project, - self.bin, ): path.mkdir(mode=0o700) + self.bin.mkdir(mode=0o700, parents=True) self.config = self.base / "inventory.json" self.config.write_text( json.dumps( @@ -57,12 +112,66 @@ def setUp(self) -> None: self.boot = self.base / "boot-id" self.boot.write_text(BOOT_ID + "\n", encoding="utf-8") self.provider_log = self.base / "provider.log" + self.account_environment_log = self.base / "account-environment.log" + self.account_profile = self.base / "codex-profile" + self.account_profile.mkdir(mode=0o700) + self.core = CORE + self.environment_overrides: dict[str, str] = {} + # A resumed conversation: the shell is handed the exact UUID in its + # launch record. `without_a_conversation()` switches to the other + # shape, where it never learns one. + self.launch_mode = "resume" + self.launch_uuid = UUID write_executable( self.bin / "codex", - '#!/usr/bin/env bash\nprintf "%s\\n" "$*" >> "$PROVIDER_LOG"\n', + '#!/usr/bin/env bash\nprintf "%s\\n" "$*" >> "$PROVIDER_LOG"\n' + 'if [[ -n ${ACCOUNT_ENV_LOG:-} ]]; then ' + 'printf "%s\\t%s\\t%s\\n" "$CODEX_HOME" "$SESSION_KIT_ACCOUNT_ALIAS" ' + '"$SESSION_KIT_ACCOUNT_CAPABLE" >> "$ACCOUNT_ENV_LOG"; fi\n', + ) + # A FAKE shpool, and it is not optional. The hand-back path runs + # `command shpool detach` (bashrc/shpool.bashrc), which is hardcoded -- + # SESSION_KIT_SHPOOL_CMD does not reach it, deliberately, because a + # session shell must not be redirected by an environment variable. So + # the only lever a fixture has is PATH, and this stub takes it: the + # bashrc prepends "$HOME/.local/bin" with the fixture HOME, which puts + # this ahead of the real binary on both platforms. + # + # Without it these tests ran TWO commands against the REAL daemon on + # its default socket: `shpool detach`, carrying + # SHPOOL_SESSION_NAME=main2 from the fixture, and `shpool list --json` + # from the collector behind `lifecycle reopen`. Nothing was harmed + # only because no live session is called main2 -- luck, not isolation, + # and the wrong thing to leave lying around on a box where fifteen + # agents run this suite. + # + # It answers `list --json` with an empty but VALID payload rather than + # refusing: the reopen guard has to fail on "this terminal is not in + # the list" for a reason the fixture chose, not on whatever the real + # estate happened to contain that minute. + self.shpool_log = self.base / "shpool.log" + write_executable( + self.bin / "shpool", + "#!/usr/bin/env bash\n" + 'printf "%s\\n" "$*" >> "$SHPOOL_LOG"\n' + 'if [[ $1 == list ]]; then printf \'{"sessions": []}\\n\'; exit 0; fi\n' + "exit 1\n", ) def tearDown(self) -> None: + # Every shpool call the fixture made, and it may only ever be the + # detach. A new call reaching this stub is a new way for the suite to + # touch a real session manager, and it should be read before it is + # allowed. + if self.shpool_log.exists(): + calls = sorted( + set(self.shpool_log.read_text(encoding="utf-8").split("\n")) - {""} + ) + self.assertEqual( + [], + [call for call in calls if call not in ("detach", "list --json")], + f"the fixture called shpool unexpectedly: {calls}", + ) self.temp.cleanup() def environment(self) -> dict[str, str]: @@ -75,31 +184,67 @@ def environment(self) -> dict[str, str]: "SHPOOL_JOURNAL": "disabled", "SESSION_KIT_BOOT_ID_FILE": str(self.boot), "SESSION_KIT_CONFIG": str(self.config), - "SESSION_KIT_INVENTORY_CORE": str(CORE), + "SESSION_KIT_INVENTORY_CORE": str(self.core), "SESSION_KIT_START_DIR": str(self.start), "SESSION_KIT_STATE_DIR": str(self.state), "PROVIDER_LOG": str(self.provider_log), + "SHPOOL_LOG": str(self.shpool_log), + "ACCOUNT_ENV_LOG": str(self.account_environment_log), + # `__sk_state_root` is ${XDG_STATE_HOME:-$HOME/.local/state}. + # Inheriting a set XDG_STATE_HOME would send every state write + # the bashrc makes -- provider-bounce, account-switch-requests, + # session-color -- outside this fixture. Pin it. + "XDG_STATE_HOME": str(self.home / ".local" / "state"), + # And these two relocate `closed_sessions.data_dir()`, which + # is the closed-sessions ledger -- the one file this whole + # branch exists to protect. Neither is set on this box, so + # nothing escaped; that is luck, and luck is what the shpool + # stub above was added to stop relying on (review, 2026-08-15). + "XDG_DATA_HOME": str(self.home / ".local" / "share"), + "SESSION_KIT_DATA_DIR": str( + self.home / ".local" / "share" / "session-kit" + ), "PYTHONDONTWRITEBYTECODE": "1", } ) + environment.update(self.environment_overrides) return environment - def launch(self, choices: str) -> subprocess.CompletedProcess[str]: + def launch( + self, choices: str, *, account_alias: str | None = None + ) -> subprocess.CompletedProcess[str]: record = self.start / "main2" record.write_text( - f"codex\t{self.project}\t{UUID}\tresume\n", + f"codex\t{self.project}\t{self.launch_uuid}\t{self.launch_mode}\n", encoding="utf-8", ) + if account_alias is not None: + account = self.start / "main2.account" + account.write_text(f"codex\t{account_alias}\n", encoding="utf-8") + account.chmod(0o600) + # The generation of a process is read from /proc on Linux and from the + # native adapter on Darwin, exactly as the bashrc itself does, so this + # harness exercises the real launch path on both platforms. command = r""" bash --noprofile --norc -ic ' - shell_start=$(awk "{print \$22}" /proc/$$/stat) - parent_start=$(awk "{print \$22}" /proc/$PPID/stat) - printf "%s\t%s\t%s\t1\t%s\t%s\t%s\t%s\t%s\tresume\n" \ - codex "$2" "$4" "$$" "$shell_start" "$PPID" "$parent_start" "$5" \ + # Captured out here on purpose: inside the function, $6 would refer to the + # sixth argument of the function rather than of this script. + inventory_core="$6" + start_ticks() { + if [ -r "/proc/$1/stat" ]; then + awk "{print \$22}" "/proc/$1/stat" + else + python3 "$inventory_core" platform process-info "$1" | cut -f2 + fi + } + shell_start=$(start_ticks $$) + parent_start=$(start_ticks $PPID) + printf "%s\t%s\t%s\t1\t%s\t%s\t%s\t%s\t%s\t%s\n" \ + codex "$2" "$4" "$$" "$shell_start" "$PPID" "$parent_start" "$5" "$7" \ > "$3/main2.expected" source "$1" printf "SOURCE_RETURNED\n" -' lifecycle-inner "$1" "$2" "$3" "$4" "$5" +' lifecycle-inner "$1" "$2" "$3" "$4" "$5" "$6" "$7" """ return subprocess.run( [ @@ -111,7 +256,9 @@ def launch(self, choices: str) -> subprocess.CompletedProcess[str]: str(self.project), str(self.start), BOOT_ID, - UUID, + self.launch_uuid, + str(self.core), + self.launch_mode, ], cwd=REPO, env=self.environment(), @@ -134,38 +281,507 @@ def lifecycle_document(self) -> dict: self.assertEqual(0o600, stat.S_IMODE(candidates[0].stat().st_mode)) return json.loads(candidates[0].read_text(encoding="utf-8")) - def test_provider_exit_stays_in_controlled_menu_until_input_is_recorded( + def reopen_answers(self, status: int) -> Path: + """Make `lifecycle reopen` answer with a fixed outcome. + + 0 is a reopened conversation that ended cleanly, 76 is one that + crashed again, and anything else is a refusal to reopen at all. The + real verb needs a live daemon generation this harness cannot provide, + and the shell's decision is what these tests are about. + """ + log = self.base / "reopen.log" + wrapper = self.base / "reopen-wrapper.py" + wrapper.write_text( + f"""#!/usr/bin/env python3 +import os, pathlib, sys +if sys.argv[1:3] == ["lifecycle", "reopen"]: + with pathlib.Path(os.environ["REOPEN_LOG"]).open("a") as handle: + handle.write("reopen\\n") + raise SystemExit({status}) +os.execv(sys.executable, [sys.executable, os.environ["REAL_CORE"], *sys.argv[1:]]) +""", + encoding="utf-8", + ) + wrapper.chmod(0o700) + self.core = wrapper + self.environment_overrides = { + "REOPEN_LOG": str(log), + "REAL_CORE": str(CORE), + } + return log + + def provider_exit_record_fails(self, times: int = 99) -> Path: + """Make `lifecycle provider-exited` fail its first `times` attempts. + + The record is what makes a row say "provider exited", and only a row + that says it is ever considered by automatic cleanup. A failure here + used to be permanent and silent. + """ + log = self.base / "provider-exited.log" + wrapper = self.base / "exit-record-wrapper.py" + wrapper.write_text( + f"""#!/usr/bin/env python3 +import os, pathlib, sys +if sys.argv[1:3] == ["lifecycle", "provider-exited"]: + counter = pathlib.Path(os.environ["EXIT_RECORD_LOG"]) + seen = len(counter.read_text().split()) if counter.exists() else 0 + with counter.open("a") as handle: + handle.write("attempt\\n") + if seen < {times}: + raise SystemExit(1) +os.execv(sys.executable, [sys.executable, os.environ["REAL_CORE"], *sys.argv[1:]]) +""", + encoding="utf-8", + ) + wrapper.chmod(0o700) + self.core = wrapper + self.environment_overrides = { + "EXIT_RECORD_LOG": str(log), + "REAL_CORE": str(CORE), + } + return log + + def provider_transcript(self, *, uuid: str = UUID, mode: int = 0o600) -> Path: + """The conversation's OWN record, the file a restore actually reads. + + Every close that claims a conversation comes back is claiming this + file exists and can be read. Tests that asserted restorability while + no transcript existed were asserting nothing -- they passed a + `still_readable` stub instead of the real predicate, and the close + path did the same thing in production (found in review, 2026-08-15). + """ + day = self.home / ".codex" / "sessions" / "2026" / "08" / "15" + day.mkdir(parents=True, exist_ok=True) + path = day / f"rollout-2026-08-15T03-00-00-{uuid}.jsonl" + path.write_text( + json.dumps({"type": "session_meta", "payload": {"id": uuid}}) + "\n", + encoding="utf-8", + ) + path.chmod(mode) + return path + + def ledger_cannot_be_written(self) -> Path: + """A closed-sessions append that fails, deterministically. + + A directory where the ledger file belongs makes every append raise + EISDIR -- the same shape as a full disk or a lost mount, without + needing either. + """ + path = self.home / ".local" / "share" / "session-kit" / "closed-sessions.jsonl" + path.parent.mkdir(parents=True, exist_ok=True) + path.mkdir() + return path + + def closed_sessions_list(self) -> list: + """What `sp recover` and the picker actually show, via the real verb. + + Not the ledger file and not a stubbed predicate: the shipped reader, + with the real transcript check, under this fixture's HOME and state. + """ + completed = run( + [CORE, "closed-sessions", "list"], + env=self.environment(), + check=False, + ) + self.assertEqual(0, completed.returncode, completed.stderr) + return json.loads(completed.stdout)["closed"] + + def without_a_conversation(self) -> None: + """Launch as `new`, where the shell never learns a conversation UUID. + + Codex allocates its thread ID inside the TUI, so a session started by + `sp new` runs with an empty conversation until a first-prompt intake + commits one. Its provider-exit record therefore names no conversation, + and that is the state any close path has to cope with: there is + nothing to tombstone and nothing to restore. + """ + self.launch_mode = "new" + self.launch_uuid = "" + + def keep_this_session(self) -> None: + """Mark the session `keep`, the way `keep_session` does, mid-run. + + The lifecycle document does not exist until `lifecycle provider-exited` + writes it, and `record_provider_exit` carries `keep` forward only from + a document of the SAME shell generation. So the marker has to be set + between the exit record and the close -- which is exactly the state a + session is in when somebody typed `keep_session` in it earlier. + + Chains onto whatever core is already installed; call it after + `reopen_answers`. + """ + previous_core = self.core + wrapper = self.base / "keep-wrapper.py" + wrapper.write_text( + f"""#!/usr/bin/env python3 +import os, sys +sys.path.insert(0, {str(REPO / "lib")!r}) +from pathlib import Path +from sessionkit_inventory import lifecycle as lifecycle_module + +if sys.argv[1:3] == ["lifecycle", "closed"]: + lifecycle_module.update_state( + Path(os.environ["SESSION_KIT_STATE_DIR"]), + session_id=os.environ["SESSION_KIT_LIFECYCLE_SESSION_ID"], + boot_id=os.environ["SESSION_KIT_LIFECYCLE_BOOT_ID"], + shell_pid=int(os.environ["SESSION_KIT_LIFECYCLE_SHELL_PID"]), + shell_start_ticks=int( + os.environ["SESSION_KIT_LIFECYCLE_SHELL_START_TICKS"] + ), + event="keep", + keep=True, + ) +os.execv( + sys.executable, + [sys.executable, os.environ["KEEP_NEXT_CORE"], *sys.argv[1:]], +) +""", + encoding="utf-8", + ) + wrapper.chmod(0o700) + self.core = wrapper + self.environment_overrides = { + **self.environment_overrides, + "KEEP_NEXT_CORE": str(previous_core), + } + + def remembered_name(self, title: str, *, uuid: str = UUID) -> None: + """The name the picker last saw for this session. + + A shell that closes itself knows only its own ID, so the closed row + takes its name, directory and origin from the last inventory the + collector wrote (closed_sessions.entry_from_inventory). Without that + file the row lands nameless, which is the placeholder R3 forbids -- + so the name has to be asserted against a session that HAS one. + """ + payload = { + "schema_version": 1, + "sessions": [ + { + "shpool_id_raw": "main2", + "provider": "codex", + "identity": {"uuid": uuid, "confidence": "exact"}, + "title": title, + "cwd": str(self.project), + "origin": "human", + "account_alias": "", + } + ], + } + path = self.state / "inventory.json" + path.write_text(json.dumps(payload) + "\n", encoding="utf-8") + path.chmod(0o600) + + def collector_kept_the_exact_conversation(self, uuid: str = UUID) -> None: + """Plant the handoff record `persist_last_exact` writes. + + The collector writes it at the one moment a live provider row would + otherwise be replaced by an idle shell row, and it is bound to this + boot, this shell PID and this shell start -- values only the running + fixture shell knows. Writing it from a wrapper around the lifecycle + verb is the only way to use the real generation; the wrapper takes + those three from the same environment the shell exports, which is + exactly what the collector would have recorded. + + Chains onto whatever core is already installed, so it composes with + `reopen_answers`; call this one last. + """ + previous_core = self.core + wrapper = self.base / "retained-exact-wrapper.py" + wrapper.write_text( + f"""#!/usr/bin/env python3 +import os, sys +sys.path.insert(0, {str(REPO / "lib")!r}) +from pathlib import Path +from sessionkit_inventory import lifecycle as lifecycle_module +from sessionkit_inventory.state_io import atomic_write_private_json + +if sys.argv[1:3] == ["lifecycle", "closed"]: + state_dir = Path(os.environ["SESSION_KIT_STATE_DIR"]) + session_id = os.environ["SESSION_KIT_LIFECYCLE_SESSION_ID"] + path = lifecycle_module._last_exact_path( + state_dir, session_id, create_key=True + ) + atomic_write_private_json(path, {{ + "schema_version": 1, + "session_key": lifecycle_module.session_key( + state_dir, session_id, create=True + ), + "boot_id": os.environ["SESSION_KIT_LIFECYCLE_BOOT_ID"], + "shell_pid": int(os.environ["SESSION_KIT_LIFECYCLE_SHELL_PID"]), + "shell_start_ticks": int( + os.environ["SESSION_KIT_LIFECYCLE_SHELL_START_TICKS"] + ), + "provider": "codex", + "uuid": {uuid!r}, + "title": "Exit Ruling Work", + "display_title": "Exit Ruling Work", + "title_source": "provider", + "recovery": {{ + "available": True, + "provider": "codex", + "uuid": {uuid!r}, + }}, + }}) +os.execv( + sys.executable, + [sys.executable, os.environ["RETAINED_NEXT_CORE"], *sys.argv[1:]], +) +""", + encoding="utf-8", + ) + wrapper.chmod(0o700) + self.core = wrapper + self.environment_overrides = { + **self.environment_overrides, + "RETAINED_NEXT_CORE": str(previous_core), + } + + def _state_mangling_wrapper(self, name: str, body: str) -> None: + """Damage the lifecycle document at the moment the close reads it. + + The interesting failures are not "the file was always broken" but + "it broke between the exit record and the close" -- the window a full + disk or a killed writer opens. Chains onto the installed core. + """ + previous_core = self.core + wrapper = self.base / f"{name}-wrapper.py" + wrapper.write_text( + f"""#!/usr/bin/env python3 +import os, sys +sys.path.insert(0, {str(REPO / "lib")!r}) +from pathlib import Path +from sessionkit_inventory import lifecycle as lifecycle_module + +if sys.argv[1:3] == ["lifecycle", "closed"]: + state_dir = Path(os.environ["SESSION_KIT_STATE_DIR"]) + path = lifecycle_module.lifecycle_path( + state_dir, os.environ["SESSION_KIT_LIFECYCLE_SESSION_ID"] + ) + if path is not None and path.exists(): +{body} +os.execv( + sys.executable, + [sys.executable, os.environ["MANGLE_NEXT_CORE"], *sys.argv[1:]], +) +""", + encoding="utf-8", + ) + wrapper.chmod(0o700) + self.core = wrapper + self.environment_overrides = { + **self.environment_overrides, + "MANGLE_NEXT_CORE": str(previous_core), + } + + def delete_the_lifecycle_record(self) -> None: + self._state_mangling_wrapper("delete-record", " path.unlink()") + + def corrupt_the_lifecycle_record(self) -> None: + """Truncate it mid-object, the shape an interrupted write leaves.""" + self._state_mangling_wrapper( + "corrupt-record", + " blob = path.read_bytes()\n" + " path.write_bytes(blob[: max(1, len(blob) // 2)])", + ) + + def ledger_rows(self) -> list: + path = self.home / ".local/share/session-kit/closed-sessions.jsonl" + if not path.is_file(): + return [] + return [ + json.loads(line) + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + def lifecycle_record_from_another_generation(self) -> None: + """Leave a record that belongs to a PREVIOUS shell of this session id. + + shpool ids are reused: `main2` closes, a new `main2` opens, and the + old lifecycle document survives until a collector pass prunes it. The + close loaded that document by session id alone, so the fresh shell + could tombstone the previous occupant's conversation -- `update_state` + had guarded exactly this since it was written, and the close had not + (found in review, 2026-08-15). + """ + previous_core = self.core + wrapper = self.base / "stale-generation-wrapper.py" + wrapper.write_text( + f"""#!/usr/bin/env python3 +import os, sys +sys.path.insert(0, {str(REPO / "lib")!r}) +from pathlib import Path +from sessionkit_inventory import lifecycle as lifecycle_module +from sessionkit_inventory.state_io import atomic_write_private_json + +if sys.argv[1:3] == ["lifecycle", "closed"]: + state_dir = Path(os.environ["SESSION_KIT_STATE_DIR"]) + session_id = os.environ["SESSION_KIT_LIFECYCLE_SESSION_ID"] + document = lifecycle_module.load_state(state_dir, session_id) + document["shell_pid"] = 424242 + path = lifecycle_module.lifecycle_path(state_dir, session_id) + atomic_write_private_json(path, document) +os.execv( + sys.executable, + [sys.executable, os.environ["STALE_NEXT_CORE"], *sys.argv[1:]], +) +""", + encoding="utf-8", + ) + wrapper.chmod(0o700) + self.core = wrapper + self.environment_overrides = { + **self.environment_overrides, + "STALE_NEXT_CORE": str(previous_core), + } + + def crashing_provider(self, code: int = 3) -> None: + """A provider that dies badly: the one path that reopens itself.""" + write_executable( + self.bin / "codex", + "#!/usr/bin/env bash\n" + 'printf "%s\\n" "$*" >> "$PROVIDER_LOG"\n' + f"exit {code}\n", + ) + + def close_intents(self) -> dict: + path = self.state / "closed-conversations.json" + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")).get("closed", {}) + + +class ProviderExitShellTests(ProviderExitShellHarness): + def test_a_clean_exit_closes_the_session_without_a_menu(self) -> None: + # /exit is the operator saying "done here": the shell ends with the + # provider, which ends the shpool session. No marker, no menu, no + # opt-in, tests/test_exit_closes_session.py covers the contract. + completed = self.launch("") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertNotIn("Provider exited:", completed.stdout) + self.assertNotIn("exited with status", completed.stdout) + # The shell closed rather than returning to the sourcing caller. + self.assertNotIn("SOURCE_RETURNED", completed.stdout) + state = self.lifecycle_document() + self.assertTrue(state["user_input_after_exit"]) + + def test_account_record_exports_exact_codex_profile_before_resume(self) -> None: + wrapper = self.base / "inventory-wrapper.py" + wrapper.write_text( + """#!/usr/bin/env python3 +import json, os, sys +if sys.argv[1:4] == ["account", "resume-profile", "codex"]: + print(json.dumps({ + "provider": "codex", + "alias": sys.argv[4], + "email": "account@example.com", + "profile_dir": os.environ["ACCOUNT_PROFILE"], + "plan": "plus", + })) + raise SystemExit(0) +os.execv(sys.executable, [sys.executable, os.environ["REAL_CORE"], *sys.argv[1:]]) +""", + encoding="utf-8", + ) + wrapper.chmod(0o700) + self.core = wrapper + self.environment_overrides = { + "ACCOUNT_PROFILE": str(self.account_profile), + "REAL_CORE": str(CORE), + } + completed = self.launch("", account_alias="work") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertTrue( + self.account_environment_log.exists(), + completed.stdout + completed.stderr, + ) + self.assertEqual( + f"{self.account_profile}\twork\t1\n", + self.account_environment_log.read_text(encoding="utf-8"), + ) + self.assertFalse((self.start / "main2.account").exists()) + + def test_crashed_provider_reopens_itself_without_asking(self) -> None: + self.crashing_provider() + completed = self.launch("") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("Codex exited with status 3", completed.stdout) + self.assertIn("Codex crashed. Reopened.", completed.stdout) + self.assertNotIn("Provider exited:", completed.stdout) + self.assertNotIn("Choice:", completed.stdout) + + def test_a_reopen_that_is_refused_lands_in_a_shell_and_says_why( self, ) -> None: - completed = self.launch("s\nexit\n") + # The reopen guard needs a live daemon generation this harness has no + # way to provide, so the reopen is refused: the one path where the + # window is handed back with the session still open. + self.crashing_provider() + completed = self.launch("exit\n") self.assertEqual(0, completed.returncode, completed.stderr) - self.assertIn("Codex exited with status 0", completed.stdout) - self.assertIn("Provider exited: [r] reopen conversation", completed.stdout) + self.assertIn("Codex exited with status 3", completed.stdout) + self.assertIn("Codex crashed. Reopened.", completed.stdout) + self.assertIn("nothing reopened", completed.stderr) self.assertIn("Shell opened", completed.stdout) self.assertIn("SOURCE_RETURNED", completed.stdout) self.assertEqual( ( "-c check_for_update_on_startup=false " + '-c tui.terminal_title=["activity", "thread"] ' f"--no-alt-screen resume {UUID}\n" ), self.provider_log.read_text(encoding="utf-8"), ) - state = self.lifecycle_document() - self.assertTrue(state["input_tracking"]) - self.assertTrue(state["user_input_after_exit"]) - self.assertNotIn("user_input_after_exit_at", state) + # Nothing was typed, so nothing is recorded as attended: the reaper's + # evidence stays the truth about this session. + self.assertEqual([], sorted((self.state / "lifecycle").glob("*.json"))[2:]) self.assertFalse((self.start / "main2").exists()) self.assertFalse((self.start / "main2.expected").exists()) - def test_unknown_choice_is_permanent_input_before_close(self) -> None: - completed = self.launch("not-a-choice\nc\n") - self.assertEqual(0, completed.returncode, completed.stderr) - self.assertIn("Unknown choice", completed.stdout) - self.assertNotIn("SOURCE_RETURNED", completed.stdout) - self.assertTrue( - self.lifecycle_document()["user_input_after_exit"] + def test_a_crash_records_no_decision_the_operator_never_made(self) -> None: + """The crash path asks nothing, so it records no answer. + + Every key of the old menu recorded input before it was dispatched, so + one typo marked the terminal as attended forever -- the reaper skips + it from then on. Reopening is the shell healing itself, not a person + deciding something.""" + log = self.base / "lifecycle-calls.log" + wrapper = self.base / "inventory-wrapper.py" + wrapper.write_text( + r"""#!/usr/bin/env python3 +import os, pathlib, sys +if sys.argv[1:3] == ["lifecycle", "user-input"]: + with pathlib.Path(os.environ["LIFECYCLE_CALL_LOG"]).open("a") as handle: + handle.write("user-input\n") +os.execv(sys.executable, [sys.executable, os.environ["REAL_CORE"], *sys.argv[1:]]) +""", + encoding="utf-8", + ) + wrapper.chmod(0o700) + self.core = wrapper + self.environment_overrides = { + "LIFECYCLE_CALL_LOG": str(log), + "REAL_CORE": str(CORE), + } + # A crash, so the only records are the ones this menu writes: a clean + # exit records the operator's own decision to leave before the menu is + # ever drawn. + write_executable( + self.bin / "codex", + '#!/usr/bin/env bash\nprintf "%s\\n" "$*" >> "$PROVIDER_LOG"\nexit 3\n', ) + completed = self.launch("exit\n") + + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertNotIn("Unknown choice", completed.stdout) + self.assertIn("Codex crashed. Reopened.", completed.stdout) + self.assertFalse(log.exists(), log.read_text() if log.exists() else "") + + @unittest.skipUnless( + Path("/proc/1/stat").exists(), "reads a foreign process generation from /proc" + ) def test_unrelated_process_cannot_forge_lifecycle_generation(self) -> None: pid_one_fields = Path("/proc/1/stat").read_text(encoding="utf-8") pid_one_start = int(pid_one_fields[pid_one_fields.rfind(")") + 2 :].split()[19]) diff --git a/tests/test_login.py b/tests/test_login.py index f29ea54..d774b32 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -1,6 +1,7 @@ from __future__ import annotations import errno +import fcntl import json import os from pathlib import Path @@ -8,10 +9,13 @@ import re import select import signal +import struct import tempfile +import termios import time import unicodedata import unittest +from collections.abc import Callable from tests.support import REPO, run @@ -20,14 +24,32 @@ SGR = re.compile(r"\x1b\[([0-9;]*)m") # Tab-title sequences render zero cells in a real terminal. OSC_TITLE = re.compile(r"\x1b\][0-9;]*[^\x07\x1b]*(?:\x07|\x1b\\)") +# Cursor moves, erases, and synchronized-frame markers render zero cells too. +# A capture keeps them inline, so a line that carries a screen clear is not +# actually wider on the terminal that draws it. +CSI = re.compile(r"\x1b\[[0-9;?]*[@-~]") def strip_sgr(text: str) -> str: return OSC_TITLE.sub("", SGR.sub("", text)) +# Every non-SGR sequence the picker is allowed to emit on a capable terminal, +# enumerated so a test that forbids escapes forbids the ones that came from +# session content. Screen clear and cursor home start a frame; bracketed paste +# is armed around a prompt so a pasted block arrives as data (see +# tests/test_picker_input_safety.py). +PICKER_SCREEN_CONTROL = ("\x1b[2J", "\x1b[H", "\x1b[?2004h", "\x1b[?2004l") + + +def strip_screen_control(text: str) -> str: + for sequence in PICKER_SCREEN_CONTROL: + text = text.replace(sequence, "") + return text + + def display_cells(text: str) -> int: - text = strip_sgr(text) + text = CSI.sub("", strip_sgr(text)) return sum( 0 if unicodedata.combining(character) @@ -71,9 +93,7 @@ def row( "process_start_ticks": 10_000 + suffix, }, "started_at_unix_ms": 1_700_000_000_000 + suffix, - "shpool_status": ( - "Disconnected" if availability == "ready" else "Attached" - ), + "shpool_status": ("Disconnected" if availability == "ready" else "Attached"), "availability": availability, "provider": provider, "identity": { @@ -116,6 +136,52 @@ def inventory(*rows: dict, stale: bool = False) -> dict: } +def _recovery_rows(pending: dict | None) -> dict: + """Build what `--recovery-pending-list` returns, with the real projection. + + A case writes the STORE it cares about -- a pending record, or a session + closed on purpose -- and the module both surfaces read turns it into the + payload. Hand-writing that payload is how a renamed timestamp field + reached the screen: the fixture went on describing a shape the emitter had + stopped producing, so the test for the picker's age line kept passing + against a record the emitter cannot make. + """ + import sys as _sys + + _sys.path.insert(0, os.fspath(REPO / "lib")) + from sessionkit_inventory import recovery_list + + document = pending or {"schema_version": 1, "entries": []} + now_ms = int(time.time() * 1000) + closed_rows: list[dict] = [] + pending_entries: list[dict] = [] + numbers: dict[str, int] = {} + for index, row in enumerate(document.get("entries") or [], 1): + filled = dict(row) + # A case that says nothing about time still needs an order, and an age + # that reads like one rather than fifty years. + filled.setdefault("started_at_unix_ms", now_ms - index * 60_000) + # A case that does not say otherwise describes numbered rows, which is + # the normal shape: a conversation keeps its number across the close. + # `numbered: False` asks for the row whose number was retired. + uuid = str(filled.get("uuid") or "") + provider = str(filled.get("provider") or "") + if uuid and provider and filled.pop("numbered", True): + numbers[f"ai:{provider}:{uuid}"] = index + if filled.get("closed_at_unix_ms"): + filled.setdefault("restorable", True) + closed_rows.append(filled) + else: + filled.setdefault("actionable", True) + pending_entries.append(filled) + rows = recovery_list.recovery_rows( + closed_rows=closed_rows, + pending_entries=pending_entries, + numbers=numbers, + ) + return {**document, "entries": rows} + + class LoginFixture: def __init__( self, @@ -128,9 +194,7 @@ def __init__( fixture_root = Path( os.environ.get("SESSION_KIT_TEST_EXEC_ROOT", os.fspath(REPO)) ) - self.temp = tempfile.TemporaryDirectory( - prefix=".login-", dir=fixture_root - ) + self.temp = tempfile.TemporaryDirectory(prefix=".login-", dir=fixture_root) self.base = Path(self.temp.name) self.home = self.base / "home" self.home.mkdir() @@ -156,9 +220,13 @@ def __init__( self.snapshot_count = self.base / "snapshot-count" self.pending = self.base / "pending.json" self.pending.write_text( - json.dumps(pending or {"schema_version": 1, "entries": []}), + json.dumps(_recovery_rows(pending)), encoding="utf-8", ) + self.prompt_quarantine = self.base / "prompt-quarantine.json" + self.prompt_quarantine.write_text( + json.dumps({"items": [], "schema_version": 1}), encoding="utf-8" + ) self.sp_log = self.base / "sp.log" self.status_log = self.base / "status.log" self.ack_exit = ack_exit @@ -187,6 +255,11 @@ def __init__( print(source.read_text(),end="") elif args == ["--recovery-pending-list"]: print(pathlib.Path(os.environ["LOGIN_PENDING"]).read_text(),end="") +elif len(args) == 2 and args[0] == "--recovery-remember-printed": + # The real status command writes down what this screen is about to print, + # so `sp restore` can check a word typed later against it. It prints + # nothing to the screen; LOGIN_REMEMBER_EXIT makes it fail on purpose. + raise SystemExit(int(os.environ.get("LOGIN_REMEMBER_EXIT", "0"))) elif len(args) == 4 and args[0] == "--recovery-pending-ack": print("{}") raise SystemExit(int(os.environ.get("LOGIN_ACK_EXIT", "0"))) @@ -199,8 +272,51 @@ def __init__( """#!/usr/bin/env python3 import json,os,pathlib,stat,sys args=sys.argv[1:] +if args == ["prompt-quarantine","list","--json"]: + print(pathlib.Path(os.environ["LOGIN_PROMPT_QUARANTINE"]).read_text(),end="") + raise SystemExit(0) +if len(args) == 3 and args[:2] == ["account","choices"]: + provider=args[2] + second_ok=os.environ.get("LOGIN_ACCOUNT_SECOND_ELIGIBLE", "1") != "0" + print(json.dumps({ + "provider":provider, + "roster_fresh":True, + "roster_state":"fresh", + "advice_fresh":True, + "recommendation":None, + "recommendation_reason":"", + "recommendation_blocked":None, + "choices":[ + { + "alias":"wren", + "email":"account@example.com", + "plan":"subscription", + "eligible":True, + "state":"ready", + "health":"ok", + "serving":True, + "u5h":0.0, + "u7d":0.75, + "recommended":False, + }, + { + "alias":"work", + "email":"work@example.com", + "plan":"subscription", + "eligible":second_ok, + "state":"ready" if second_ok else "blocked: health error", + "health":"ok" if second_ok else "error", + "serving":False, + "u5h":0.34, + "u7d":0.36, + "recommended":False, + }, + ], + })) + raise SystemExit(0) entry={"args":args} if args and args[0].startswith("picker-"): + entry["confirm_id"]=os.environ.get("SESSION_KIT_CONFIRM_ID","") proof=pathlib.Path(args[1]) metadata=proof.lstat() entry["proof"]=json.loads(proof.read_text()) @@ -210,12 +326,26 @@ def __init__( log.write(json.dumps(entry,sort_keys=True)+"\\n") if args and args[0] == "restore-exact": print("restored-fixture") +if len(args) > 1 and args[:2] == ["prompt-quarantine","resume"]: + print("s20260809-232323-9 f9e8d7c6-b5a4-4938-a271-6f5e4d3c2b1a") raise SystemExit(int(os.environ.get("LOGIN_SP_EXIT","0"))) """, ) def close(self) -> None: - self.temp.cleanup() + # A picker child that has not fully exited can still be writing under + # the fixture (git object files, frame captures) when cleanup walks + # the tree, and rmtree then dies on a directory that refilled behind + # it. The stragglers exit within moments; retry instead of failing + # the test on its own teardown. + for attempt in range(5): + try: + self.temp.cleanup() + return + except OSError: + if attempt == 4: + raise + time.sleep(0.2 * (attempt + 1)) def env( self, *, lines: int = 24, columns: int = 100, sp_exit: int = 0 @@ -235,10 +365,17 @@ def env( "SESSION_KIT_SP_CMD": str(self.fake_sp), "SESSION_KIT_NONINTERACTIVE": "0", "SESSION_KIT_NO_COLOR": "1", + # The stub shpool here cannot carry an event stream, so every + # fixture that is not about events measures the timed poll -- + # which is also what a machine without the events socket runs. + # tests/test_picker_events.py turns it back on with a stub that + # speaks the stream. + "SESSION_KIT_PICKER_EVENTS": "0", "LOGIN_INVENTORY": str(self.inventory), "LOGIN_REFRESHED_INVENTORY": str(self.refreshed_inventory), "LOGIN_SNAPSHOT_COUNT": str(self.snapshot_count), "LOGIN_PENDING": str(self.pending), + "LOGIN_PROMPT_QUARANTINE": str(self.prompt_quarantine), "LOGIN_SP_LOG": str(self.sp_log), "LOGIN_STATUS_LOG": str(self.status_log), "LOGIN_ACK_EXIT": str(self.ack_exit), @@ -254,9 +391,7 @@ def sp_entries(self) -> list[dict]: return [json.loads(line) for line in self.sp_log.read_text().splitlines()] def status_entries(self) -> list[list[str]]: - return [ - json.loads(line) for line in self.status_log.read_text().splitlines() - ] + return [json.loads(line) for line in self.status_log.read_text().splitlines()] def picker_temps(self) -> list[Path]: return sorted( @@ -266,6 +401,57 @@ def picker_temps(self) -> list[Path]: ) +# The picker names the window it is serving: a session's own title while that +# session is open, and "session kit" while the menu is. That trail is the one +# reliable record of what the picker DID, and it is the fact a timeout is +# almost always about. +_OSC_TITLE = re.compile(rb"\x1b\][02];([^\x07\x1b]*)(?:\x07|\x1b\\)") +_IDLE_TITLE = "session kit" + + +def _pty_diagnosis(raw: bytes, typed: list[bytes]) -> str: + """Say what the picker did with what was typed, in one line. + + A timed-out picker used to report several kilobytes of escape bytes and + nothing else, so every occurrence had to be decoded by hand before it could + be argued about -- and each of these timeouts so far has turned out to be + the same shape, spelled out in an OSC title nobody was reading: a keystroke + went missing, the bare newline behind it answered the prompt, and `Enter + opens the top session` did exactly what it promises. Reading that out of + the dump cost far more than acting on it, so the harness reads it here. + + The raw dump still follows. This only puts the answer in front of it. + """ + notes: list[str] = [] + if typed: + notes.append("typed " + ", ".join(repr(item) for item in typed)) + titles = [ + match.group(1).decode("utf-8", errors="replace") + for match in _OSC_TITLE.finditer(raw) + ] + if titles: + notes.append("window titles in order: " + " -> ".join(repr(t) for t in titles)) + opened = [title for title in titles if title and title != _IDLE_TITLE] + if opened: + notes.append( + f"the picker OPENED {opened[0]!r} -- a prompt was answered by " + "Enter, not by what was typed" + ) + # Whether the keys were echoed AFTER the last prompt, not whether they + # appear at all: every one of these screens carries the footer, and the + # footer says "quit q or b", so a plain search for a `q` always finds one. + # + # The bare glyph, with no trailing space: a coloured prompt puts a reset + # sequence between the two, and matching "glyph space" then finds none of + # the prompts on any screen the kit drew in colour. + prompt = "❯".encode() + keys = b"".join(typed).replace(b"\n", b"").replace(b"\r", b"") + if keys and prompt in raw and keys not in raw.rsplit(prompt, 1)[-1]: + notes.append(f"nothing echoed {keys!r} after the last prompt was drawn") + notes.append(f"{raw.count(prompt)} prompt(s) drawn") + return "; ".join(notes) + + def run_pty( fixture: LoginFixture, input_bytes: bytes = b"", @@ -274,12 +460,18 @@ def run_pty( columns: int = 100, sp_exit: int = 0, send_signal: int | None = None, + signal_marker: str = "❯ ", post_signal_bytes: bytes = b"", + post_signal_marker: str | None = None, env_updates: dict[str, str | None] | None = None, + deferred: tuple[str, bytes] | None = None, + deferred_delay_seconds: float = 0, + deferred_action: Callable[[], None] | None = None, + deferred_check: Callable[[], None] | None = None, + followup: tuple[str, bytes, float] | None = None, + pty_winsize: tuple[int, int] | None = None, ) -> tuple[int, str]: - environment = fixture.env( - lines=lines, columns=columns, sp_exit=sp_exit - ) + environment = fixture.env(lines=lines, columns=columns, sp_exit=sp_exit) for key, value in (env_updates or {}).items(): if value is None: environment.pop(key, None) @@ -297,25 +489,137 @@ def run_pty( if Path(environment["SESSION_KIT_SP_CMD"]).resolve() == (REPO / "bin/sp").resolve(): raise AssertionError("PTY test must never invoke the repository sp command") + # When a test supplies a window size, do not release the child until the + # parent has installed it. Setting TIOCSWINSZ after pty.fork otherwise + # races the child's exec: on the macOS runner the picker sometimes read + # the launcher's 76-column pty before this fixture pinned 40 columns. + ready_reader = ready_writer = None + if pty_winsize is not None: + ready_reader, ready_writer = os.pipe() pid, descriptor = pty.fork() if pid == 0: - os.chdir(fixture.base) - if os.environ.get("SESSION_KIT_TEST_EXEC_ROOT"): - os.execve( - "/usr/bin/bash", - ["/usr/bin/bash", os.fspath(LOGIN)], - environment, - ) - os.execve(LOGIN, [os.fspath(LOGIN)], environment) + # A child that cannot exec must never return: it is a forked copy of + # the test runner, and returning resumes the whole suite from here. + try: + if ready_reader is not None and ready_writer is not None: + os.close(ready_writer) + os.read(ready_reader, 1) + os.close(ready_reader) + os.chdir(fixture.base) + if os.environ.get("SESSION_KIT_TEST_EXEC_ROOT"): + os.execve( + "/usr/bin/bash", + ["/usr/bin/bash", os.fspath(LOGIN)], + environment, + ) + os.execve(LOGIN, [os.fspath(LOGIN)], environment) + finally: + os._exit(127) + + if pty_winsize is not None: + # Give the slave a real window size so the ioctl path is exercised. + # The pipe barrier above guarantees the child cannot measure it first. + winsize_rows, winsize_cols = pty_winsize + fcntl.ioctl( + descriptor, + termios.TIOCSWINSZ, + struct.pack("HHHH", winsize_rows, winsize_cols, 0, 0), + ) + assert ready_reader is not None and ready_writer is not None + os.close(ready_reader) + os.write(ready_writer, b"1") + os.close(ready_writer) output = bytearray() - deadline = time.monotonic() + 10 + # Every keystroke this harness sends, in order, so a timeout can say what + # the picker was given as well as what it did with it. + typed: list[bytes] = [] + deadline = time.monotonic() + 30 try: if input_bytes: + typed.append(input_bytes) os.write(descriptor, input_bytes) + if deferred is not None: + # Type only once the picker has painted something on its own, + # so an unattended repaint can be observed before any keystroke. + marker, payload = deferred + marker_deadline = min(deadline, time.monotonic() + 20) + while marker.encode() not in output and time.monotonic() < marker_deadline: + ready, _, _ = select.select([descriptor], [], [], 0.05) + if not ready: + continue + try: + chunk = os.read(descriptor, 65536) + except OSError as exc: + if exc.errno != errno.EIO: + raise + break + if not chunk: + break + output.extend(chunk) + if marker.encode() not in output: + os.kill(pid, signal.SIGKILL) + os.waitpid(pid, 0) + raise AssertionError( + f"picker never rendered {marker!r} on its own; " + f"output={output.decode(errors='replace')!r}" + ) + if deferred_action is not None: + deferred_action() + if deferred_delay_seconds > 0: + time.sleep(deferred_delay_seconds) + if deferred_check is not None: + deferred_check() + # The keystrokes below get their own wait, the way the followup and + # signal stages already do. The overall deadline runs from process + # start, so waiting for the picker to paint (up to 20s) and any + # deliberate delay were charged against the time this typed input + # then had to finish in: on a slow runner the quit was typed with + # seconds left and the test killed a picker that was doing exactly + # what it should. + deadline = max(deadline, time.monotonic() + 20) + typed.append(payload) + os.write(descriptor, payload) + if followup is not None: + # A second scripted beat for flows that hand the terminal to a + # NEW process mid-run (the upgrade handoff): wait for a marker + # only that successor can print, then type into it. The deadline + # is the followup's own, recovery spans exec boundaries and can + # honestly take longer than the marker wait above. + followup_marker, followup_payload, followup_wait = followup + followup_deadline = time.monotonic() + followup_wait + while ( + followup_marker.encode() not in output + and time.monotonic() < followup_deadline + ): + ready, _, _ = select.select([descriptor], [], [], 0.05) + if not ready: + continue + try: + chunk = os.read(descriptor, 65536) + except OSError as exc: + if exc.errno != errno.EIO: + raise + break + if not chunk: + break + output.extend(chunk) + if followup_marker.encode() not in output: + os.kill(pid, signal.SIGKILL) + os.waitpid(pid, 0) + raise AssertionError( + f"picker never rendered {followup_marker!r} after the " + f"deferred stage; output={output.decode(errors='replace')!r}" + ) + deadline = max(deadline, time.monotonic() + 20) + typed.append(followup_payload) + os.write(descriptor, followup_payload) if send_signal is not None: - prompt = "❯ ".encode() - signal_deadline = min(deadline, time.monotonic() + 5) + # Which prompt the signal is aimed at. A modal has to be on screen + # before an interrupt can be said to have cancelled it, and the + # home prompt is already there by then. + prompt = signal_marker.encode() + signal_deadline = min(deadline, time.monotonic() + 15) while prompt not in output and time.monotonic() < signal_deadline: ready, _, _ = select.select([descriptor], [], [], 0.05) if not ready: @@ -331,11 +635,38 @@ def run_pty( output.extend(chunk) if prompt not in output: raise AssertionError( - "picker PTY did not render its prompt before the signal" + f"picker PTY never rendered {signal_marker!r} before the signal; " + f"output={output.decode(errors='replace')!r}" ) os.kill(pid, send_signal) if post_signal_bytes: - time.sleep(0.3) + if post_signal_marker is None: + time.sleep(0.3) + else: + # Type only once the picker has answered the signal. A + # fixed sleep races the answer, and a keystroke that lands + # first is consumed by the very prompt under test. + answer = post_signal_marker.encode() + answer_deadline = min(deadline, time.monotonic() + 15) + while answer not in output and time.monotonic() < answer_deadline: + ready, _, _ = select.select([descriptor], [], [], 0.05) + if not ready: + continue + try: + chunk = os.read(descriptor, 65536) + except OSError as exc: + if exc.errno != errno.EIO: + raise + break + if not chunk: + break + output.extend(chunk) + if answer not in output: + raise AssertionError( + f"picker never printed {post_signal_marker!r} after the " + f"signal; output={output.decode(errors='replace')!r}" + ) + typed.append(post_signal_bytes) os.write(descriptor, post_signal_bytes) status = None while time.monotonic() < deadline: @@ -357,7 +688,8 @@ def run_pty( os.kill(pid, signal.SIGKILL) _, status = os.waitpid(pid, 0) raise AssertionError( - f"picker PTY timed out; output={output.decode(errors='replace')!r}" + f"picker PTY timed out; {_pty_diagnosis(bytes(output), typed)}" + f"\noutput={output.decode(errors='replace')!r}" ) while True: ready, _, _ = select.select([descriptor], [], [], 0) @@ -380,43 +712,151 @@ def run_pty( class LoginPickerTests(unittest.TestCase): - def test_enter_and_eof_return_regular_shell_without_action(self) -> None: + def test_quit_and_eof_return_regular_shell_without_action(self) -> None: for label, payload in ( - ("enter", b"\n"), + ("quit", b"q\n"), ("eof", b"\x04"), ): with self.subTest(label=label): fixture = LoginFixture(inventory(row("ready", number=9))) try: code, _ = run_pty(fixture, payload) - self.assertEqual(2, code) + self.assertEqual(0, code) self.assertEqual([], fixture.sp_entries()) self.assertEqual([], fixture.picker_temps()) finally: fixture.close() + def test_enter_on_the_home_list_opens_the_top_row(self) -> None: + """Enter takes the most likely option: the row drawn first. + + It used to drop the person into a bare shell, so the one key a menu + trains you to press was the one that left without doing anything. + """ + fixture = LoginFixture( + inventory( + row("second", number=4), + row("top", number=9, needs_you=True), + ) + ) + try: + code, output = run_pty(fixture, b"\nq\n") + self.assertEqual(0, code) + # The footer promised the number, and the open used it. + self.assertIn("↵ open 9", output) + entries = fixture.sp_entries() + self.assertEqual(["picker-open"], [entry["args"][0] for entry in entries]) + self.assertEqual("top", entries[0]["proof"]["shpool_id"]) + # And the picker was still there afterwards: q is what ended it, + # so the list was drawn again after the open attempt returned. + self.assertGreaterEqual(output.count("↵ open 9"), 2) + finally: + fixture.close() + + def test_enter_on_an_empty_list_opens_the_new_session_screen(self) -> None: + fixture = LoginFixture(inventory()) + try: + code, output = run_pty(fixture, b"\nb\nq\n") + self.assertEqual(0, code) + self.assertIn("↵ new", output) + self.assertIn("New session", output) + self.assertIn("↵ Claude Code", output) + self.assertEqual([], fixture.sp_entries()) + finally: + fixture.close() + + def test_the_home_footer_names_the_row_enter_opens_and_the_way_out(self) -> None: + """The footer promises a number, and the dispatcher opens that one. + + Both read the same page slice, so the promise and the action cannot + disagree -- and the door out is named for the keys that take it. + """ + fixture = LoginFixture( + inventory( + row("second", number=2), + row("top", number=7, needs_you=True), + ) + ) + try: + code, output = run_pty(fixture, b"q\n", columns=120) + self.assertEqual(0, code) + footers = [ + line + for line in strip_sgr(output).splitlines() + if line.strip().startswith("↵ open") + ] + self.assertTrue(footers, output) + self.assertTrue( + footers[-1].startswith(" ↵ open 7 · # · kill k #"), footers[-1] + ) + self.assertIn("quit q or b", footers[-1]) + self.assertNotIn("back ↵ or b", output) + finally: + fixture.close() + + def test_the_home_footer_fits_every_width_and_keeps_the_enter_hint(self) -> None: + """The hint that names Enter is the last thing a narrow window loses. + + The footer used to be four hand-sized width tiers, and the Enter hint + -- whose width follows the row number it names -- overflowed the + narrowest of them the day it landed. + """ + document = inventory( + row("second", number=2), + row("top", number=7, needs_you=True), + ) + for columns in (30, 40, 50, 60, 80, 120): + with self.subTest(columns=columns): + fixture = LoginFixture(document) + try: + code, output = run_pty(fixture, b"q\n", columns=columns) + self.assertEqual(0, code) + footers = [ + line + for line in strip_sgr(output).splitlines() + if line.strip().startswith("↵ open") + ] + self.assertTrue(footers, output) + for line in footers: + self.assertLessEqual(display_cells(line), columns - 1, line) + self.assertTrue(footers[-1].startswith(" ↵ open 7"), footers[-1]) + # Segments are ordered by what a person needs, so what a + # narrow window drops is the luxury and not the door: + # `more` reaches projects, closed sessions, and everything + # outside the kit, and it survives where `history h #` + # and the rest do not. + if columns >= 50: + self.assertIn("more m", footers[-1]) + if columns == 50: + self.assertNotIn("needs you a", footers[-1]) + self.assertNotIn("history h #", footers[-1]) + finally: + fixture.close() + def test_interrupt_redraws_menu_instead_of_exiting(self) -> None: # A stray Ctrl-C must never dump the human to a bare terminal: the - # picker redraws its menu and only a deliberate Enter/quit ends it. + # picker redraws its menu and only a deliberate quit ends it. fixture = LoginFixture(inventory(row("ready", number=9))) try: code, output = run_pty( fixture, b"", send_signal=signal.SIGINT, - post_signal_bytes=b"\n", + post_signal_bytes=b"q\n", ) - self.assertEqual(2, code) + self.assertEqual(0, code) # The exit reason proves the interrupt was absorbed: the picker - # ended through the deliberate Enter that followed, not the signal. + # ended through the deliberate q that followed, not the signal. log = fixture.state / "action-events.jsonl" events = log.read_text() if log.exists() else "" - self.assertIn("terminal_requested", events) + self.assertIn("quit", events) self.assertEqual([], fixture.sp_entries()) finally: fixture.close() - def test_projection_groups_and_search_preserve_stable_terminal_numbers(self) -> None: + def test_projection_groups_and_search_preserve_stable_terminal_numbers( + self, + ) -> None: searchable = row("codex10", number=10, provider="codex") searchable["title"] = "Searchable task" searchable["native_title"] = "Searchable task" @@ -428,6 +868,7 @@ def test_projection_groups_and_search_preserve_stable_terminal_numbers(self) -> availability="attached", ) unavailable["agent_status"] = "state unavailable" + unavailable["process_age_seconds"] = 2 * 86400 + 3 * 3600 fixture = LoginFixture( inventory( searchable, @@ -442,28 +883,27 @@ def test_projection_groups_and_search_preserve_stable_terminal_numbers(self) -> ) ) try: - code, output = run_pty(fixture, b"/codex10\n\n") - self.assertEqual(2, code) - self.assertIn( - "4 sessions · 3 ready here · 1 open elsewhere", output - ) - self.assertIn("Ready to open", output) + code, output = run_pty(fixture, b"/codex10\nq\n") + self.assertEqual(0, code) + self.assertIn("4 sessions · 3 ready · 1 open elsewhere", output) + self.assertIn("Ready", output) self.assertIn("Open elsewhere", output) - self.assertIn("status unavailable", output) + self.assertIn("| CDX | pe… | pe… | pending", output) self.assertNotIn("state unavailable", output) - self.assertLess(output.index("Claude\n"), output.index("Codex\n")) self.assertLess(output.index("claude1"), output.index("claude2")) self.assertIn("1 match of 4 sessions", output) self.assertRegex( output, - r"(?m)^\s+10\s+Searchable task\s+\|\s+working$", + r"(?m)^\s+10\s+Searchable task \| CDX \| pe… \| pe… \| working \| last active pending$", ) self.assertNotIn("[codex10]", output) self.assertEqual([], fixture.sp_entries()) finally: fixture.close() - def test_refresh_and_provider_regroup_preserve_terminal_number_and_internal_row(self) -> None: + def test_refresh_and_provider_regroup_preserve_terminal_number_and_internal_row( + self, + ) -> None: before = row("transition", number=7401, provider="unknown") before["row"] = 1 before["identity"]["uuid"] = None @@ -480,41 +920,47 @@ def test_refresh_and_provider_regroup_preserve_terminal_number_and_internal_row( refreshed_document=inventory(after), ) try: - code, output = run_pty(fixture, b"r\n7401\n\n\n") - self.assertEqual(2, code) + code, output = run_pty(fixture, b"r\n7401\nb\nq\n") + self.assertEqual(0, code) self.assertRegex(output, r"(?m)^\s+7401\s+Unknown transition") self.assertRegex(output, r"(?m)^\s+7401\s+Codex transition") - self.assertIn("Already open in another SSH window", output) + self.assertIn("Open elsewhere.", output) self.assertEqual([], fixture.sp_entries()) finally: fixture.close() def test_sparse_page_membership_and_arbitrary_width_number_are_exact(self) -> None: huge = 12345678901234567890 - rows = [ - row(f"s{index:02}", number=1000 + index * 13) - for index in range(1, 25) - ] + rows = [row(f"s{index:02}", number=1000 + index * 13) for index in range(1, 25)] rows[-1]["terminal_number"] = huge fixture = LoginFixture(inventory(*rows)) try: code, output = run_pty( fixture, - f"{huge}\nnext\n{huge}\n\n".encode(), + f"{huge}\nnext\n{huge}\nq\n".encode(), lines=24, columns=60, ) - self.assertEqual(2, code) + self.assertEqual(0, code) self.assertIn( - "Choose a number shown here. Nothing changed.", + "on this screen. Numbers shown here work.", output, ) + # No longer truncated: the title column is sized against the pair + # rather than against the state word alone. self.assertRegex(output, rf"(?m)^\s+{huge}\s+Codex s24") entries = fixture.sp_entries() self.assertEqual(["picker-open"], [entry["args"][0] for entry in entries]) self.assertEqual("s24", entries[0]["proof"]["shpool_id"]) self.assertLessEqual( - max(display_cells(line) for line in output.splitlines()[2:]), + max( + display_cells(line) + for line in output.splitlines()[2:] + # The refusal names the number that was typed, and this one + # is twenty digits long. The rule under test is that the + # list frame fits the window. + if "Numbers shown here work." not in line + ), 59, ) finally: @@ -523,19 +969,24 @@ def test_sparse_page_membership_and_arbitrary_width_number_are_exact(self) -> No def test_missing_terminal_number_is_not_renumbered_or_actionable(self) -> None: unsafe = row("no-number", number=1) unsafe["terminal_number"] = None + unsafe["mutation_allowed"] = False + unsafe["mutation_rejection_reason"] = "missing-shell-generation" fixture = LoginFixture(inventory(unsafe)) try: - code, output = run_pty(fixture, b"1\nm\n\n\n") - self.assertEqual(2, code) - self.assertIn( - "0 sessions · 0 ready here · 0 open elsewhere", output - ) - self.assertIn("More: m (1)", output) + code, output = run_pty(fixture, b"1\nm\n\nq\n") + self.assertEqual(0, code) + self.assertNotIn("Codex no-number", output) + self.assertIn("Sessions: none.", output) + self.assertNotIn("0 sessions", output) + self.assertIn("more m", output) self.assertIn( - "Unavailable: 1 session record without a live shell (no actions", + "Unavailable: 1 session whose shell is gone", output, ) - self.assertIn("Choose a number shown here. Nothing changed.", output) + # The one thing the count never said: what clears it, and where to + # read the rest. + self.assertIn("sp help unavailable", output) + self.assertIn("on this screen. Numbers shown here work.", output) self.assertNotIn("[no-number]", output) self.assertEqual([], fixture.sp_entries()) finally: @@ -545,15 +996,17 @@ def test_mixed_unavailable_row_does_not_block_exact_kill_proof(self) -> None: exact = row("exact", number=9, availability="attached") unavailable = row("no-shell", number=2) unavailable["terminal_number"] = None + unavailable["mutation_allowed"] = False + unavailable["mutation_rejection_reason"] = "missing-shell-generation" fixture = LoginFixture(inventory(exact, unavailable)) try: - code, output = run_pty(fixture, b"k 2\nk 9\nm\n\n\n") - self.assertEqual(2, code) + code, output = run_pty(fixture, b"k 2\nk 9\nm\n\nq\n") + self.assertEqual(0, code) self.assertIn( - "Unavailable: 1 session record without a live shell (no actions", + "Unavailable: 1 session whose shell is gone", output, ) - self.assertIn("Choose a number shown here. Nothing changed.", output) + self.assertIn("There is no session 2 on this screen.", output) self.assertNotIn("[no-shell]", output) entries = fixture.sp_entries() self.assertEqual(1, len(entries)) @@ -566,9 +1019,9 @@ def test_paging_rejects_hidden_number_then_opens_with_canonical_proof(self) -> N rows = tuple(row(f"s{number}", number=number) for number in range(1, 26)) fixture = LoginFixture(inventory(*rows)) try: - code, output = run_pty(fixture, b"17\nnext\n17\n\n", lines=24) - self.assertEqual(2, code) - self.assertIn("Choose a number shown here", output) + code, output = run_pty(fixture, b"17\nnext\n17\nq\n", lines=24) + self.assertEqual(0, code) + self.assertIn("on this screen. Numbers shown here work.", output) self.assertIn("Page 1/2 | next", output) self.assertIn("Page 2/2 | prev", output) entries = fixture.sp_entries() @@ -600,6 +1053,36 @@ def test_paging_rejects_hidden_number_then_opens_with_canonical_proof(self) -> N finally: fixture.close() + def test_rows_fit_the_real_pty_width_when_no_size_is_exported(self) -> None: + """Production exports no COLUMNS/LINES: with the frame captured to a + file, the picker must still measure the real terminal, not a probe + fallback, or every row lays out for a 100-column window.""" + fixture = LoginFixture( + inventory( + row( + "alpha-with-a-fairly-long-name-that-must-truncate-somewhere", + number=3, + ) + ) + ) + try: + code, output = run_pty( + fixture, + b"q\n", + env_updates={"COLUMNS": None, "LINES": None}, + pty_winsize=(24, 40), + ) + self.assertEqual(0, code) + self.assertRegex(output, r"\s3\s+\S") + for line in output.splitlines()[2:]: + self.assertLessEqual( + display_cells(line), + 39, + f"line wider than the 40-column terminal: {line!r}", + ) + finally: + fixture.close() + def test_compact_rows_fit_24_and_36_with_exact_directions(self) -> None: rows = [ row( @@ -658,9 +1141,9 @@ def test_compact_rows_fit_24_and_36_with_exact_directions(self) -> None: fixture = LoginFixture(document) try: code, output = run_pty( - fixture, b"\n", lines=lines, columns=columns + fixture, b"q\n", lines=lines, columns=columns ) - self.assertEqual(2, code) + self.assertEqual(0, code) self.assertNotIn(" Page ", output) rendered = output.splitlines()[2:] self.assertLessEqual( @@ -670,11 +1153,7 @@ def test_compact_rows_fit_24_and_36_with_exact_directions(self) -> None: self.assertEqual( 11, sum( - bool( - __import__("re").match( - r"^\s+\d+\s+.+\s+\|", line - ) - ) + bool(__import__("re").match(r"^\s+\d+\s+.+\s+\|", line)) for line in rendered ), ) @@ -701,19 +1180,18 @@ def test_compact_rows_fit_24_and_36_with_exact_directions(self) -> None: try: code, output = run_pty( fixture, - b"next\nnext\nprev\n/d3\n\n", + b"next\nnext\nprev\n/d3\nq\n", lines=24, columns=80, ) - self.assertEqual(2, code) - self.assertIn("Page 1/4 | next", output) - self.assertIn("Page 2/4 | prev | next", output) - self.assertIn("Page 3/4 | prev | next", output) + self.assertEqual(0, code) + self.assertIn("Page 1/2 | next", output) + self.assertIn("Page 2/2 | prev", output) self.assertIn("1 match of 31 sessions", output) self.assertNotIn("Page 1/1", output) self.assertRegex( output, - r"(?m)^\s+11\s+Codex d3\s+\|\s+working$", + r"(?m)^\s+11\s+Codex d3\s+\| CDX \| pe… \| pe… \| working \| last active pending$", ) self.assertEqual([], fixture.sp_entries()) finally: @@ -748,16 +1226,20 @@ def test_blocking_replies_lead_each_availability_group(self) -> None: ) ) try: - code, output = run_pty(fixture, b"\n", lines=30, columns=120) - self.assertEqual(2, code) - self.assertEqual(2, output.count("Needs your reply")) + code, output = run_pty(fixture, b"q\n", lines=30, columns=120) + self.assertEqual(0, code) + # Three: the footer's "needs you a", and the two blocking rows' + # state slots, one word for one state since the needs-you rename. + self.assertEqual(3, output.count("needs you")) self.assertLess(output.index("reply-codex"), output.index("ready-claude")) self.assertLess( output.index("reply-open-codex"), output.index("open-claude") ) self.assertIn("optional", output) - self.assertIn("reply optional", output) - self.assertNotIn("! reply optional", output) + # "reply optional" is the collector's word for a session that is + # still working, and the screen speaks one vocabulary. + self.assertNotIn("reply optional", output) + self.assertEqual(3, output.count("| working")) finally: fixture.close() @@ -774,13 +1256,162 @@ def test_automatic_name_states_are_visible_but_never_attention_sorted(self) -> N ) fixture = LoginFixture(inventory(pending, failed, blocking)) try: - code, output = run_pty(fixture, b"\n", columns=120) - self.assertEqual(2, code) + code, output = run_pty(fixture, b"q\n", columns=120) + self.assertEqual(0, code) self.assertLess(output.index("blocking"), output.index("pending")) self.assertLess(output.index("blocking"), output.index("failed")) - self.assertIn("name pending | working", output) - self.assertIn("name failed | working", output) - self.assertEqual(1, output.count("Needs your reply")) + self.assertIn("name pending", output) + self.assertIn("name failed", output) + self.assertEqual(1, output.count("| needs you |")) + finally: + fixture.close() + + def test_pending_provider_bar_title_is_internal_in_normal_rows(self) -> None: + pending = row( + "title-pending", + number=6, + provider="codex", + availability="attached", + ) + pending["provider_title_state"] = "pending" + fixture = LoginFixture(inventory(pending)) + try: + code, output = run_pty(fixture, b"q\n", columns=120) + self.assertEqual(0, code) + self.assertIn("working", output) + self.assertNotIn("title pending", output) + finally: + fixture.close() + + def test_deferred_provider_bar_title_is_not_shown_as_actionable(self) -> None: + deferred = row( + "title-deferred", + number=6, + provider="codex", + availability="attached", + ) + deferred["provider_title_state"] = "deferred" + fixture = LoginFixture(inventory(deferred)) + try: + code, output = run_pty(fixture, b"q\n", columns=120) + self.assertEqual(0, code) + self.assertNotIn("title pending", output) + self.assertIn("working", output) + finally: + fixture.close() + + def test_enter_on_the_open_elsewhere_panel_moves_the_session_here(self) -> None: + """Enter takes the most likely option on that panel. + + A person who picked a session that is open elsewhere almost always + wants it here; Enter used to walk back out of the panel instead. + """ + elsewhere = row( + "open-elsewhere", + number=6, + provider="codex", + availability="attached", + ) + fixture = LoginFixture(inventory(elsewhere)) + try: + code, output = run_pty(fixture, b"6\n\nq\n", columns=120) + self.assertEqual(0, code) + self.assertIn("↵ move it here · b back", output) + entries = fixture.sp_entries() + self.assertEqual( + ["picker-takeover"], [entry["args"][0] for entry in entries] + ) + self.assertEqual("open-elsewhere", entries[0]["proof"]["shpool_id"]) + finally: + fixture.close() + + def test_b_on_the_open_elsewhere_panel_still_goes_back(self) -> None: + elsewhere = row( + "open-elsewhere", + number=6, + provider="codex", + availability="attached", + ) + fixture = LoginFixture(inventory(elsewhere)) + try: + code, output = run_pty(fixture, b"6\nb\nq\n", columns=120) + self.assertEqual(0, code) + self.assertIn("Open elsewhere.", output) + self.assertEqual([], fixture.sp_entries()) + finally: + fixture.close() + + def test_open_elsewhere_pending_title_offers_proof_bound_refresh(self) -> None: + pending = row( + "title-pending", + number=6, + provider="codex", + availability="attached", + ) + pending["provider_title_state"] = "pending" + fixture = LoginFixture(inventory(pending)) + try: + code, output = run_pty(fixture, b"6\n5\nq\n", columns=120) + self.assertEqual(0, code) + self.assertIn("Apply the pending title", output) + entries = fixture.sp_entries() + self.assertEqual(1, len(entries)) + self.assertEqual("picker-title-refresh", entries[0]["args"][0]) + self.assertEqual("title-pending", entries[0]["proof"]["shpool_id"]) + finally: + fixture.close() + + def test_open_elsewhere_account_change_is_proof_bound(self) -> None: + waiting = row( + "account-change", + number=6, + provider="codex", + availability="attached", + ) + waiting["agent_status"] = "idle" + waiting["account_alias"] = "wren" + waiting["account_switch_capable"] = True + fixture = LoginFixture(inventory(waiting)) + try: + code, output = run_pty(fixture, b"6\n4\n2\nq\n", columns=120) + self.assertEqual(0, code) + self.assertIn("Change subscription account", output) + self.assertNotIn("[y/N]", output) + entries = fixture.sp_entries() + self.assertEqual(1, len(entries)) + self.assertEqual("picker-account-switch", entries[0]["args"][0]) + self.assertEqual("work", entries[0]["args"][2]) + self.assertEqual("account-change", entries[0]["proof"]["shpool_id"]) + self.assertEqual("account-change", entries[0]["confirm_id"]) + finally: + fixture.close() + + def test_open_elsewhere_refuses_an_unhealthy_target_account(self) -> None: + waiting = row( + "account-change", + number=6, + provider="codex", + availability="attached", + ) + waiting["agent_status"] = "idle" + waiting["account_alias"] = "wren" + waiting["account_switch_capable"] = True + fixture = LoginFixture(inventory(waiting)) + try: + # One Enter to leave the account step the refusal kept open, one + # more to leave the picker. + code, output = run_pty( + fixture, + b"6\n4\n2\n\nq\n", + columns=120, + env_updates={"LOGIN_ACCOUNT_SECOND_ELIGIBLE": "0"}, + ) + self.assertEqual(0, code) + # The refusal quotes that row's own state, and the step stays open + # for another pick instead of unwinding to the dashboard. + self.assertIn("is not selectable: blocked: health error", output) + self.assertEqual(2, output.count("target account ❯")) + self.assertEqual([], fixture.sp_entries()) finally: fixture.close() @@ -794,10 +1425,11 @@ def test_automatic_name_states_are_visible_but_never_attention_sorted(self) -> N combined["display_title"] = "A Long Combined Attention And Naming State" fixture = LoginFixture(inventory(combined)) try: - code, output = run_pty(fixture, b"\n", columns=60) - self.assertEqual(2, code) - self.assertIn("Needs your reply", output) - self.assertIn("! needs your reply", output) + code, output = run_pty(fixture, b"q\n", columns=60) + self.assertEqual(0, code) + # One phrase for the attention state on every surface (picker-rows + # ruling): the row says "needs you", never "needs you". + self.assertIn("needs you", output) finally: fixture.close() @@ -808,7 +1440,7 @@ def test_colored_picker_uses_only_the_approved_sgr_allowlist(self) -> None: try: code, output = run_pty( fixture, - b"\n", + b"q\n", columns=100, env_updates={ "NO_COLOR": None, @@ -816,22 +1448,25 @@ def test_colored_picker_uses_only_the_approved_sgr_allowlist(self) -> None: "TERM": "xterm-ghostty", }, ) - self.assertEqual(2, code) + self.assertEqual(0, code) codes = set(SGR.findall(output)) - self.assertTrue({"0", "1", "32", "33", "36"}.issubset(codes)) - self.assertLessEqual(codes, {"0", "1", "32", "33", "36"}) - self.assertRegex( - output, - r"\x1b\[1m\x1b\[36mCodex\x1b\[0m", + self.assertTrue({"0", "1", "32", "33", "38;2;64;216;209"}.issubset(codes)) + self.assertLessEqual( + codes, + {"0", "1", "32", "33", "36", "38;2;64;216;209"}, ) self.assertRegex( output, r"\x1b\[1m\x1b\[32m\s*7\x1b\[0m", ) plain = strip_sgr(output) + # The startup screen clear (erase display + cursor home) is the + # one approved non-SGR sequence on capable terminals; nothing + # else may survive the strip. + plain = strip_screen_control(plain) self.assertNotIn("\x1b", plain) self.assertIn("Unsafe [31m title ]0;bad", plain) - self.assertIn("! needs your reply", plain) + self.assertIn("needs you", plain) self.assertLessEqual( max(display_cells(line) for line in output.splitlines()[2:]), 99, @@ -839,6 +1474,56 @@ def test_colored_picker_uses_only_the_approved_sgr_allowlist(self) -> None: finally: fixture.close() + def test_provider_abbreviations_use_distinct_contrasting_colors(self) -> None: + fixture = LoginFixture( + inventory( + row("claude-color", number=1, provider="claude"), + row("codex-color", number=2, provider="codex"), + ) + ) + try: + code, output = run_pty( + fixture, + b"q\n", + columns=120, + env_updates={ + "NO_COLOR": None, + "SESSION_KIT_NO_COLOR": None, + "TERM": "xterm-ghostty", + }, + ) + self.assertEqual(0, code) + self.assertIn( + "\x1b[1m\x1b[38;2;249;215;108mCLD\x1b[0m", + output, + ) + self.assertIn( + "\x1b[1m\x1b[38;2;64;216;209mCDX\x1b[0m", + output, + ) + finally: + fixture.close() + + def test_unknown_provider_badge_is_neutral_not_claude_yellow(self) -> None: + unknown = row("unknown-color", number=4, provider="other") + fixture = LoginFixture(inventory(unknown)) + try: + code, output = run_pty( + fixture, + b"q\n", + columns=120, + env_updates={ + "NO_COLOR": None, + "SESSION_KIT_NO_COLOR": None, + "TERM": "xterm-ghostty", + }, + ) + self.assertEqual(0, code) + self.assertIn("\x1b[1m\x1b[38;2;205;210;220mUNK\x1b[0m", output) + self.assertNotIn("\x1b[1m\x1b[38;2;249;215;108mUNK\x1b[0m", output) + finally: + fixture.close() + def test_color_is_disabled_by_presence_or_unsupported_terminal(self) -> None: cases = ( ( @@ -887,12 +1572,14 @@ def test_color_is_disabled_by_presence_or_unsupported_terminal(self) -> None: ) ) try: - code, output = run_pty( - fixture, b"\n", env_updates=updates - ) - self.assertEqual(2, code) - self.assertNotIn("\x1b", output) - self.assertIn("! needs your reply", output) + code, output = run_pty(fixture, b"q\n", env_updates=updates) + self.assertEqual(0, code) + if updates.get("TERM") in {"dumb", None}: + self.assertNotIn("\x1b", output) + else: + plain = strip_screen_control(output) + self.assertNotIn("\x1b", plain) + self.assertIn("needs you", output) finally: fixture.close() @@ -906,14 +1593,12 @@ def test_output_age_is_conditional_and_quiet_threshold_is_protected(self) -> Non quiet["recent_output_age_seconds"] = 2700 fixture = LoginFixture(inventory(current, minute, forty_four, quiet)) try: - code, output = run_pty(fixture, b"\n", columns=180) - self.assertEqual(2, code) + code, output = run_pty(fixture, b"q\n", columns=180) + self.assertEqual(0, code) self.assertNotIn("recent output", output) self.assertNotIn("last output now", output) - self.assertIn("last output 1m", output) - self.assertIn("last output 44m", output) - self.assertIn("quiet 45m", output) - self.assertNotRegex(output, r"quiet 45m.*last output") + self.assertNotIn("last output", output) + self.assertIn("| CDX | pe… | pe… | working", output) finally: fixture.close() @@ -925,22 +1610,165 @@ def test_output_age_is_conditional_and_quiet_threshold_is_protected(self) -> Non crowded["subagents"] = [{"status": "open"} for _ in range(20)] fixture = LoginFixture(inventory(crowded)) try: - code, output = run_pty(fixture, b"\n", columns=60) - self.assertEqual(2, code) + code, output = run_pty(fixture, b"q\n", columns=60) + self.assertEqual(0, code) self.assertIn("| working", output) self.assertNotIn("last output 30m", output) - self.assertNotIn("20 subagents", output) + self.assertIn("working | 20 subagents | 30m", output) finally: fixture.close() - def test_snapshot_control_text_is_sanitized_at_render_boundary(self) -> None: - unsafe = row("main1", number=1) - unsafe.update( - { - "display_provider": "codex\x1b[31m", - "display_title": "Unsafe\x1b]0;title\u0007界 e\u0301", - "display_shpool_id": "main\x1b[2J1", - "agent_status": "working\x1b[99m", + def test_main_rows_show_state_alias_and_opened_age_in_aligned_columns(self) -> None: + """Every row carries one labelled age, and the columns line up. + + The response and waiting ages this row once showed came from the + retired attention queue; `opened` is the one age the collector still + records, so it is the one every row is checked against here. + """ + now_ms = int(time.time() * 1000) + ready = row("recent-ready", number=2, provider="claude") + ready["agent_status"] = "idle" + ready["account_alias"] = "main" + ready["started_at_unix_ms"] = now_ms - 55 * 60 * 1000 + attached = row( + "working-elsewhere", + number=8, + provider="codex", + availability="attached", + ) + attached["agent_status"] = "working" + attached["account_alias"] = "backup" + attached["started_at_unix_ms"] = now_ms - 3 * 3600 * 1000 + waiting = row( + "waiting-ready", + number=9, + provider="claude", + needs_you=True, + ) + waiting["account_alias"] = "spare" + waiting["started_at_unix_ms"] = now_ms - 12 * 3600 * 1000 + shell = row("plain-shell", number=10, provider="shell") + shell["agent_status"] = "idle" + shell["started_at_unix_ms"] = now_ms - 2 * 3600 * 1000 + fixture = LoginFixture(inventory(ready, attached, waiting, shell)) + try: + code, output = run_pty(fixture, b"q\n", columns=200) + self.assertEqual(0, code) + self.assertIn("Ready", output) + self.assertIn("Open elsewhere", output) + self.assertRegex(output, r"needs you \| last active pending") + self.assertRegex(output, r"working\s+\| last active pending") + self.assertIn("| CLD | main | pending | needs you", output) + self.assertIn("| CDX | backup | pending | working", output) + self.assertIn("| CLD | spare | pending | needs you", output) + self.assertRegex(output, r"\| SHL \| pendi… \| no model\s+\| needs you") + # One time phrase, one starting column, on every row. + rows_shown = [ + line + for line in strip_sgr(output).splitlines() + if " | CLD | " in line or " | CDX | " in line or " | SHL | " in line + ] + self.assertEqual(4, len(rows_shown), rows_shown) + self.assertEqual( + 1, len({line.index("last active") for line in rows_shown}), rows_shown + ) + lines = { + name: next(line for line in output.splitlines() if name in line) + for name in ( + "recent-ready", + "working-elsewhere", + "waiting-ready", + "plain-shell", + ) + } + self.assertEqual( + 1, + len( + { + lines["recent-ready"].index("CLD"), + lines["working-elsewhere"].index("CDX"), + lines["waiting-ready"].index("CLD"), + lines["plain-shell"].index("SHL"), + } + ), + ) + # The state column starts at one place on every row -- and there + # are two words for it now, not three. + self.assertEqual( + 1, + len( + { + lines["recent-ready"].index("needs you"), + lines["working-elsewhere"].index( + "working", lines["working-elsewhere"].index("CDX") + ), + lines["waiting-ready"].index("needs you"), + lines["plain-shell"].index("needs you"), + } + ), + ) + # So does the one time each row carries. + self.assertEqual( + 1, + len({line.index("last active") for line in lines.values()}), + ) + finally: + fixture.close() + + def test_narrow_main_row_drops_age_before_the_primary_state(self) -> None: + now_ms = int(time.time() * 1000) + item = row("narrow-age", number=8, provider="codex") + item["display_title"] = ( + "A useful descriptive title that should keep the available room" + ) + item["started_at_unix_ms"] = now_ms - 30 * 60 * 1000 + fixture = LoginFixture(inventory(item)) + try: + code, output = run_pty(fixture, b"q\n", columns=60) + self.assertEqual(0, code) + # The model column costs real width, so the title yields a little + # -- but never below the point where it names anything. + # One or two cells shorter than before: the state-and-time pair is + # reserved ahead of the title now, and at 60 columns the title sits + # on its floor. + self.assertIn("A useful de", output) + self.assertIn("| working", output) + self.assertNotIn("30 min ago", output) + self.assertNotIn("last active", output) + finally: + fixture.close() + + def test_main_age_uses_readable_unit_and_date_boundaries(self) -> None: + now_ms = int(time.time() * 1000) + offsets = (5, 2 * 60, 2 * 3600, 2 * 86400, 31 * 86400) + items = [ + row(f"age-{index}", number=index, provider="claude") + for index in range(1, 6) + ] + for item, seconds in zip(items, offsets, strict=True): + item["agent_status"] = "idle" + item["started_at_unix_ms"] = now_ms - seconds * 1000 + fixture = LoginFixture(inventory(*items)) + try: + code, output = run_pty(fixture, b"q\n", columns=220) + self.assertEqual(0, code) + # One phrase, one shape, whatever the span: this row used to + # switch between `just now`, `2 hr ago` and a bare date. + for line in strip_sgr(output).splitlines(): + if " | CLD | " not in line: + continue + self.assertIn("| last active ", line) + finally: + fixture.close() + + def test_snapshot_control_text_is_sanitized_at_render_boundary(self) -> None: + unsafe = row("main1", number=1) + unsafe.update( + { + "display_provider": "codex\x1b[31m", + "display_title": "Unsafe\x1b]0;title\u0007界 e\u0301", + "display_shpool_id": "main\x1b[2J1", + "agent_status": "working\x1b[99m", } ) document = inventory(unsafe, stale=True) @@ -963,27 +1791,19 @@ def test_snapshot_control_text_is_sanitized_at_render_boundary(self) -> None: ] fixture = LoginFixture(document) try: - code, output = run_pty( - fixture, b"\n", lines=24, columns=60 - ) - self.assertEqual(2, code) - self.assertNotIn("\x1b", output) - self.assertIn( - "Open: number · New: n · Kill: k number", - output, - ) - self.assertIn( - "Terminal: Enter · Search: /text · More: m (1) · Help: ?", - output, - ) + code, output = run_pty(fixture, b"q\n", lines=24, columns=60) + self.assertEqual(0, code) + plain = strip_screen_control(output) + self.assertNotIn("\x1b", plain) + self.assertIn("↵ open 1 · # · kill k #", output) self.assertFalse( any( unicodedata.category(character).startswith("C") - for character in output.replace("\n", "").replace("\r", "") + for character in plain.replace("\n", "").replace("\r", "") ) ) - self.assertIn(" Unknown", output) - self.assertIn("Warning: showing cached inventory", output) + self.assertIn("| pending | pending", output) + self.assertIn("Showing a cached list.", output) self.assertNotIn("[22222222]", output) self.assertLessEqual( max(display_cells(line) for line in output.splitlines()[2:]), @@ -1006,10 +1826,10 @@ def test_context_history_close_and_ai_rename_use_proof_bound_children(self) -> N try: code, output = run_pty( fixture, - b"9\n2\nx 9\nname 9\nBetter name\n\n", + b"9\n2\nk 9\nname 9\nBetter name\nq\n", ) - self.assertEqual(2, code) - self.assertIn("Already open in another SSH window", output) + self.assertEqual(0, code) + self.assertIn("Open elsewhere.", output) entries = fixture.sp_entries() self.assertEqual( ["picker-history", "picker-close", "picker-name"], @@ -1022,8 +1842,8 @@ def test_context_history_close_and_ai_rename_use_proof_bound_children(self) -> N finally: fixture.close() - def test_kill_shortcut_and_close_alias_are_proof_bound(self) -> None: - for command in ("k 9", "K 9", "x 9", "X 9"): + def test_the_close_key_is_proof_bound(self) -> None: + for command in ("k 9", "K 9", "k9", "K9"): with self.subTest(command=command): fixture = LoginFixture( inventory( @@ -1036,12 +1856,16 @@ def test_kill_shortcut_and_close_alias_are_proof_bound(self) -> None: ) ) try: + # The close, then q: nothing stands between the typed + # number and the close, and leaving is its own key. code, output = run_pty( fixture, - f"{command}\n\n".encode(), + f"{command}\nq\n".encode(), ) - self.assertEqual(2, code) - self.assertNotIn("Unknown choice", output) + self.assertEqual(0, code) + self.assertNotIn("There is no such key", output) + self.assertNotIn("[y/N]", output) + self.assertNotIn("↵ again leaves the picker.", output) entries = fixture.sp_entries() self.assertEqual(1, len(entries)) self.assertEqual("picker-close", entries[0]["args"][0]) @@ -1049,30 +1873,162 @@ def test_kill_shortcut_and_close_alias_are_proof_bound(self) -> None: finally: fixture.close() + def test_kill_accepts_lists_and_ranges_and_refuses_on_one_bad_token( + self, + ) -> None: + fixture = LoginFixture( + inventory( + row("open1", number=4, provider="codex"), + row("open2", number=5, provider="codex"), + row("open3", number=6, provider="claude"), + ) + ) + try: + code, output = run_pty(fixture, b"k 4, 6\nq\n") + self.assertEqual(0, code) + entries = fixture.sp_entries() + self.assertEqual(2, len(entries)) + self.assertEqual( + {("picker-close", "open1"), ("picker-close", "open3")}, + {(entry["args"][0], entry["proof"]["shpool_id"]) for entry in entries}, + ) + finally: + fixture.close() + # A range expands; one unshown number refuses the WHOLE request. + fixture = LoginFixture( + inventory( + row("open1", number=4, provider="codex"), + row("open2", number=5, provider="codex"), + ) + ) + try: + code, output = run_pty(fixture, b"k 4-9\nk 4-5\nq\n") + self.assertEqual(0, code) + self.assertIn("There is no session 6 on this screen.", output) + entries = fixture.sp_entries() + self.assertEqual(2, len(entries)) + self.assertEqual( + {"open1", "open2"}, + {entry["proof"]["shpool_id"] for entry in entries}, + ) + finally: + fixture.close() + + def test_kill_all_closes_exactly_what_the_page_is_showing(self) -> None: + """`all` is the same word the recovery and project screens take.""" + fixture = LoginFixture( + inventory( + row("open1", number=4, provider="codex"), + row("open2", number=5, provider="codex"), + row("open3", number=6, provider="claude"), + ) + ) + try: + code, output = run_pty(fixture, b"k all\nq\n") + self.assertEqual(0, code) + self.assertEqual( + {"open1", "open2", "open3"}, + {entry["proof"]["shpool_id"] for entry in fixture.sp_entries()}, + ) + self.assertNotIn("There is no session number in that", output) + finally: + fixture.close() + + def test_closing_asks_nothing_and_closes_exactly_the_number_typed( + self, + ) -> None: + """No confirmation, anywhere: the number was typed against the rows. + + The close used to ask twice in two grammars -- here, and again inside + the proof-bound child -- while the screen promised the session was + restorable. + """ + fixture = LoginFixture( + inventory( + row("open1", number=4, provider="codex"), + row("open2", number=5, provider="codex"), + ) + ) + try: + code, output = run_pty(fixture, b"k 4\nq\n") + self.assertEqual(0, code) + self.assertNotIn("[y/N]", output) + self.assertNotIn("cannot be undone", output) + self.assertNotIn("Nothing closed.", output) + entries = fixture.sp_entries() + self.assertEqual( + [("picker-close", "open1")], + [(entry["args"][0], entry["proof"]["shpool_id"]) for entry in entries], + ) + # The child's guard is answered by naming the exact session, so a + # close behaves the same whether or not a terminal is attached. + self.assertEqual("open1", entries[0]["confirm_id"]) + finally: + fixture.close() + + def test_kill_all_refuses_a_page_that_moved_under_the_typing(self) -> None: + """`all` means the page that was read, not the page that arrived. + + The background refresh re-sorts the list on its own, so a mass close + resolved at Enter could close a set nobody had seen -- and nothing + stands between that Enter and the kill. + """ + first = row("alpha", number=1, provider="codex") + second = row("bravo", number=2, provider="codex") + fixture = LoginFixture( + inventory(first), refreshed_document=inventory(first, second) + ) + try: + code, output = run_pty( + fixture, + b"k al", + env_updates={ + "SESSION_KIT_PICKER_REFRESH_SECONDS": "2", + "SESSION_KIT_NO_COLOR": None, + "SESSION_KIT_PICKER_FILTER_LIVE": "0", + }, + deferred=("Codex bravo", b"l\nq\n"), + ) + self.assertEqual(0, code) + self.assertIn("The list changed. Nothing changed.", output) + self.assertEqual([], fixture.sp_entries()) + finally: + fixture.close() + def test_kill_shortcut_refuses_invalid_or_unshown_numbers(self) -> None: fixture = LoginFixture(inventory(row("open1", number=9))) try: - code, output = run_pty(fixture, b"k nope\nk 99\n\n") - self.assertEqual(2, code) + code, output = run_pty(fixture, b"k nope\nk 99\nq\n") + self.assertEqual(0, code) + self.assertIn( + "There is no session number in that. Numbers shown here work.", + output, + ) self.assertIn( - "Use k followed by a visible number. Nothing changed.", + "There is no session 99 on this screen. Numbers shown here work.", output, ) - self.assertIn("Choose a number shown here. Nothing changed.", output) self.assertEqual([], fixture.sp_entries()) finally: fixture.close() - def test_help_presents_kill_shortcut_and_close_alias(self) -> None: + def test_help_presents_the_close_key_and_nothing_that_is_not_a_key( + self, + ) -> None: fixture = LoginFixture(inventory()) try: - code, output = run_pty(fixture, b"?\n\n") - self.assertEqual(2, code) + code, output = run_pty(fixture, b"?\n\nq\n", columns=110) + self.assertEqual(0, code) self.assertIn( - "k <number> Kill the exact displayed session after exact-ID confirmation", + "k numbers Kill sessions: k 5 · k 5, 6, 8 · k 4-7 · k all", output, ) - self.assertIn("x <number> Compatibility alias for k", output) + # Rows of the open-session action menu are not list keys, and the + # x alias is gone: help names keys, and only keys. + self.assertNotIn("Compatibility alias", output) + self.assertNotIn("action 4", output) + self.assertNotIn("action 5", output) + self.assertNotIn("after confirming", output) finally: fixture.close() @@ -1083,10 +2039,10 @@ def test_name_reset_and_fork_use_exact_proof_bound_terminal_number(self) -> None try: code, output = run_pty( fixture, - b"name reset 88005553535\nfork 88005553535\n\n", + b"name reset 88005553535\nfork 88005553535\nq\n", columns=80, ) - self.assertEqual(2, code) + self.assertEqual(0, code) entries = fixture.sp_entries() self.assertEqual( ["picker-name-reset", "picker-fork"], @@ -1110,9 +2066,9 @@ def test_fork_and_name_reset_refuse_nonexact_or_hidden_numbers(self) -> None: try: code, output = run_pty( fixture, - b"/plain\nfork 91\nname reset 91\nfork 700\n\n", + b"/plain\nfork 91\nname reset 91\nfork 700\nq\n", ) - self.assertEqual(2, code) + self.assertEqual(0, code) self.assertIn( "Only exact Claude and Codex conversations can be forked.", output, @@ -1122,7 +2078,7 @@ def test_fork_and_name_reset_refuse_nonexact_or_hidden_numbers(self) -> None: output, ) self.assertIn( - "Choose a number shown here. Nothing changed.", + "There is no session 700 on this screen. Numbers shown here work.", output, ) self.assertEqual([], fixture.sp_entries()) @@ -1130,7 +2086,7 @@ def test_fork_and_name_reset_refuse_nonexact_or_hidden_numbers(self) -> None: fixture.close() def test_long_silent_running_session_reports_its_silence(self) -> None: - """"running" must not be printed for a session that stopped producing. + """ "running" must not be printed for a session that stopped producing. A Codex run froze for eight hours while the list kept calling it running, which is how it went unnoticed overnight. @@ -1140,10 +2096,11 @@ def test_long_silent_running_session_reports_its_silence(self) -> None: stalled["recent_output_age_seconds"] = 28_170 # 7h 49m fixture = LoginFixture(inventory(stalled)) try: - code, output = run_pty(fixture, b"\n", columns=200) - self.assertEqual(2, code) - self.assertIn("quiet 7h 49m", output) + code, output = run_pty(fixture, b"q\n", columns=200) + self.assertEqual(0, code) + self.assertIn("| needs you", output) self.assertNotIn("| running", output) + self.assertNotIn("| idle", output) finally: fixture.close() @@ -1154,20 +2111,14 @@ def test_briefly_quiet_running_session_is_still_running(self) -> None: busy["recent_output_age_seconds"] = 1800 # 30m fixture = LoginFixture(inventory(busy)) try: - code, output = run_pty(fixture, b"\n", columns=200) - self.assertEqual(2, code) - self.assertIn("running", output) - self.assertNotIn("quiet 7h", output) + code, output = run_pty(fixture, b"q\n", columns=200) + self.assertEqual(0, code) + self.assertIn("| working", output) + self.assertNotIn("| idle", output) finally: fixture.close() - def test_narrow_terminal_drops_subagents_before_staleness(self) -> None: - """How long a session has been quiet is never the field that is cut. - - At a crowded width the picker used to drop "recent output 7h 52m ago" - and keep "20 subagents", hiding the only evidence that a session was - dead. - """ + def test_narrow_terminal_keeps_subagents_and_staleness(self) -> None: stalled = row("ready1", number=1, recent_output_at_unix_ms=1) stalled["agent_status"] = "running" stalled["recent_output_age_seconds"] = 28_170 @@ -1179,18 +2130,19 @@ def test_narrow_terminal_drops_subagents_before_staleness(self) -> None: stalled["native_title"] = stalled["title"] fixture = LoginFixture(inventory(stalled)) try: - code, output = run_pty(fixture, b"\n", columns=80) - self.assertEqual(2, code) - self.assertIn("quiet 7h 49m", output) - self.assertNotIn("20 subagents", output) + code, output = run_pty(fixture, b"q\n", columns=80) + self.assertEqual(0, code) + self.assertIn("| needs you", output) + self.assertIn("20 subagents", output) + self.assertIn("7h 49m", output) finally: fixture.close() def test_refused_action_refreshes_instead_of_exiting_picker(self) -> None: fixture = LoginFixture(inventory(row("ready1", number=1))) try: - code, output = run_pty(fixture, b"1\n\n", sp_exit=74) - self.assertEqual(2, code) + code, output = run_pty(fixture, b"1\nq\n", sp_exit=74) + self.assertEqual(0, code) self.assertIn("changed or failed a safety check", output) self.assertNotIn("terminal died", output) snapshots = [ @@ -1203,10 +2155,11 @@ def test_refused_action_refreshes_instead_of_exiting_picker(self) -> None: def test_attach_failure_is_not_diagnosed_as_a_dead_terminal(self) -> None: fixture = LoginFixture(inventory(row("ready1", number=1))) try: - code, output = run_pty(fixture, b"1\n\n", sp_exit=75) - self.assertEqual(2, code) - self.assertIn("could not connect", output) - self.assertIn("does not prove the session is dead", output) + code, output = run_pty(fixture, b"1\nq\n", sp_exit=75) + self.assertEqual(0, code) + self.assertIn("Could not open session 1. It was left alone.", output) + # The picker never diagnoses a session it could not reach. + self.assertNotIn("dead", output) entries = fixture.sp_entries() commands = [entry["args"][0] for entry in entries] self.assertIn("picker-open", commands) @@ -1221,9 +2174,9 @@ def test_unknown_open_failure_never_offers_unproven_recovery(self) -> None: unknown["setup_incomplete"] = True fixture = LoginFixture(inventory(unknown)) try: - code, output = run_pty(fixture, b"4\n\n", sp_exit=7) - self.assertEqual(2, code) - self.assertIn("failed without a verified cause", output) + code, output = run_pty(fixture, b"4\nq\n", sp_exit=7) + self.assertEqual(0, code) + self.assertIn("Could not open session 4. It was left alone.", output) self.assertNotIn("terminal died", output) commands = [entry["args"][0] for entry in fixture.sp_entries()] self.assertNotIn("picker-recover", commands) @@ -1233,9 +2186,11 @@ def test_unknown_open_failure_never_offers_unproven_recovery(self) -> None: def test_cached_row_selection_is_read_only_before_proof_or_sp(self) -> None: fixture = LoginFixture(inventory(row("ready1", number=1), stale=True)) try: - code, output = run_pty(fixture, b"1\nk 1\n\n") - self.assertEqual(2, code) - self.assertIn("Cached rows are read-only", output) + code, output = run_pty(fixture, b"1\nk 1\nq\n") + self.assertEqual(0, code) + self.assertIn( + "Showing a cached list. Actions are off until it refreshes.", output + ) self.assertIn("Nothing changed", output) self.assertEqual([], fixture.sp_entries()) self.assertNotIn("terminal died", output) @@ -1248,8 +2203,8 @@ def test_ordinary_rows_hide_ids_but_search_still_matches_them(self) -> None: hidden["display_title"] = "Picker safety work" fixture = LoginFixture(inventory(hidden)) try: - code, output = run_pty(fixture, b"\n", columns=120) - self.assertEqual(2, code) + code, output = run_pty(fixture, b"q\n", columns=120) + self.assertEqual(0, code) self.assertIn("Picker safety work", output) self.assertNotIn("private-session-id", output) finally: @@ -1257,28 +2212,48 @@ def test_ordinary_rows_hide_ids_but_search_still_matches_them(self) -> None: fixture = LoginFixture(inventory(hidden)) try: - code, output = run_pty( - fixture, b"/private-session-id\n\n", columns=120 - ) - self.assertEqual(2, code) + code, output = run_pty(fixture, b"/private-session-id\nq\n", columns=120) + self.assertEqual(0, code) self.assertIn("1 match of 1 session", output) self.assertIn("Picker safety work", output) finally: fixture.close() + def test_needs_you_opens_the_review_screen_and_its_back_key_goes_back(self) -> None: + """The screen advertises `Back: Enter or q`, and that must hold. + + The Codex prompt-quarantine rows this screen once carried retired with + the intake surface; what survives is the review screen itself and the + promise that `a` reaches it and `q` returns to the list. + """ + waiting = row("waits", number=6, needs_you=True) + waiting["title"] = "Deploy review" + waiting["agent_status"] = "needs your reply" + fixture = LoginFixture(inventory(waiting)) + try: + code, output = run_pty(fixture, b"a\nq\nq\n", columns=140) + self.assertEqual(0, code) + self.assertIn("Deploy review", output) + self.assertIn("needs you:", output) + self.assertNotIn("Unknown choice", output) + # q returned to the list rather than dropping to a bare shell, so + # the q after it is what ends the picker. + self.assertIn("# · kill k #", output) + finally: + fixture.close() + def test_available_unknown_provider_delegates_exact_proof_open(self) -> None: unknown = row("unknown1", number=4, provider="unknown") unknown["identity"]["uuid"] = None unknown["display_provider"] = "codex" - unknown["display_title"] = "Codex setup incomplete" + unknown["display_title"] = "Codex pending" unknown["setup_incomplete"] = True fixture = LoginFixture(inventory(unknown)) try: - code, output = run_pty(fixture, b"4\n\n") - self.assertEqual(2, code) - self.assertIn(" Codex", output) - self.assertIn("Codex setup incomplete", output) - self.assertIn("| ! setup incomplete", output) + code, output = run_pty(fixture, b"4\nq\n") + self.assertEqual(0, code) + self.assertIn("Codex pending", output) + self.assertIn("| CDX | pe… | pe… | pending", output) entries = fixture.sp_entries() self.assertEqual(1, len(entries)) self.assertEqual("picker-open", entries[0]["args"][0]) @@ -1309,23 +2284,22 @@ def test_outside_provider_root_uses_separate_private_read_only_view(self) -> Non ] fixture = LoginFixture(document) try: - code, output = run_pty(fixture, b"o\n\n1\n\n") - self.assertEqual(2, code) - self.assertIn( - "0 sessions · 0 ready here · 0 open elsewhere", output - ) - self.assertIn("More: m (1)", output) - self.assertIn("Other provider sessions", output) + code, output = run_pty(fixture, b"o\n\n1\nq\n") + self.assertEqual(0, code) + self.assertIn("Sessions: none.", output) + self.assertNotIn("0 sessions", output) + self.assertIn("more m", output) + self.assertIn("Outside the kit", output) self.assertIn( - "Detected live provider roots outside the session manager; they are not attachable here.", + "Live sessions outside the kit. Not openable here.", output, ) self.assertIn("Outside account project-alpha", output) - self.assertIn("project: project-alpha | not attachable here", output) + self.assertIn("project: project-alpha | not openable here", output) self.assertNotIn("/home/private-account", output) self.assertNotIn("private-account", output) self.assertNotIn("Page 1/", output) - self.assertIn("Choose a number shown here", output) + self.assertIn("on this screen. Numbers shown here work.", output) self.assertEqual([], fixture.sp_entries()) finally: fixture.close() @@ -1349,12 +2323,11 @@ def test_many_outside_roots_do_not_change_main_count_or_paging(self) -> None: ] fixture = LoginFixture(document) try: - code, output = run_pty(fixture, b"\n", lines=12) - self.assertEqual(2, code) - self.assertIn( - "0 sessions · 0 ready here · 0 open elsewhere", output - ) - self.assertIn("More: m (30)", output) + code, output = run_pty(fixture, b"q\n", lines=12) + self.assertEqual(0, code) + self.assertIn("Sessions: none.", output) + self.assertNotIn("0 sessions", output) + self.assertIn("more m", output) self.assertNotIn("Page 1/", output) self.assertNotIn("Outside provider 1", output) self.assertEqual([], fixture.sp_entries()) @@ -1364,152 +2337,1367 @@ def test_many_outside_roots_do_not_change_main_count_or_paging(self) -> None: def test_guided_new_uses_first_project_default_and_child_sp(self) -> None: fixture = LoginFixture(inventory()) try: - code, output = run_pty(fixture, b"n\n2\n\n\n") - self.assertEqual(2, code) + code, output = run_pty(fixture, b"n\n2\n1\n\nq\n") + self.assertEqual(0, code) self.assertIn("New session", output) - self.assertIn("Use main:", output) - self.assertIn("use name <number>", output) + self.assertIn("use main:", output) + self.assertIn("Name it later with name number.", output) + self.assertNotIn("Type yes to start", output) + self.assertNotIn("New session cancelled", output) self.assertEqual( - [["new", "codex", "main"]], + [["new", "codex", "main", "--account", "wren"]], [entry["args"] for entry in fixture.sp_entries()], ) finally: fixture.close() - def test_recovery_open_and_empty_input_do_not_ack_or_restore(self) -> None: - pending = { - "schema_version": 1, - "entries": [ - { - "source_generation_key": "generation", - "old_shpool_id": "old1", - "display_old_shpool_id": "old1", - "provider": "claude", - "uuid": "11111111-1111-4111-8111-111111111111", - "cwd": "/srv/project", - "title": "Recover me", - } - ], - } - fixture = LoginFixture(inventory(), pending=pending) + def test_guided_new_takes_claude_code_on_enter(self) -> None: + """Enter on the provider screen sent the most common act backwards. + + It answered `back`, so the one key a menu trains you to press left + the New session screen without starting anything. + """ + fixture = LoginFixture(inventory()) try: - code, output = run_pty(fixture, b"u\n\n\n") - self.assertEqual(2, code) - self.assertIn("More: m (1)", output) - self.assertIn("[Claude] Recover me [old1]", output) - self.assertNotIn("Traceback", output) - self.assertEqual([], fixture.sp_entries()) - self.assertFalse( - any( - entry and entry[0] == "--recovery-pending-ack" - for entry in fixture.status_entries() - ) + code, output = run_pty(fixture, b"n\n\n1\n\nq\n") + self.assertEqual(0, code) + self.assertIn("↵ Claude Code · b back", output) + # Past the provider screen, not back out of it: the account step + # is what a Claude session asks next. + self.assertIn("account ❯", output) + self.assertEqual( + [["new", "claude", "main", "--account", "wren"]], + [entry["args"] for entry in fixture.sp_entries()], ) - self.assertEqual([], fixture.picker_temps()) finally: fixture.close() - def test_recovery_selection_opens_exact_active_managed_uuid_without_duplicate(self) -> None: - active = row("active-one", number=4107, provider="codex") - pending = { - "schema_version": 1, - "entries": [ - { - "source_generation_key": "generation", - "old_shpool_id": "old-active", - "display_old_shpool_id": "old-active", - "provider": "codex", - "uuid": active["identity"]["uuid"], - "cwd": "/srv/project", - "title": "Already running", - } - ], - } - fixture = LoginFixture(inventory(active), pending=pending) + def test_guided_new_still_goes_back_on_b(self) -> None: + fixture = LoginFixture(inventory()) try: - code, output = run_pty(fixture, b"u\na\n\n") - self.assertEqual(2, code) - self.assertIn( - "already active as session 4107; opening the existing session instead", - output, + code, output = run_pty(fixture, b"n\nb\nq\n") + self.assertEqual(0, code) + self.assertIn("New session", output) + self.assertEqual([], fixture.sp_entries()) + finally: + fixture.close() + + def test_guided_new_lists_a_directory_once_across_providers(self) -> None: + fixture = LoginFixture(inventory()) + try: + # The provider is chosen before the project list, so a second row + # for the same directory would render as a duplicate line. + fixture.projects.write_text( + f"main\tclaude\t{fixture.primary_project}\n" + f"main-codex\tcodex\t{fixture.primary_project}\n" + f"other\tcodex\t{fixture.other}\n", + encoding="utf-8", ) - self.assertIn( - "Use fork 4107 from the picker to start a separate writable fork.", - output, + code, output = run_pty(fixture, b"n\n2\n1\n\nq\n") + self.assertEqual(0, code) + self.assertNotIn("main-codex:", output) + self.assertIn(f" 1 main: {fixture.primary_project}", output) + self.assertIn(f" 2 other: {fixture.other}", output) + self.assertEqual( + [["new", "codex", "main", "--account", "wren"]], + [entry["args"] for entry in fixture.sp_entries()], ) - self.assertIn( - "Cleared the recovery record for exact active session 4107.", - output, + finally: + fixture.close() + + def test_projects_screen_adds_a_directory_with_a_suggested_name(self) -> None: + fixture = LoginFixture(inventory(row("alpha", number=1))) + try: + target = fixture.base / "newsite" + target.mkdir() + # More, Projects, Add, the directory, accept the suggested short + # name, back out, quit. There is no provider to accept: which one + # opens a directory is answered when a session starts. + code, output = run_pty( + fixture, + f"m\np\na\n{target}\n\n\n\nq\n".encode(), + env_updates={"SESSION_KIT_PICKER_REFRESH_SECONDS": "0"}, ) + self.assertEqual(0, code) + self.assertIn("Projects", output) + self.assertIn(f"main: {fixture.primary_project}", output) + self.assertIn("Short name [newsite]", output) + self.assertIn(f"Added newsite: {target}", output) self.assertIn( - [ - "--recovery-pending-ack", - "generation", - "old-active", - active["identity"]["uuid"], - ], - fixture.status_entries(), - ) - entries = fixture.sp_entries() - self.assertEqual(["picker-open"], [entry["args"][0] for entry in entries]) - self.assertFalse( - any(entry["args"][0] == "restore-exact" for entry in entries) + f"newsite\tany\t{target}", + fixture.projects.read_text(encoding="utf-8"), ) - self.assertEqual("active-one", entries[0]["proof"]["shpool_id"]) finally: fixture.close() - def test_active_managed_recovery_reports_and_retains_failed_ack(self) -> None: - active = row("active-one", number=4107, provider="codex") - pending = { - "schema_version": 1, - "entries": [ - { - "source_generation_key": "generation", - "old_shpool_id": "old-active", - "display_old_shpool_id": "old-active", - "provider": "codex", - "uuid": active["identity"]["uuid"], - "cwd": "/srv/project", - "title": "Already running", - } - ], - } - fixture = LoginFixture(inventory(active), pending=pending, ack_exit=1) + # The undo-a-drop round trip lives in tests/test_picker_projects.py, on + # the confirmed-hide flow that superseded the immediate drop. + + def test_projects_screen_drops_a_directory_for_good(self) -> None: + fixture = LoginFixture(inventory(row("alpha", number=1))) try: - code, output = run_pty(fixture, b"u\na\n\n") - self.assertEqual(2, code) - self.assertIn( - "Exact active session 4107 was found, but its recovery record could not be cleared; the record was retained.", - output, - ) - self.assertIn( - [ - "--recovery-pending-ack", - "generation", - "old-active", - active["identity"]["uuid"], - ], - fixture.status_entries(), + code, output = run_pty( + fixture, + b"m\np\nd 2\ny\n\n\nq\n", + env_updates={"SESSION_KIT_PICKER_REFRESH_SECONDS": "0"}, ) - self.assertEqual( - ["picker-open"], - [entry["args"][0] for entry in fixture.sp_entries()], + self.assertEqual(0, code) + self.assertIn("stays out of the project list", output) + contents = fixture.projects.read_text(encoding="utf-8") + self.assertIn(f"other\tignore\t{fixture.other}", contents) + self.assertIn(f"main\tclaude\t{fixture.primary_project}", contents) + finally: + fixture.close() + + def test_every_keystroke_draws_one_frame_instead_of_stacking_menus(self) -> None: + """The screen the picker leaves behind is the same every time. + + The menu used to be appended below whatever was already there, while + the background repaint replaced the screen in place: the same key left + a different screen depending on when it was pressed.""" + fixture = LoginFixture(inventory(row("one", number=1))) + try: + code, output = run_pty(fixture, b"c\nc\nq\n") + self.assertEqual(0, code) + # Startup, both compact toggles, and the q that leaves. + self.assertEqual(4, output.count("\033[2J")) + frame = output.rsplit("\033[2J", 2)[1] + self.assertIn("Compact rows off.", frame) + self.assertEqual(1, frame.count("# · kill k # · new n · more m")) + finally: + fixture.close() + + def test_idle_menu_repaints_itself_when_the_estate_changes(self) -> None: + first = row("alpha", number=1) + second = row("bravo", number=2) + fixture = LoginFixture( + inventory(first), refreshed_document=inventory(first, second) + ) + try: + code, output = run_pty( + fixture, + env_updates={ + "SESSION_KIT_PICKER_REFRESH_SECONDS": "2", + # A repaint replaces the menu with terminal control + # sequences, so it only runs on a terminal that accepts them. + "SESSION_KIT_NO_COLOR": None, + }, + deferred=("Codex bravo", b"q\n"), ) + # No key was pressed until the second session appeared by itself. + self.assertIn("Codex alpha", output) + self.assertIn("Codex bravo", output) + self.assertEqual(0, code) + # The first draw clears once. The automatic replacement is a + # synchronized in-place frame whose content arrives WITH the + # cursor-home -- the screen is never erased ahead of the new + # frame, so a slow render cannot flash a blank screen. + before_repaint = output.index("Codex bravo") + self.assertEqual(1, output[:before_repaint].count("\033[2J")) + self.assertIn("\033[?2026h\033[H ", output[:before_repaint]) + self.assertNotIn("\033[?2026h\033[H\033[J", output) + self.assertIn("\033[J\033[?2026l", output[before_repaint:]) + self.assertEqual(1, output.count("Codex bravo")) finally: fixture.close() - def test_recovery_selection_refuses_duplicate_active_outside_manager(self) -> None: - exact_uuid = "22222222-2222-4222-8222-222222222222" - document = inventory() - document["outside_agents"] = [ + def test_hidden_other_provider_changes_do_not_repaint_home(self) -> None: + first = inventory(row("alpha", number=1)) + refreshed = inventory(row("alpha", number=1)) + refreshed["outside_agents"] = [ { - "provider": "claude", + "provider": "codex", "identity": { - "uuid": exact_uuid, - "pid": 9001, - "process_start_ticks": 90010, - "confidence": "exact", + "uuid": "22222222-2222-4222-8222-222222222222", + "pid": 2222, + "process_start_ticks": 22222, + }, + "title": "Invisible outside process", + "cwd": "/tmp/outside", + } + ] + fixture = LoginFixture(first, refreshed_document=refreshed) + try: + code, output = run_pty( + fixture, + env_updates={ + "SESSION_KIT_PICKER_REFRESH_SECONDS": "2", + "SESSION_KIT_NO_COLOR": None, + }, + deferred=("Codex alpha", b"q\n"), + deferred_delay_seconds=3, + ) + self.assertEqual(0, code) + # Nothing repainted: no synchronized in-place frame was drawn at + # all, and the screen cleared exactly twice -- once at startup and + # once for the keystroke that ended the picker. + self.assertNotIn("\033[?2026h", output) + self.assertEqual(2, output.count("\033[2J")) + self.assertNotIn("Invisible outside process", output) + finally: + fixture.close() + + def test_help_and_more_remain_modal_across_refresh_intervals(self) -> None: + first = inventory(row("alpha", number=1)) + refreshed = inventory(row("alpha", number=1), row("bravo", number=2)) + for command, marker in ((b"?\n", "Picker help"), (b"m\n", " More")): + with self.subTest(command=command): + fixture = LoginFixture(first, refreshed_document=refreshed) + try: + code, output = run_pty( + fixture, + command, + env_updates={ + "SESSION_KIT_PICKER_REFRESH_SECONDS": "2", + "SESSION_KIT_NO_COLOR": None, + }, + # Back out of the modal, then leave from the list -- + # two beats, not one burst. Written together as + # "\nq\n", both keys sat in the read that closed the + # modal: the picker took the newline as "back" and + # the "q\n" behind it went nowhere, so the list was + # left sitting at its prompt until the fixture killed + # it (2026-08-18, ubuntu-24.04/3.10). "Codex bravo" + # is the marker because it is the one line that + # cannot already be on screen -- the modal is proven + # below never to have shown it, so seeing it means + # the list is back and has taken the refresh. + deferred=(marker, b"\n"), + deferred_delay_seconds=3, + followup=("Codex bravo", b"q\n", 30), + ) + self.assertEqual(0, code) + modal_start = output.index(marker) + modal_end = output.index("Codex alpha", modal_start) + modal = output[modal_start:modal_end] + self.assertNotIn("\033[2J", modal) + self.assertNotIn("Codex bravo", modal) + finally: + fixture.close() + + def test_every_picker_submenu_uses_interrupt_resistant_modal_reads(self) -> None: + modules = ( + REPO / "lib/sh/shpool_login_actions.sh", + REPO / "lib/sh/shpool_login_render.sh", + REPO / "lib/sh/shpool_login_projects.sh", + REPO / "lib/sh/shpool_login_recovery.sh", + ) + offenders = [] + for path in modules: + for number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), 1 + ): + if "picker_read " in line: + offenders.append(f"{path.name}:{number}") + self.assertEqual([], offenders) + + def test_idle_picker_reexecs_the_release_selected_by_current(self) -> None: + rows = tuple(row(f"session-{number}", number=number) for number in range(1, 51)) + fixture = LoginFixture(inventory(*rows)) + release_id = "a1b2c3d4e5f6789012345678901234567890abcd" + install_root = fixture.base / "session-kit" + new_release = install_root / "releases" / release_id + launcher = new_release / "bin" / "shpool_login_launcher" + record = fixture.base / "upgraded-picker" + launcher.parent.mkdir(parents=True) + write_executable( + launcher, + "#!/usr/bin/env bash\n" + "printf '%s|%s|%s\\n' \"$SESSION_KIT_PICKER_RESUME_QUERY\" " + '"$SESSION_KIT_PICKER_RESUME_PAGE" ' + '"$SESSION_KIT_PICKER_COMPACT" > "$UPGRADE_RECORD"\n' + "printf 'NEW RELEASE PICKER RAN\\n'\n" + # Outlive the handoff probation, or a truthful exit reads as a + # corpse and the wrapper reloads the old picker. + "sleep 1.5\n", + ) + current = install_root / "current" + current.parent.mkdir(parents=True, exist_ok=True) + current.symlink_to(REPO) + + def activate() -> None: + replacement = install_root / ".current-next" + replacement.symlink_to(new_release) + os.replace(replacement, current) + + try: + code, output = run_pty( + fixture, + b"/session-\nnext\n", + env_updates={ + "SESSION_KIT_ROOT": os.fspath(install_root), + "SESSION_KIT_PICKER_COMPACT": "1", + "UPGRADE_RECORD": os.fspath(record), + "SESSION_KIT_PICKER_HANDOFF_PROBATION_SECONDS": "1", + }, + deferred=("Page 2/", b""), + deferred_action=activate, + ) + self.assertEqual(0, code) + self.assertIn( + "Reloading the picker into release a1b2c3d4e5f6.", + output, + ) + self.assertIn("NEW RELEASE PICKER RAN", output) + self.assertEqual("session-|2|1", record.read_text().strip()) + events = (fixture.state / "action-events.jsonl").read_text() + self.assertIn("release_upgrade", events) + finally: + fixture.close() + + def test_release_upgrade_waits_until_a_modal_prompt_closes(self) -> None: + fixture = LoginFixture(inventory(row("alpha", number=1))) + release_id = "b1c2d3e4f5a6789012345678901234567890abcd" + install_root = fixture.base / "session-kit" + new_release = install_root / "releases" / release_id + launcher = new_release / "bin" / "shpool_login_launcher" + record = fixture.base / "upgraded-picker" + launcher.parent.mkdir(parents=True) + write_executable( + launcher, + "#!/usr/bin/env bash\n" + ': > "$UPGRADE_RECORD"\n' + "printf 'NEW RELEASE PICKER RAN\\n'\n" + "sleep 1.5\n", + ) + current = install_root / "current" + current.parent.mkdir(parents=True, exist_ok=True) + current.symlink_to(REPO) + + def activate() -> None: + replacement = install_root / ".current-next" + replacement.symlink_to(new_release) + os.replace(replacement, current) + + try: + code, output = run_pty( + fixture, + b"?\n", + env_updates={ + "SESSION_KIT_ROOT": os.fspath(install_root), + "UPGRADE_RECORD": os.fspath(record), + "SESSION_KIT_PICKER_HANDOFF_PROBATION_SECONDS": "1", + }, + deferred=("Picker help", b"\n"), + deferred_action=activate, + deferred_delay_seconds=2, + deferred_check=lambda: self.assertFalse(record.exists()), + ) + self.assertEqual(0, code) + self.assertTrue(record.exists()) + self.assertLess( + output.index("Picker help"), + output.index("Reloading the picker into release b1c2d3e4f5a6"), + ) + finally: + fixture.close() + + def test_broken_current_release_warns_once_and_keeps_old_picker_alive(self) -> None: + fixture = LoginFixture(inventory(row("alpha", number=1))) + release_id = "c1d2e3f4a5b6789012345678901234567890abcd" + install_root = fixture.base / "session-kit" + broken_release = install_root / "releases" / release_id + broken_release.mkdir(parents=True) + current = install_root / "current" + current.parent.mkdir(parents=True, exist_ok=True) + current.symlink_to(REPO) + + def activate() -> None: + replacement = install_root / ".current-next" + replacement.symlink_to(broken_release) + os.replace(replacement, current) + + warning = "Release c1d2e3f4a5b6 is unavailable. Continuing with this picker." + try: + code, output = run_pty( + fixture, + env_updates={ + "SESSION_KIT_ROOT": os.fspath(install_root), + "SESSION_KIT_PICKER_REFRESH_SECONDS": "0", + }, + # Type only once the warning is on screen. Typing on the + # deferred beat put the keystroke into the same read that the + # upgrade check was interrupting to print that warning: the + # `q` was swallowed and the trailing newline arrived alone, + # so the picker opened the top session instead of leaving + # (macOS arm64, where the beat and the check coincide). A + # person types after they read the line; so does this test. + deferred=("Codex alpha", b""), + deferred_action=activate, + deferred_delay_seconds=0, + followup=(warning, b"q\n", 30), + ) + self.assertEqual(0, code) + self.assertEqual(1, output.count(warning)) + events = (fixture.state / "action-events.jsonl").read_text() + self.assertIn("quit", events) + self.assertNotIn("release_upgrade", events) + finally: + fixture.close() + + def test_unstartable_launcher_recovers_and_never_retries_the_same_target( + self, + ) -> None: + """No static probe proves a program will serve; the handoff does. + + A launcher whose shebang names a missing interpreter, or whose env + command is absent, can pass every file check and die a beat after + exec. Review lanes rv-rn-2 and rv-rn2-* proved the two failure + shapes: a success line followed by a dead window, and a relaunch + storm retrying the same corpse every idle beat. The supervised + handoff runs the new picker as a foreground child: a quick abnormal + exit is announced as the failure it is, the window gets this + release's picker back, and the failed target is remembered across + the exec so it is warned about once, never retried.""" + fixture = LoginFixture(inventory(row("alpha", number=1))) + release_id = "d1e2f3a4b5c6789012345678901234567890abcd" + install_root = fixture.base / "session-kit" + broken_release = install_root / "releases" / release_id + (broken_release / "bin").mkdir(parents=True) + launcher = broken_release / "bin" / "shpool_login_launcher" + launcher.write_text( + "#!/definitely/missing/interpreter\nexit 0\n", encoding="utf-8" + ) + launcher.chmod(0o755) + current = install_root / "current" + current.parent.mkdir(parents=True, exist_ok=True) + current.symlink_to(REPO) + + def activate() -> None: + replacement = install_root / ".current-next" + replacement.symlink_to(broken_release) + os.replace(replacement, current) + + try: + code, output = run_pty( + fixture, + env_updates={ + "SESSION_KIT_ROOT": os.fspath(install_root), + "SESSION_KIT_PICKER_REFRESH_SECONDS": "0", + }, + deferred=("Codex alpha", b""), + deferred_action=activate, + deferred_delay_seconds=2, + # The recovered picker needs idle beats to prove it does NOT + # retry, then a quit typed into it proves it is serving. + followup=("Continuing with this picker", b"q\n", 6), + ) + self.assertEqual(0, code) + self.assertIn("Reloading the picker into release d1e2f3a4b5c6.", output) + self.assertIn("exited immediately (status", output) + self.assertEqual( + 1, + output.count("Reloading the picker into release d1e2f3a4b5c6."), + "the failed target must never be retried", + ) + self.assertEqual( + 1, + output.count( + "Release d1e2f3a4b5c6 could not serve this window." + " Continuing with this picker." + ), + ) + events = (fixture.state / "action-events.jsonl").read_text() + self.assertIn("release_upgrade_failed", events) + self.assertIn("quit", events) + finally: + fixture.close() + + def test_zero_exit_corpse_inside_probation_recovers_the_picker(self) -> None: + """A corpse wearing a success code is still a corpse. + + A launcher that exits 0 instantly used to read as the person leaving + the new picker, so the wrapper exited 0 and the window lost its list + with no refusal (review lane rv-rn3-2). A person cannot open and + leave a picker faster than it draws: any exit inside probation is a + failed handoff, whatever the status says.""" + fixture = LoginFixture(inventory(row("alpha", number=1))) + release_id = "e2f3a4b5c6d7789012345678901234567890abcd" + install_root = fixture.base / "session-kit" + corpse_release = install_root / "releases" / release_id + (corpse_release / "bin").mkdir(parents=True) + write_executable( + corpse_release / "bin" / "shpool_login_launcher", + "#!/usr/bin/env bash\nexit 0\n", + ) + current = install_root / "current" + current.parent.mkdir(parents=True, exist_ok=True) + current.symlink_to(REPO) + + def activate() -> None: + replacement = install_root / ".current-next" + replacement.symlink_to(corpse_release) + os.replace(replacement, current) + + try: + code, output = run_pty( + fixture, + env_updates={ + "SESSION_KIT_ROOT": os.fspath(install_root), + "SESSION_KIT_PICKER_REFRESH_SECONDS": "0", + }, + deferred=("Codex alpha", b""), + deferred_action=activate, + deferred_delay_seconds=2, + followup=("Continuing with this picker", b"q\n", 6), + ) + self.assertEqual(0, code) + self.assertIn( + "The release e2f3a4b5c6d7 picker ended before it could serve.", + output, + ) + self.assertEqual( + 1, + output.count("Reloading the picker into release e2f3a4b5c6d7."), + "the failed target must never be retried", + ) + events = (fixture.state / "action-events.jsonl").read_text() + self.assertIn("release_upgrade_failed", events) + self.assertIn("quit", events) + finally: + fixture.close() + + def test_octal_looking_probation_neither_aborts_nor_serves_a_corpse(self) -> None: + """ "08" is decimal eight here, not broken octal. + + Bash arithmetic reads a leading zero as octal and aborts on "08", + review round five drove the picker to die after the reload line with + no recovery. The value is forced to base ten; the corpse still + recovers under the eight-second probation it now means.""" + fixture = LoginFixture(inventory(row("alpha", number=1))) + release_id = "b5c6d7e8f9a0789012345678901234567890abcd" + install_root = fixture.base / "session-kit" + corpse_release = install_root / "releases" / release_id + (corpse_release / "bin").mkdir(parents=True) + write_executable( + corpse_release / "bin" / "shpool_login_launcher", + "#!/usr/bin/env bash\nexit 0\n", + ) + current = install_root / "current" + current.parent.mkdir(parents=True, exist_ok=True) + current.symlink_to(REPO) + + def activate() -> None: + replacement = install_root / ".current-next" + replacement.symlink_to(corpse_release) + os.replace(replacement, current) + + try: + code, output = run_pty( + fixture, + env_updates={ + "SESSION_KIT_ROOT": os.fspath(install_root), + "SESSION_KIT_PICKER_REFRESH_SECONDS": "0", + "SESSION_KIT_PICKER_HANDOFF_PROBATION_SECONDS": "08", + }, + deferred=("Codex alpha", b""), + deferred_action=activate, + deferred_delay_seconds=2, + followup=("Continuing with this picker", b"q\n", 10), + ) + self.assertEqual(0, code) + self.assertIn( + "The release b5c6d7e8f9a0 picker ended before it could serve.", + output, + ) + events = (fixture.state / "action-events.jsonl").read_text() + self.assertIn("release_upgrade_failed", events) + self.assertIn("quit", events) + finally: + fixture.close() + + def test_zero_probation_is_invalid_and_still_catches_the_corpse(self) -> None: + """A probation of zero is no probation at all, so it is invalid. + + "0" passed the digit check, zeroed the window, and an instant + status-0 corpse was announced as served, the picker ended with no + refusal and no recovery (review lane rv-rn6-1). The window exists to + catch corpses; a value that cannot catch one falls back to the + default five seconds. "000000" reaches the same rejected-zero branch + after base-ten forcing.""" + fixture = LoginFixture(inventory(row("alpha", number=1))) + release_id = "c6d7e8f9a0b1789012345678901234567890abcd" + install_root = fixture.base / "session-kit" + corpse_release = install_root / "releases" / release_id + (corpse_release / "bin").mkdir(parents=True) + write_executable( + corpse_release / "bin" / "shpool_login_launcher", + "#!/usr/bin/env bash\nexit 0\n", + ) + current = install_root / "current" + current.parent.mkdir(parents=True, exist_ok=True) + current.symlink_to(REPO) + + def activate() -> None: + replacement = install_root / ".current-next" + replacement.symlink_to(corpse_release) + os.replace(replacement, current) + + try: + code, output = run_pty( + fixture, + env_updates={ + "SESSION_KIT_ROOT": os.fspath(install_root), + "SESSION_KIT_PICKER_REFRESH_SECONDS": "0", + "SESSION_KIT_PICKER_HANDOFF_PROBATION_SECONDS": "0", + }, + deferred=("Codex alpha", b""), + deferred_action=activate, + deferred_delay_seconds=2, + followup=("Continuing with this picker", b"q\n", 10), + ) + self.assertEqual(0, code) + self.assertIn( + "The release c6d7e8f9a0b1 picker ended before it could serve.", + output, + ) + self.assertEqual( + 1, + output.count("Reloading the picker into release c6d7e8f9a0b1."), + "the failed target must never be retried", + ) + events = (fixture.state / "action-events.jsonl").read_text() + self.assertIn("release_upgrade_failed", events) + self.assertIn("quit", events) + finally: + fixture.close() + + def test_missing_bash5_clock_still_catches_the_corpse(self) -> None: + """A shell without EPOCHREALTIME must not die reading it. + + EPOCHREALTIME is a Bash 5.0 variable and the kit promises Bash 4: + under nounset the bare expansion aborted the picker right after the + reload line, no refusal, no recovery, the list gone (review lane + rv-rn6-2, reproduced by unsetting the variable through BASH_ENV on + the real production path). The guarded expansion now falls back to + the SECONDS builtin, and an instant status-0 corpse still recovers.""" + fixture = LoginFixture(inventory(row("alpha", number=1))) + release_id = "d7e8f9a0b1c2789012345678901234567890abcd" + install_root = fixture.base / "session-kit" + corpse_release = install_root / "releases" / release_id + (corpse_release / "bin").mkdir(parents=True) + write_executable( + corpse_release / "bin" / "shpool_login_launcher", + "#!/usr/bin/env bash\nexit 0\n", + ) + bash4_env = fixture.base / "bash4-env.sh" + bash4_env.write_text("unset EPOCHREALTIME\n", encoding="utf-8") + current = install_root / "current" + current.parent.mkdir(parents=True, exist_ok=True) + current.symlink_to(REPO) + + def activate() -> None: + replacement = install_root / ".current-next" + replacement.symlink_to(corpse_release) + os.replace(replacement, current) + + try: + code, output = run_pty( + fixture, + env_updates={ + "SESSION_KIT_ROOT": os.fspath(install_root), + "SESSION_KIT_PICKER_REFRESH_SECONDS": "0", + "BASH_ENV": os.fspath(bash4_env), + }, + deferred=("Codex alpha", b""), + deferred_action=activate, + deferred_delay_seconds=2, + followup=("Continuing with this picker", b"q\n", 10), + ) + self.assertEqual(0, code) + self.assertNotIn("unbound variable", output) + self.assertIn( + "The release d7e8f9a0b1c2 picker ended before it could serve.", + output, + ) + self.assertEqual( + 1, + output.count("Reloading the picker into release d7e8f9a0b1c2."), + "the failed target must never be retried", + ) + events = (fixture.state / "action-events.jsonl").read_text() + self.assertIn("release_upgrade_failed", events) + self.assertIn("quit", events) + finally: + fixture.close() + + def test_missing_bash5_clock_still_serves_a_real_picker(self) -> None: + """The seconds fallback must serve, not only recover. + + If a Bash-4 shell treated every handoff as zero elapsed, a person + who used the new picker for an hour and quit would get the old + picker relaunched at them and the target falsely marked failed. The + SECONDS fallback surrenders one whole second before comparing, the + rv-rn4-2 rounding credit cannot return, so the launcher here must + outlive probation by more than a full second to prove serving.""" + fixture = LoginFixture(inventory(row("alpha", number=1))) + release_id = "e8f9a0b1c2d3789012345678901234567890abcd" + install_root = fixture.base / "session-kit" + new_release = install_root / "releases" / release_id + (new_release / "bin").mkdir(parents=True) + write_executable( + new_release / "bin" / "shpool_login_launcher", + "#!/usr/bin/env bash\nprintf 'NEW RELEASE PICKER RAN\\n'\nsleep 3\n", + ) + bash4_env = fixture.base / "bash4-env.sh" + bash4_env.write_text("unset EPOCHREALTIME\n", encoding="utf-8") + current = install_root / "current" + current.parent.mkdir(parents=True, exist_ok=True) + current.symlink_to(REPO) + + def activate() -> None: + replacement = install_root / ".current-next" + replacement.symlink_to(new_release) + os.replace(replacement, current) + + try: + code, output = run_pty( + fixture, + env_updates={ + "SESSION_KIT_ROOT": os.fspath(install_root), + "SESSION_KIT_PICKER_REFRESH_SECONDS": "0", + "SESSION_KIT_PICKER_HANDOFF_PROBATION_SECONDS": "1", + "BASH_ENV": os.fspath(bash4_env), + }, + deferred=("Codex alpha", b""), + deferred_action=activate, + deferred_delay_seconds=2, + ) + self.assertEqual(0, code) + self.assertNotIn("unbound variable", output) + self.assertIn("NEW RELEASE PICKER RAN", output) + self.assertNotIn("ended before it could serve", output) + self.assertNotIn("Continuing with this picker", output) + self.assertEqual( + 1, + output.count("Reloading the picker into release e8f9a0b1c2d3."), + ) + finally: + fixture.close() + + def test_tampered_seconds_variable_cannot_credit_a_corpse(self) -> None: + """The fallback clock is the system's, never mutable shell state. + + Review lanes rv-rn7-1/2 turned SECONDS into a nameref to LINENO + through BASH_ENV: the source-line difference across the handoff read + as fifteen elapsed seconds and an instant status-0 corpse was + announced as served, ending the list with a lone release_upgrade + event. The fallback now reads builtin printf's %(%s)T strftime + clock, so no shell variable an adversary can retarget participates + in the serving decision, and this corpse recovers.""" + fixture = LoginFixture(inventory(row("alpha", number=1))) + release_id = "f9a0b1c2d3e4789012345678901234567890abcd" + install_root = fixture.base / "session-kit" + corpse_release = install_root / "releases" / release_id + (corpse_release / "bin").mkdir(parents=True) + write_executable( + corpse_release / "bin" / "shpool_login_launcher", + "#!/usr/bin/env bash\nexit 0\n", + ) + tampered_env = fixture.base / "tampered-env.sh" + tampered_env.write_text( + "unset EPOCHREALTIME SECONDS\ndeclare -n SECONDS=LINENO\n", + encoding="utf-8", + ) + current = install_root / "current" + current.parent.mkdir(parents=True, exist_ok=True) + current.symlink_to(REPO) + + def activate() -> None: + replacement = install_root / ".current-next" + replacement.symlink_to(corpse_release) + os.replace(replacement, current) + + try: + code, output = run_pty( + fixture, + env_updates={ + "SESSION_KIT_ROOT": os.fspath(install_root), + "SESSION_KIT_PICKER_REFRESH_SECONDS": "0", + "BASH_ENV": os.fspath(tampered_env), + }, + deferred=("Codex alpha", b""), + deferred_action=activate, + deferred_delay_seconds=2, + followup=("Continuing with this picker", b"q\n", 10), + ) + self.assertEqual(0, code) + self.assertIn( + "The release f9a0b1c2d3e4 picker ended before it could serve.", + output, + ) + self.assertEqual( + 1, + output.count("Reloading the picker into release f9a0b1c2d3e4."), + "the failed target must never be retried", + ) + events = (fixture.state / "action-events.jsonl").read_text() + self.assertIn("release_upgrade_failed", events) + self.assertIn("quit", events) + finally: + fixture.close() + + def test_corpse_just_under_probation_is_not_rounded_into_service(self) -> None: + """Integer seconds rounded a 4.2-second corpse up to the probation. + + Review lane rv-rn4-2 demonstrated it: SECONDS granularity credited a + status-0 exit at 4.201s with the full five-second probation and the + window lost its list. Elapsed time is measured in milliseconds now; + a corpse dying at eighty percent of probation recovers, every time.""" + fixture = LoginFixture(inventory(row("alpha", number=1))) + release_id = "a4b5c6d7e8f9789012345678901234567890abcd" + install_root = fixture.base / "session-kit" + corpse_release = install_root / "releases" / release_id + (corpse_release / "bin").mkdir(parents=True) + write_executable( + corpse_release / "bin" / "shpool_login_launcher", + "#!/usr/bin/env bash\nsleep 1.6\nexit 0\n", + ) + current = install_root / "current" + current.parent.mkdir(parents=True, exist_ok=True) + current.symlink_to(REPO) + + def activate() -> None: + replacement = install_root / ".current-next" + replacement.symlink_to(corpse_release) + os.replace(replacement, current) + + try: + code, output = run_pty( + fixture, + env_updates={ + "SESSION_KIT_ROOT": os.fspath(install_root), + "SESSION_KIT_PICKER_REFRESH_SECONDS": "0", + "SESSION_KIT_PICKER_HANDOFF_PROBATION_SECONDS": "2", + }, + deferred=("Codex alpha", b""), + deferred_action=activate, + deferred_delay_seconds=2, + followup=("Continuing with this picker", b"q\n", 10), + ) + self.assertEqual(0, code) + self.assertIn( + "The release a4b5c6d7e8f9 picker ended before it could serve.", + output, + ) + events = (fixture.state / "action-events.jsonl").read_text() + self.assertIn("release_upgrade_failed", events) + self.assertIn("quit", events) + finally: + fixture.close() + + def test_signal_death_after_probation_recovers_the_picker(self) -> None: + """Death by signal is never a served picker's own ending. + + A launcher that hangs without serving survives probation; when the + person kills it, the old design propagated 128+N and the window lost + its list (review lane rv-rn3-2's non-serving hang). A real picker + handles its signals, so a signal exit takes the recovery path at + any age.""" + fixture = LoginFixture(inventory(row("alpha", number=1))) + release_id = "f3a4b5c6d7e8789012345678901234567890abcd" + install_root = fixture.base / "session-kit" + hang_release = install_root / "releases" / release_id + (hang_release / "bin").mkdir(parents=True) + write_executable( + hang_release / "bin" / "shpool_login_launcher", + "#!/usr/bin/env bash\nsleep 1.5\nkill -TERM $$\n", + ) + current = install_root / "current" + current.parent.mkdir(parents=True, exist_ok=True) + current.symlink_to(REPO) + + def activate() -> None: + replacement = install_root / ".current-next" + replacement.symlink_to(hang_release) + os.replace(replacement, current) + + try: + code, output = run_pty( + fixture, + env_updates={ + "SESSION_KIT_ROOT": os.fspath(install_root), + "SESSION_KIT_PICKER_REFRESH_SECONDS": "0", + "SESSION_KIT_PICKER_HANDOFF_PROBATION_SECONDS": "1", + }, + deferred=("Codex alpha", b""), + deferred_action=activate, + deferred_delay_seconds=2, + followup=("Continuing with this picker", b"q\n", 8), + ) + self.assertEqual(0, code) + self.assertIn("exited immediately (status 1", output) + events = (fixture.state / "action-events.jsonl").read_text() + self.assertIn("release_upgrade_failed", events) + self.assertIn("quit", events) + finally: + fixture.close() + + def test_a_self_upgrade_ends_its_collector_and_frees_its_temps(self) -> None: + """`exec` keeps the process ID, so the child would outlive its parent. + + An in-flight collector left running writes a state snapshot into a + file the exiting picker has just unlinked, and is then inherited, + unreaped, by the picker that replaced it. + """ + with tempfile.TemporaryDirectory(prefix=".upgrade-", dir=REPO) as raw: + base = Path(raw) + live_file = base / "live.json" + live_done = base / "live.done" + live_file.write_text("{}", encoding="utf-8") + live_done.write_text("", encoding="utf-8") + pid_file = base / "collector.pid" + completed = run( + [ + "bash", + "-c", + 'source "$LOGIN_LIVE_MODULE"; ' + '{ sleep 45 & echo $! > "$COLLECTOR_PID"; wait; } & ' + "LIVE_PID=$!; " + 'while [ ! -s "$COLLECTOR_PID" ]; do sleep 0.05; done; ' + "picker_live_stop; " + 'printf \'%s|%s\\n\' "${LIVE_PID:-gone}" "$(cat "$COLLECTOR_PID")"', + ], + env={ + "LOGIN_LIVE_MODULE": os.fspath( + REPO / "lib" / "sh" / "shpool_login_live.sh" + ), + "LIVE_FILE": os.fspath(live_file), + "LIVE_DONE": os.fspath(live_done), + "COLLECTOR_PID": os.fspath(pid_file), + }, + ) + reported, collector = completed.stdout.strip().split("|") + self.assertEqual("gone", reported) + self.assertFalse(live_file.exists()) + self.assertFalse(live_done.exists()) + # The timed status run under the collector is ended too. + self.assertFalse(Path(f"/proc/{collector}").exists()) + + def test_the_view_a_person_set_up_survives_a_self_upgrade(self) -> None: + completed = run( + [ + "bash", + "-c", + 'source "$LOGIN_LIVE_MODULE"; ' + "build_view() { return 0; }; " + "QUERY=''; PAGE=1; picker_resume_view; " + 'printf \'%s|%s|%s|%s\\n\' "$QUERY" "$PAGE" ' + '"${SESSION_KIT_PICKER_RESUME_QUERY-unset}" ' + '"${SESSION_KIT_PICKER_RESUME_PAGE-unset}"', + ], + env={ + "LOGIN_LIVE_MODULE": os.fspath( + REPO / "lib" / "sh" / "shpool_login_live.sh" + ), + "SESSION_KIT_PICKER_RESUME_QUERY": "deploy", + "SESSION_KIT_PICKER_RESUME_PAGE": "3", + }, + ) + self.assertEqual( + "deploy|3|unset|unset", + completed.stdout.strip(), + ) + + def test_a_private_picker_key_does_not_change_the_visible_fingerprint(self) -> None: + """The fingerprint covers what is drawn, and only that. + + Terminal 1 was once reserved for the retired Fleet Supervisor and the + fingerprint re-ordered rows to match the pinned screen. Nothing pins a + row now, so the invariant that remains is the narrower one: a private + `_picker_*` annotation is not visible content and must not repaint. + """ + first = row("first", number=1, provider="claude") + second = row("second", number=2, provider="codex") + before = inventory(first, second) + annotated_first = dict(first) + annotated_first["_picker_private_annotation"] = True + after = inventory(annotated_first, second) + fixture = LoginFixture(before) + pinned_path = fixture.base / "annotated.json" + pinned_path.write_text(json.dumps(after), encoding="utf-8") + + def fingerprint(path: Path) -> str: + completed = run( + [ + "bash", + "-c", + 'source "$LOGIN_LIVE_MODULE"; picker_view_fingerprint', + ], + env={ + "LOGIN_LIVE_MODULE": os.fspath( + REPO / "lib" / "sh" / "shpool_login_live.sh" + ), + "VIEW": os.fspath(path), + "PAGE": "1", + "QUERY": "", + }, + ) + return completed.stdout.strip() + + try: + self.assertEqual( + fingerprint(fixture.inventory), + fingerprint(pinned_path), + ) + finally: + fixture.close() + + def test_account_alias_changes_fingerprint_but_quota_does_not(self) -> None: + first = row("account", number=1, provider="codex") + first["account_alias"] = "main" + same_account = dict(first) + same_account["account_quota"] = {"weekly_used": 0.99} + other_account = dict(first) + other_account["account_alias"] = "backup" + fixture = LoginFixture(inventory(first)) + same_path = fixture.base / "same-account.json" + other_path = fixture.base / "other-account.json" + same_path.write_text(json.dumps(inventory(same_account)), encoding="utf-8") + other_path.write_text(json.dumps(inventory(other_account)), encoding="utf-8") + + def fingerprint(path: Path) -> str: + completed = run( + [ + "bash", + "-c", + 'source "$LOGIN_LIVE_MODULE"; picker_view_fingerprint', + ], + env={ + "LOGIN_LIVE_MODULE": os.fspath( + REPO / "lib" / "sh" / "shpool_login_live.sh" + ), + "VIEW": os.fspath(path), + "PAGE": "1", + "QUERY": "", + }, + ) + return completed.stdout.strip() + + try: + initial = fingerprint(fixture.inventory) + self.assertEqual(initial, fingerprint(same_path)) + self.assertNotEqual(initial, fingerprint(other_path)) + finally: + fixture.close() + + def test_a_half_typed_command_survives_an_automatic_repaint(self) -> None: + first = row("alpha", number=1) + second = row("bravo", number=2) + fixture = LoginFixture( + inventory(first), refreshed_document=inventory(first, second) + ) + try: + # "/alp" is typed and left unsent. The repaint clears the screen + # underneath it; the search must still run on the whole word. + code, output = run_pty( + fixture, + b"/alp", + env_updates={ + "SESSION_KIT_PICKER_REFRESH_SECONDS": "2", + "SESSION_KIT_NO_COLOR": None, + # This is the repaint guarantee, not the filter preview: + # with type-ahead filtering on, "/alp" would hide the + # arriving row on purpose and there would be no repaint to + # survive. tests/test_picker_ux.py covers the preview. + "SESSION_KIT_PICKER_FILTER_LIVE": "0", + }, + deferred=("Codex bravo", b"ha\nq\n"), + ) + self.assertEqual(0, code) + # A repaint clears the screen under the queued characters, but it + # must never consume them: the finished search still runs on the + # whole word and hides the row it excludes. + trailing = output[output.rindex("Codex bravo") :] + self.assertIn("Codex alpha", trailing) + self.assertNotIn("Unknown choice", trailing) + finally: + fixture.close() + + def test_a_terminal_without_escapes_never_auto_repaints(self) -> None: + first = row("alpha", number=1) + second = row("bravo", number=2) + fixture = LoginFixture( + inventory(first), refreshed_document=inventory(first, second) + ) + try: + # The menu cannot be cleared here, so repainting would stack + # copies down the screen. It stays static instead. + code, output = run_pty( + fixture, + b"q\n", + env_updates={ + "SESSION_KIT_PICKER_REFRESH_SECONDS": "2", + "TERM": "dumb", + }, + ) + self.assertIn("Codex alpha", output) + self.assertNotIn("Codex bravo", output) + self.assertEqual(0, code) + finally: + fixture.close() + + def test_refresh_off_leaves_the_menu_exactly_as_drawn(self) -> None: + first = row("alpha", number=1) + second = row("bravo", number=2) + fixture = LoginFixture( + inventory(first), refreshed_document=inventory(first, second) + ) + try: + code, output = run_pty( + fixture, + b"q\n", + env_updates={"SESSION_KIT_PICKER_REFRESH_SECONDS": "0"}, + ) + self.assertIn("Codex alpha", output) + self.assertNotIn("Codex bravo", output) + self.assertEqual(0, code) + finally: + fixture.close() + + def test_guided_new_never_lists_an_ignored_directory(self) -> None: + fixture = LoginFixture(inventory()) + try: + fixture.projects.write_text( + f"main\tclaude\t{fixture.primary_project}\n" + f"other\tignore\t{fixture.other}\n", + encoding="utf-8", + ) + code, output = run_pty(fixture, b"n\n2\n1\n\nq\n") + self.assertEqual(0, code) + self.assertNotIn("other:", output) + self.assertIn(f" 1 main: {fixture.primary_project}", output) + self.assertEqual( + [["new", "codex", "main", "--account", "wren"]], + [entry["args"] for entry in fixture.sp_entries()], + ) + finally: + fixture.close() + + def test_recovery_open_and_empty_input_do_not_ack_or_restore(self) -> None: + pending = { + "schema_version": 1, + "entries": [ + { + "source_generation_key": "generation", + "old_shpool_id": "old1", + "display_old_shpool_id": "old1", + "provider": "claude", + "uuid": "11111111-1111-4111-8111-111111111111", + "cwd": "/srv/project", + "title": "Recover me", + "started_at_unix_ms": int(time.time() * 1000) - (51 * 3600 * 1000), + } + ], + } + fixture = LoginFixture(inventory(), pending=pending) + try: + code, output = run_pty(fixture, b"u\n\nq\n") + self.assertEqual(0, code) + self.assertIn("more m", output) + # The row's own event, in the words `sp recover` uses for it. + self.assertIn("[Claude] Recover me [lost 2 days ago]", output) + self.assertNotIn("old1", output) + self.assertIn( + "restore numbers · one with no number goes by the selector" + " beside it · restore all a · ↵ back", + output, + ) + self.assertNotIn("Traceback", output) + self.assertEqual([], fixture.sp_entries()) + self.assertFalse( + any( + entry and entry[0] == "--recovery-pending-ack" + for entry in fixture.status_entries() + ) + ) + self.assertEqual([], fixture.picker_temps()) + finally: + fixture.close() + + def test_a_selection_the_screen_cannot_read_restores_nothing(self) -> None: + """A typo is not a command, on the real screen. + + The parser's refusal was caught and then undone: every fragment that + happened to be a number was added back, so "1,garbage" restored + session 1 and "1,,2" restored two conversations. + """ + pending = { + "schema_version": 1, + "entries": [ + { + "source_generation_key": "generation", + "old_shpool_id": f"old-{index}", + "provider": "claude", + "uuid": f"1111111{index}-1111-4111-8111-111111111111", + "cwd": "/srv/project", + "title": f"Work {index}", + } + for index in (1, 2) + ], + } + for answer in (b"1,garbage", b"1,,2"): + fixture = LoginFixture(inventory(), pending=pending) + try: + code, output = run_pty(fixture, b"u\n" + answer + b"\nq\n") + self.assertEqual(0, code) + self.assertIn("There is no such closed session on this screen", output) + self.assertEqual([], fixture.sp_entries()) + self.assertFalse( + any( + entry and entry[0] == "--recovery-pending-ack" + for entry in fixture.status_entries() + ) + ) + finally: + fixture.close() + + def test_a_row_with_no_number_is_restored_by_the_name_it_shows(self) -> None: + """The screen printed a dash where the selector belongs. + + The parser quietly accepted a short id the screen never showed, so the + only way to act on the row was to read the JSON behind it. + """ + pending = { + "schema_version": 1, + "entries": [ + { + "source_generation_key": "generation", + "old_shpool_id": "old-1", + "provider": "claude", + "uuid": "22222222-2222-4222-8222-222222222222", + "cwd": "/srv/project", + "title": "Older work", + "numbered": False, + } + ], + } + fixture = LoginFixture(inventory(), pending=pending) + try: + code, output = run_pty(fixture, b"u\nolder work\nq\n", columns=120) + self.assertEqual(0, code) + self.assertIn("— [Claude] Older work", output) + self.assertIn('Restored closed session "Older work".', output) + self.assertEqual( + [ + [ + "restore-exact", + "claude", + "22222222-2222-4222-8222-222222222222", + "/srv/project", + ] + ], + [entry["args"] for entry in fixture.sp_entries()], + ) + # And no conversation identifier reached the screen to get there. + self.assertNotIn("22222222", output) + finally: + fixture.close() + + def test_recovery_selection_opens_exact_active_managed_uuid_without_duplicate( + self, + ) -> None: + active = row("active-one", number=4107, provider="codex") + pending = { + "schema_version": 1, + "entries": [ + { + "source_generation_key": "generation", + "old_shpool_id": "old-active", + "display_old_shpool_id": "old-active", + "provider": "codex", + "uuid": active["identity"]["uuid"], + "cwd": "/srv/project", + "title": "Already running", + } + ], + } + fixture = LoginFixture(inventory(active), pending=pending) + try: + code, output = run_pty(fixture, b"u\nall\nq\n") + self.assertEqual(0, code) + self.assertIn( + "Closed session 1 is already open as session 4107. Opening it.", + output, + ) + self.assertIn( + "Use fork 4107 from the picker to start a separate writable fork.", + output, + ) + self.assertIn( + "Confirmed session 4107 is open; its recovery evidence was retained.", + output, + ) + self.assertIn( + [ + "--recovery-pending-ack", + "generation", + "old-active", + active["identity"]["uuid"], + ], + fixture.status_entries(), + ) + entries = fixture.sp_entries() + self.assertEqual(["picker-open"], [entry["args"][0] for entry in entries]) + self.assertFalse( + any(entry["args"][0] == "restore-exact" for entry in entries) + ) + self.assertEqual("active-one", entries[0]["proof"]["shpool_id"]) + self.assertNotIn("old-active", output) + finally: + fixture.close() + + def test_active_managed_recovery_reports_and_retains_failed_ack(self) -> None: + active = row("active-one", number=4107, provider="codex") + pending = { + "schema_version": 1, + "entries": [ + { + "source_generation_key": "generation", + "old_shpool_id": "old-active", + "display_old_shpool_id": "old-active", + "provider": "codex", + "uuid": active["identity"]["uuid"], + "cwd": "/srv/project", + "title": "Already running", + } + ], + } + fixture = LoginFixture(inventory(active), pending=pending, ack_exit=1) + try: + code, output = run_pty(fixture, b"u\na\nq\n") + self.assertEqual(0, code) + self.assertIn( + "Session 4107 is open, but its live proof was incomplete. Its recovery evidence was retained.", + output, + ) + self.assertIn( + [ + "--recovery-pending-ack", + "generation", + "old-active", + active["identity"]["uuid"], + ], + fixture.status_entries(), + ) + self.assertEqual( + ["picker-open"], + [entry["args"][0] for entry in fixture.sp_entries()], + ) + finally: + fixture.close() + + def test_recovery_selection_refuses_duplicate_active_outside_manager(self) -> None: + exact_uuid = "22222222-2222-4222-8222-222222222222" + document = inventory() + document["outside_agents"] = [ + { + "provider": "claude", + "identity": { + "uuid": exact_uuid, + "pid": 9001, + "process_start_ticks": 90010, + "confidence": "exact", }, "title": "Outside Claude", "cwd": "/srv/outside", @@ -1533,17 +3721,19 @@ def test_recovery_selection_refuses_duplicate_active_outside_manager(self) -> No } fixture = LoginFixture(document, pending=pending) try: - code, output = run_pty(fixture, b"u\na\n\n") - self.assertEqual(2, code) + code, output = run_pty(fixture, b"u\na\nq\n") + self.assertEqual(0, code) self.assertIn( - "already active outside the session manager; no duplicate was started", + "Closed session 1 is already open outside the kit. Nothing changed.", output, ) self.assertEqual([], fixture.sp_entries()) finally: fixture.close() - def test_recovery_duplicate_check_is_scoped_to_exact_provider_and_uuid(self) -> None: + def test_recovery_duplicate_check_is_scoped_to_exact_provider_and_uuid( + self, + ) -> None: active = row("codex-active", number=22, provider="codex") pending = { "schema_version": 1, @@ -1561,17 +3751,64 @@ def test_recovery_duplicate_check_is_scoped_to_exact_provider_and_uuid(self) -> } fixture = LoginFixture(inventory(active), pending=pending) try: - code, output = run_pty(fixture, b"u\na\n\n") - self.assertEqual(2, code) - self.assertIn("Started claude-old as restored-fixture", output) + code, output = run_pty(fixture, b"u\na\nq\n") + self.assertEqual(0, code) + self.assertIn("Restored closed session 1.", output) + self.assertNotIn("claude-old", output) entries = fixture.sp_entries() self.assertEqual( - [["restore-exact", "claude", active["identity"]["uuid"], "/srv/project"]], + [ + [ + "restore-exact", + "claude", + active["identity"]["uuid"], + "/srv/project", + ] + ], [entry["args"] for entry in entries], ) finally: fixture.close() + def test_recovery_comma_selection_restores_each_selected_item_only(self) -> None: + entries = [] + for number in range(1, 4): + entries.append( + { + "source_generation_key": "generation", + "old_shpool_id": f"old-{number}", + "provider": "codex", + "uuid": f"00000000-0000-4000-8000-{number:012d}", + "cwd": "/srv/project", + "title": f"Recovery {number}", + } + ) + fixture = LoginFixture( + inventory(), pending={"schema_version": 1, "entries": entries} + ) + try: + code, output = run_pty(fixture, b"u\n1,3\nq\n") + self.assertEqual(0, code) + restores = [ + entry["args"] + for entry in fixture.sp_entries() + if entry["args"][0] == "restore-exact" + ] + self.assertEqual( + [ + ["restore-exact", "codex", entries[0]["uuid"], "/srv/project"], + ["restore-exact", "codex", entries[2]["uuid"], "/srv/project"], + ], + restores, + ) + self.assertIn("Restored closed session 1.", output) + self.assertIn("Restored closed session 3.", output) + self.assertNotIn("Restored closed session 2.", output) + self.assertNotIn("old-1", output) + self.assertNotIn("old-3", output) + finally: + fixture.close() + def test_incomplete_recovery_record_is_retained_without_nounset_exit(self) -> None: pending = { "schema_version": 1, @@ -1589,9 +3826,56 @@ def test_incomplete_recovery_record_is_retained_without_nounset_exit(self) -> No } fixture = LoginFixture(inventory(), pending=pending) try: - code, output = run_pty(fixture, b"u\na\n\n") - self.assertEqual(2, code) - self.assertIn("Recovery record 1 is incomplete; it was retained.", output) + code, output = run_pty(fixture, b"u\na\nq\n") + self.assertEqual(0, code) + self.assertIn("Closed session 1 is incomplete. Nothing changed.", output) + self.assertEqual([], fixture.sp_entries()) + self.assertFalse( + any( + entry and entry[0] == "--recovery-pending-ack" + for entry in fixture.status_entries() + ) + ) + self.assertEqual([], fixture.picker_temps()) + finally: + fixture.close() + + def test_conflicting_duplicate_recovery_is_retained_for_manual_review(self) -> None: + pending = { + "schema_version": 1, + "entries": [ + { + "source_generation_key": "generation", + "old_shpool_id": "old1", + "display_old_shpool_id": "old1", + "provider": "codex", + "uuid": "11111111-1111-4111-8111-111111111111", + "cwd": "/srv/project", + "title": "Conflicting evidence", + "actionable": False, + "conflict_fields": ["cwd", "command"], + } + ], + } + fixture = LoginFixture(inventory(), pending=pending) + try: + code, output = run_pty(fixture, b"u\na\nq\n") + self.assertEqual(0, code) + self.assertIn("review required: conflicting cwd, command", output) + # The sentence the list carries for this row, and the same one + # again when they act on it -- with the fields that disagree named. + self.assertIn( + "its launch records disagree with each other, so it needs a" + " look by hand", + output, + ) + self.assertIn( + "Closed session 1: its launch records disagree with each" + " other, so it needs a look by hand (cwd, command)." + " Nothing changed.", + output, + ) + self.assertNotIn("a plain shell", output) self.assertEqual([], fixture.sp_entries()) self.assertFalse( any( @@ -1603,6 +3887,29 @@ def test_incomplete_recovery_record_is_retained_without_nounset_exit(self) -> No finally: fixture.close() + def test_closed_session_row_uses_its_recorded_close_age(self) -> None: + pending = { + "schema_version": 1, + "entries": [ + { + "source_generation_key": "generation", + "old_shpool_id": "old1", + "provider": "codex", + "uuid": "11111111-1111-4111-8111-111111111111", + "cwd": "/srv/project", + "title": "Closed two hours", + "closed_at_unix_ms": int(time.time() * 1000) - 2 * 3600 * 1000, + } + ], + } + fixture = LoginFixture(inventory(), pending=pending) + try: + code, output = run_pty(fixture, b"u\n\nq\n", columns=120) + self.assertEqual(0, code) + self.assertIn("Closed two hours [closed 2 hr ago]", output) + finally: + fixture.close() + def test_prompt_waiting_cache_survives_global_cleanup_under_nounset(self) -> None: fixture = LoginFixture(inventory()) try: @@ -1633,11 +3940,7 @@ def test_prompt_waiting_cache_survives_global_cleanup_under_nounset(self) -> Non "-u", "-i", "-c", - ( - 'source "$1"; ' - '[[ -z ${__sk_state_root+x} ]]; ' - "__sk_waiting" - ), + ('source "$1"; [[ -z ${__sk_state_root+x} ]]; __sk_waiting'), "prompt-state-test", REPO / "bashrc/shpool.bashrc", ], @@ -1649,5 +3952,55 @@ def test_prompt_waiting_cache_survives_global_cleanup_under_nounset(self) -> Non fixture.close() +class TerminalReadDisciplineTests(unittest.TestCase): + """No read may CONSUME from the terminal while a timer runs on it. + + Bash's timed read abandons input it has already pulled out of the kernel + when the timeout fires mid-read: the queue no longer has the byte and the + variable never receives it. Under a loaded scheduler that window + stretches to whole time slices, and one keypress in a few hundred died on + the one-second beat exactly that way (measured 2026-08-19: a lone Enter + into `read -r -t 1` aimed at the timer edge was lost 7 times in 200, and + the q of a q-then-Enter into a timed single-character read was eaten 1 + time in 300 -- the two shapes CI kept failing with). The picker now + waits on its tick fifo and asks readiness with a zero timeout, which + consumes nothing; only untimed reads take bytes. Every timed read in the + picker's shell must therefore poll (-t 0), read a non-terminal + descriptor (-u), or carry one of the two audited waivers: the legacy + branch used only when no tick fifo could be opened, and the interrupt + path whose whole job is throwing queued input away. + """ + + WAIVERS = ("# tick-fallback", "# discard-by-design") + + def test_no_consuming_terminal_read_carries_a_timer(self) -> None: + offenders: list[str] = [] + sources = sorted((REPO / "lib" / "sh").glob("shpool_login*.sh")) + sources.append(REPO / "bin" / "shpool_login") + for path in sources: + for number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), start=1 + ): + if line.lstrip().startswith("#"): + continue + if any(waiver in line for waiver in self.WAIVERS): + continue + if not re.search(r"\bread\b", line): + continue + timer = re.search(r"-t\s+(\S+)", line) + if timer is None: + continue + if timer.group(1).strip("\"'") == "0": + continue + if re.search(r"-u\s", line): + continue + offenders.append(f"{path.name}:{number}: {line.strip()}") + self.assertEqual( + [], + offenders, + "timed reads that can consume (and so abandon) a keypress", + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_macos_preview.py b/tests/test_macos_preview.py index f328a88..b173267 100644 --- a/tests/test_macos_preview.py +++ b/tests/test_macos_preview.py @@ -112,7 +112,16 @@ def test_process_scan_discards_generation_that_changes_mid_read(self) -> None: bsd_reader=lambda _pid: next(sequence), args_reader=lambda _pid: procargs("/opt/homebrew/bin/codex"), ) - self.assertEqual(table, {}) + # The changing generation cannot be published as process identity, but + # the process must remain in its known parent tree as unreadable. A + # missing row would let a reader mistake churn for proof of absence. + self.assertEqual({30}, set(table)) + self.assertEqual(30, table[30]["pid"]) + self.assertEqual(20, table[30]["ppid"]) + self.assertEqual(-1, table[30]["start_ticks"]) + self.assertEqual([], table[30]["cmdline"]) + self.assertTrue(table[30]["argv_unreadable"]) + self.assertTrue(table[30]["environ_unreadable"]) def test_codex_uuid_comes_only_from_exact_process_environment(self) -> None: uuid = "00000000-0000-4000-8000-000000000018" @@ -130,14 +139,127 @@ def test_codex_uuid_comes_only_from_exact_process_environment(self) -> None: } with mock.patch.object( inventory_core, "_runtime_platform", return_value="darwin" + ), mock.patch.object( + inventory_core, + "codex_rollout_by_uuid", + return_value=[ + { + "session_id": uuid, + "id": uuid, + "source": "cli", + "_turn_state": "idle", + } + ], ): indexed = inventory_core.index_codex_processes( table, Path("/proc"), Path("/unused") ) self.assertEqual(indexed[30][0]["session_id"], uuid) - self.assertEqual(indexed[30][0]["_turn_state"], "state unavailable") + self.assertEqual(indexed[30][0]["_turn_state"], "idle") self.assertEqual(indexed[31], []) + def test_native_claude_session_argument_is_exact_identity(self) -> None: + uuid = "00000000-0000-4000-8000-000000000019" + session_name = "s20260729-120000-1" + process_table = { + 10: { + "pid": 10, + "ppid": 1, + "start_ticks": 100, + "cmdline": ["/opt/homebrew/bin/shpool", "daemon"], + "comm": "shpool", + }, + 20: { + "pid": 20, + "ppid": 10, + "start_ticks": 200, + "cmdline": ["-bash"], + "comm": "bash", + "session_name": session_name, + }, + 30: { + "pid": 30, + "ppid": 20, + "start_ticks": 300, + "cmdline": ["claude", "--session-id", uuid], + "comm": "claude.exe", + "cwd": "/Users/test/project", + }, + } + result = inventory_core.build_inventory( + { + "sessions": [ + { + "name": session_name, + "status": "Disconnected", + "started_at_unix_ms": 1_800_000_000_000, + } + ] + }, + [], + process_table, + {}, + ({}, {}), + {}, + now=1_800_000_001, + ) + row = result["sessions"][0] + self.assertEqual(row["provider"], "claude") + self.assertEqual(row["identity"]["uuid"], uuid) + self.assertEqual(row["identity"]["confidence"], "exact") + self.assertTrue(row["mutation_allowed"]) + + def test_unused_native_codex_reports_provider_working_directory(self) -> None: + session_name = "s20260729-120000-2" + process_table = { + 10: { + "pid": 10, + "ppid": 1, + "start_ticks": 100, + "cmdline": ["/opt/homebrew/bin/shpool", "daemon"], + "comm": "shpool", + }, + 20: { + "pid": 20, + "ppid": 10, + "start_ticks": 200, + "cmdline": ["-bash"], + "comm": "bash", + "session_name": session_name, + "cwd": "", + }, + 30: { + "pid": 30, + "ppid": 20, + "start_ticks": 300, + "cmdline": ["codex", "--no-alt-screen"], + "comm": "codex", + "cwd": "/Users/test/project", + }, + } + result = inventory_core.build_inventory( + { + "sessions": [ + { + "name": session_name, + "status": "Disconnected", + "started_at_unix_ms": 1_800_000_000_000, + } + ] + }, + [], + process_table, + {}, + ({}, {}), + {}, + now=1_800_000_001, + ) + row = result["sessions"][0] + self.assertEqual(row["provider"], "unknown") + self.assertEqual(row["display_provider"], "codex") + self.assertEqual(row["cwd"], "/Users/test/project") + self.assertEqual(row["title"], "Codex started, no messages yet") + def test_darwin_age_uses_epoch_microseconds(self) -> None: now = time.time() table = { @@ -148,20 +270,17 @@ def test_darwin_age_uses_epoch_microseconds(self) -> None: } self.assertIn(inventory_core._process_age(7, table, now), {41, 42}) - def test_macos_requires_explicit_preview_opt_in(self) -> None: + def test_macos_is_a_supported_native_platform(self) -> None: with ( mock.patch.object( inventory_core, "_runtime_platform", return_value="darwin" ), mock.patch.dict(os.environ, {}, clear=True), ): - with self.assertRaisesRegex( - inventory_core.CollectionError, "experimental preview" - ): - inventory_core._require_supported_platform() + self.assertEqual(inventory_core._require_supported_platform(), "darwin") -class DarwinFailClosedCommandTests(unittest.TestCase): +class DarwinSupportedCommandTests(unittest.TestCase): def setUp(self) -> None: self.temp = tempfile.TemporaryDirectory(prefix=".macos-preview-", dir=REPO) self.base = Path(self.temp.name) @@ -174,7 +293,6 @@ def setUp(self) -> None: **os.environ, "PATH": f"{self.bin}:/usr/bin:/bin", "HOME": str(self.base / "home"), - "SESSION_KIT_MACOS_PREVIEW": "1", "SESSION_KIT_TESTING": "1", "SESSION_KIT_TEST_PLATFORM": "Darwin", "SESSION_KIT_STATE_DIR": str(self.base / "state"), @@ -194,25 +312,37 @@ def run_bash(self, path: Path, *args: str) -> subprocess.CompletedProcess[str]: check=False, ) - def test_reaper_watchdog_and_prune_refuse_darwin(self) -> None: - cases = ( - (REPO / "bin/shpool_reaper", (), "reaper is unavailable"), - (REPO / "bin/session_kit_watchdog", (), "watchdog repair is unavailable"), - (REPO / "bin/sp", ("prune",), "prune is unavailable"), + def test_maintenance_commands_no_longer_have_preview_refusals(self) -> None: + for relative, message in ( + ("bin/shpool_reaper", "reaper is unavailable"), + ("bin/session_kit_watchdog", "watchdog repair is unavailable in the macOS preview"), + ("bin/sp", "prune is unavailable"), + ): + with self.subTest(path=relative): + self.assertNotIn( + message, + (REPO / relative).read_text(encoding="utf-8"), + ) + + def test_watchdog_repair_requires_linux_thread_evidence(self) -> None: + env = {**self.env, "SESSION_KIT_WATCHDOG_MODE": "repair"} + result = subprocess.run( + [self.bash, str(REPO / "bin/session_kit_watchdog"), "--once"], + cwd=REPO, + env=env, + text=True, + capture_output=True, + check=False, ) - for path, args, message in cases: - with self.subTest(path=path.name): - result = self.run_bash(path, *args) - self.assertNotEqual(result.returncode, 0) - self.assertIn(message, result.stderr) - self.assertFalse((self.base / "state").exists()) - - def test_darwin_mutex_is_atomic_and_explicitly_released(self) -> None: + self.assertNotEqual(result.returncode, 0) + self.assertIn("requires Linux daemon-thread evidence", result.stderr) + + def test_darwin_mutex_is_file_backed_and_explicitly_released(self) -> None: lock = self.base / "create.lock" command = ( f"source {REPO / 'bin/session_kit_common'}; " f"exec 9>{lock}; sk_lock_acquire 9 {lock}; " - "test -d " + str(lock) + ".darwin-lock; " + f"test -f {lock}; " "sk_lock_release 9; test ! -e " + str(lock) + ".darwin-lock" ) result = subprocess.run( diff --git a/tests/test_manifest_coverage.py b/tests/test_manifest_coverage.py new file mode 100644 index 0000000..e069327 --- /dev/null +++ b/tests/test_manifest_coverage.py @@ -0,0 +1,175 @@ +"""Every tracked file either ships in the public export or is listed private. + +``public-files.txt`` decides what the export *ships*. Nothing decides what it +*omits*: ``tools/build-public-tree`` skips a tracked file that matches no +pattern and still exits 0, so both ways this goes wrong are silent. A file +meant for the public tree quietly never ships, or a file kept back on purpose +starts shipping the day someone widens a pattern. + +The list below is the missing half of that decision. It names every tracked +file the export leaves behind and why, so a new file forces a visible choice +instead of defaulting to whichever outcome its path happens to produce. +""" + +from __future__ import annotations + +import fnmatch +import subprocess +import unittest + +from tests.support import REPO + + +MANIFEST = REPO / "public-files.txt" + +# tools/build-public-tree writes this into the tree it exports; it comes from +# the exporter, not from the manifest, so it has no pattern to match. +GENERATED_BY_THE_EXPORT = {"SOURCE.json"} + +# These were deliberately removed from the public source. Broad directory +# patterns must not make a later accidental reintroduction silent. +REMOVED_FROM_THE_PUBLIC_TREE = { + "docs/build-tracks-2026-08-12.md", + "LICENSES/MIT-maniple.txt", + "tools/reset-collection-order.py", +} + +# Tracked files the public export deliberately leaves behind, each with the +# reason it is held back. Keep this list exact: an entry that starts matching a +# manifest pattern, or that stops being tracked, fails the staleness test. +PRIVATE_BY_DECISION = { + "macos/com.session-kit.reaper.plist": ( + "reference copy of a file install.sh generates; shipping it invites an " + "edit the installer would silently overwrite" + ), + "tests/test_picker_dashboard.py": ( + "machine-specific test fixture; never exported" + ), + "tests/test_production_checkout_unreachable.py": ( + "machine-specific deployment artifact; never exported. The same " + "guarantees are tested generically in tests/test_worktree_isolation.py" + ), + "phase1-patches/claude_headless_job.sh.patch": ( + "machine-specific deployment artifact; never exported" + ), + "phase1-patches/scheduled_jobs_runner.sh.patch": ( + "machine-specific deployment artifact; never exported" + ), + "phase1-patches/watcher.py.patch": ( + "machine-specific deployment artifact; never exported" + ), + "tools/publish-release": ( + "maintainer-only release tooling; deliberately not shipped" + ), +} + +UNDECIDED = """{count} tracked file(s) are neither exported nor recorded as private: +{listing} +Decide each one: + ships publicly -> add a matching pattern to public-files.txt + stays private -> add it to PRIVATE_BY_DECISION in + tests/test_manifest_coverage.py, with the reason +tools/build-public-tree skips an unmatched file without a word, so this test is +the only place the omission is visible.""" + + +def manifest_patterns() -> list[str]: + return [ + line.strip() + for line in MANIFEST.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + + +def exported(path: str, patterns: list[str]) -> bool: + """Match exactly as tools/build-public-tree.selected does.""" + return any(fnmatch.fnmatchcase(path, pattern) for pattern in patterns) + + +class ManifestCoverageTests(unittest.TestCase): + def setUp(self) -> None: + listing = subprocess.run( + ["git", "ls-files", "-z"], + cwd=REPO, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if listing.returncode != 0: + # An unpacked release archive is not a checkout; there is no + # tracked set to compare the manifest against. + self.skipTest("not a Git checkout") + self.tracked = [path for path in listing.stdout.split("\0") if path] + self.patterns = manifest_patterns() + # tools/build-public-tree writes SOURCE.json into the tree it exports. + self.is_export = (REPO / "SOURCE.json").is_file() + + def test_every_tracked_file_is_exported_or_listed_private(self) -> None: + undecided = sorted( + path + for path in self.tracked + if not exported(path, self.patterns) + and path not in PRIVATE_BY_DECISION + and path not in GENERATED_BY_THE_EXPORT + ) + self.assertEqual( + [], + undecided, + UNDECIDED.format( + count=len(undecided), + listing="".join(f" {path}\n" for path in undecided), + ), + ) + + def test_private_list_names_only_files_the_export_still_omits(self) -> None: + shipping = sorted( + path for path in PRIVATE_BY_DECISION if exported(path, self.patterns) + ) + self.assertEqual( + [], + shipping, + f"public-files.txt now exports {shipping}; remove them from " + "PRIVATE_BY_DECISION so the list keeps meaning what it says", + ) + if self.is_export: + # A public export tracks only the exported subset, so every private + # entry is correctly absent there. + return + gone = sorted( + path for path in PRIVATE_BY_DECISION if path not in set(self.tracked) + ) + self.assertEqual( + [], + gone, + f"PRIVATE_BY_DECISION names untracked paths {gone}; drop them", + ) + + def test_every_private_entry_records_why(self) -> None: + unexplained = sorted( + path for path, reason in PRIVATE_BY_DECISION.items() if not reason.strip() + ) + self.assertEqual([], unexplained, "a held-back file needs its reason") + + def test_removed_files_do_not_return(self) -> None: + returned = sorted(REMOVED_FROM_THE_PUBLIC_TREE.intersection(self.tracked)) + self.assertEqual( + [], + returned, + f"removed public-tree paths returned: {returned}", + ) + + def test_manifest_and_this_module_agree_on_the_matching_rule(self) -> None: + """The guard is worthless if it matches differently from the exporter.""" + source = (REPO / "tools" / "build-public-tree").read_text(encoding="utf-8") + self.assertIn( + "return any(fnmatch.fnmatchcase(path, pattern) for pattern in patterns)", + source, + "tools/build-public-tree changed how it matches manifest patterns; " + "update exported() in this module to match, or the guard checks " + "something the exporter no longer does", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_model_availability.py b/tests/test_model_availability.py new file mode 100644 index 0000000..8194dd2 --- /dev/null +++ b/tests/test_model_availability.py @@ -0,0 +1,286 @@ +"""The kit never changes a model by itself, and says so before a session runs. + +The launch gate only ever checked the *shape* of a model identifier. A +well-formed name that this machine answers with something smaller passed it, +the flag went on the command line, and the session ran for its whole life on a +model nobody chose, the exact way a Fable request has been quietly served by +a much smaller model on this estate before. + +So a second question is asked before anything starts: is this model really +served here? Two local answers exist (what was served the last time it was +asked for, and the machine's own model list), and with neither the answer is +"unknown", said as unknown. Nothing is ever substituted: a refusal names what +would serve the request and leaves the choice where it belongs. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import pty +import subprocess +import sys +import tempfile +import unittest + +from lib.sessionkit_inventory import worker_model +from tests.support import REPO, run +from tests.test_commands import CommandFixture + +CORE = REPO / "lib" / "session_inventory.py" +SP = REPO / "bin" / "sp" + + +class AvailabilityTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="session-kit-models.") + self.state = Path(self.temporary.name) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def availability(self, model: str, **environ: str) -> dict: + return worker_model.availability( + "claude", model, state_dir=self.state, environ=environ + ) + + def test_a_model_on_the_machines_list_starts_a_session(self) -> None: + verdict = self.availability( + "claude-opus-5", SESSION_KIT_TUI_MODELS="claude-opus-5,claude-sonnet-4-5" + ) + self.assertEqual(worker_model.OFFERED, verdict["verdict"]) + self.assertNotIn(verdict["verdict"], worker_model.REFUSALS) + + def test_a_model_the_machine_does_not_offer_is_refused_with_the_list(self) -> None: + verdict = self.availability( + "claude-fable-5", SESSION_KIT_TUI_MODELS="claude-opus-5,claude-sonnet-4-5" + ) + self.assertEqual(worker_model.NOT_OFFERED, verdict["verdict"]) + self.assertIn(verdict["verdict"], worker_model.REFUSALS) + self.assertEqual(["claude-opus-5", "claude-sonnet-4-5"], verdict["offered"]) + message = worker_model.render_availability(verdict, flag="--model-anyway") + self.assertIn("You asked for claude-fable-5.", message) + self.assertIn("claude-opus-5, claude-sonnet-4-5", message) + self.assertIn("--model-anyway", message) + + def test_a_recorded_downgrade_refuses_and_names_what_really_serves_it( + self, + ) -> None: + worker_model.record_served( + self.state, "claude", "claude-fable-5", "claude-haiku-4-5" + ) + verdict = self.availability("claude-fable-5") + self.assertEqual(worker_model.DOWNGRADED, verdict["verdict"]) + self.assertEqual("claude-haiku-4-5", verdict["serves"]) + message = worker_model.render_availability(verdict, flag="--model-anyway") + self.assertIn("actually ran on claude-haiku-4-5", message) + self.assertIn("--model claude-haiku-4-5", message) + + def test_an_observation_outranks_the_list_a_machine_declares(self) -> None: + """The list is what the operator believes; the observation is what happened.""" + worker_model.record_served( + self.state, "claude", "claude-fable-5", "claude-haiku-4-5" + ) + verdict = self.availability( + "claude-fable-5", SESSION_KIT_TUI_MODELS="claude-fable-5" + ) + self.assertEqual(worker_model.DOWNGRADED, verdict["verdict"]) + + def test_a_model_that_served_before_is_confirmed_by_that(self) -> None: + worker_model.record_served( + self.state, "claude", "claude-opus-5", "claude-opus-5" + ) + verdict = self.availability("claude-opus-5") + self.assertEqual(worker_model.SERVED, verdict["verdict"]) + + def test_with_nothing_to_go_on_the_answer_is_unknown_not_approved(self) -> None: + verdict = self.availability("claude-opus-5") + self.assertEqual(worker_model.UNKNOWN, verdict["verdict"]) + self.assertNotIn(verdict["verdict"], worker_model.REFUSALS) + self.assertIn("nothing here can confirm it", verdict["reason"]) + + def test_the_newest_observation_is_the_one_that_counts(self) -> None: + worker_model.record_served( + self.state, "claude", "claude-fable-5", "claude-haiku-4-5", now_unix_ms=1 + ) + worker_model.record_served( + self.state, "claude", "claude-fable-5", "claude-fable-5", now_unix_ms=2 + ) + self.assertEqual( + worker_model.SERVED, self.availability("claude-fable-5")["verdict"] + ) + + def test_nothing_here_ever_returns_a_model_that_was_not_asked_for(self) -> None: + """The whole point: a verdict, never a substitution.""" + worker_model.record_served( + self.state, "claude", "claude-fable-5", "claude-haiku-4-5" + ) + for model in ("claude-fable-5", "claude-opus-5"): + verdict = self.availability(model) + self.assertEqual(model, verdict["model"]) + + # ---- the command surface --------------------------------------------- + + def core(self, *argv: str, **environ: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, os.fspath(CORE), *argv], + capture_output=True, + text=True, + check=False, + env={ + **os.environ, + "SESSION_KIT_STATE_DIR": os.fspath(self.state), + **environ, + }, + ) + + def test_the_verb_refuses_with_status_three_and_says_why(self) -> None: + completed = self.core( + "model-availability", + "claude", + "claude-fable-5", + SESSION_KIT_TUI_MODELS="claude-opus-5", + ) + self.assertEqual(3, completed.returncode, completed.stderr) + self.assertIn("You asked for claude-fable-5.", completed.stderr) + self.assertIn("claude-opus-5", completed.stderr) + + def test_the_verb_agrees_when_the_machine_offers_the_model(self) -> None: + completed = self.core( + "model-availability", + "claude", + "claude-opus-5", + SESSION_KIT_TUI_MODELS="claude-opus-5", + ) + self.assertEqual(0, completed.returncode, completed.stderr) + + def test_an_observation_can_be_recorded_from_outside(self) -> None: + recorded = self.core( + "model-served", "claude", "claude-fable-5", "claude-haiku-4-5" + ) + self.assertEqual(0, recorded.returncode, recorded.stderr) + self.assertTrue(json.loads(recorded.stdout)["downgraded"]) + self.assertEqual( + 3, self.core("model-availability", "claude", "claude-fable-5").returncode + ) + + +class LaunchRefusesASilentDowngradeTests(unittest.TestCase): + """`sp new` and `sp change-model` stop before the session exists.""" + + def setUp(self) -> None: + self.fixture = CommandFixture() + + def tearDown(self) -> None: + self.fixture.close() + + def environment(self, **extra: str) -> dict[str, str]: + return { + **self.fixture.env(), + "STUB_DYNAMIC_PROVIDER": "claude", + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "SESSION_KIT_TUI_MODELS": "claude-opus-5", + **extra, + } + + def test_a_model_this_machine_does_not_serve_starts_nothing(self) -> None: + refused = run( + [SP, "new", "claude", "fixture", "--model", "claude-fable-5"], + env=self.environment(), + check=False, + ) + self.assertEqual(2, refused.returncode, refused.stdout) + self.assertIn("You asked for claude-fable-5.", refused.stderr) + self.assertIn("claude-opus-5", refused.stderr) + self.assertIn("no session was created", refused.stderr) + self.assertFalse( + self.fixture.shpool_log.exists(), + "a refused model must not have started anything", + ) + + def test_the_person_can_say_they_meant_it(self) -> None: + started = run( + [ + SP, + "new", + "claude", + "fixture", + "--model", + "claude-fable-5", + "--model-anyway", + ], + env=self.environment(), + check=False, + ) + self.assertEqual(0, started.returncode, started.stderr) + self.assertTrue(self.fixture.shpool_log.exists()) + + def test_an_unconfirmed_model_tells_the_person_at_their_terminal(self) -> None: + """"Unknown" has to reach the person, or it is not an answer. + + With no model list and no observation, nothing on this machine can + confirm a model. That is not a refusal: the session starts exactly as + asked, but a launch that says nothing is indistinguishable from a + launch that was checked. A person asking at a terminal is told; a + scripted launch is not, because "nobody has confirmed this" repeated + into every log line is how a real warning stops being read. + """ + environment = self.environment() + environment.pop("SESSION_KIT_TUI_MODELS") + primary, secondary = pty.openpty() + try: + started = subprocess.run( + [SP, "new", "claude", "fixture", "--model", "claude-opus-5"], + env={**os.environ, **environment}, + stdout=subprocess.PIPE, + stderr=secondary, + text=True, + check=False, + timeout=120, + ) + os.close(secondary) + secondary = -1 + seen = b"" + while True: + try: + block = os.read(primary, 4096) + except OSError: + break + if not block: + break + seen += block + finally: + os.close(primary) + if secondary != -1: + os.close(secondary) + self.assertEqual(0, started.returncode, seen) + self.assertIn("nothing here can confirm it", seen.decode("utf-8", "replace")) + self.assertTrue(self.fixture.shpool_log.exists()) + + def test_a_scripted_launch_is_not_told_twice_a_day(self) -> None: + environment = self.environment() + environment.pop("SESSION_KIT_TUI_MODELS") + started = run( + [SP, "new", "claude", "fixture", "--model", "claude-opus-5"], + env=environment, + check=False, + ) + self.assertEqual(0, started.returncode, started.stderr) + self.assertNotIn("nothing here can confirm it", started.stderr) + self.assertTrue(self.fixture.shpool_log.exists()) + + def test_a_model_on_the_list_is_untouched_and_starts(self) -> None: + started = run( + [SP, "new", "claude", "fixture", "--model", "claude-opus-5"], + env=self.environment(), + check=False, + ) + self.assertEqual(0, started.returncode, started.stderr) + launches = sorted(Path(self.fixture.start).glob("*.launch")) + self.assertTrue(launches, "the launch record is what carries the model") + self.assertIn("claude-opus-5", launches[0].read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_model_launch.py b/tests/test_model_launch.py new file mode 100644 index 0000000..f56616d --- /dev/null +++ b/tests/test_model_launch.py @@ -0,0 +1,470 @@ +"""Generation-bound requested models for managed provider launches.""" + +from __future__ import annotations + +import os +from pathlib import Path +import tempfile +import unittest + +from tests.support import REPO, run + + +BASHRC = REPO / "bashrc/shpool.bashrc" + + +class RequestedModelLaunchTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix=".model-launch-", dir=REPO.parent) + self.root = Path(self.temporary.name) + self.home = self.root / "home" + self.home.mkdir(mode=0o700) + codex_themes = self.home / ".codex" / "themes" + codex_themes.mkdir(parents=True, mode=0o700) + (codex_themes / "sk-lime.tmTheme").write_text( + "fixture theme\n", encoding="utf-8" + ) + self.state = self.root / "state" + self.state.mkdir(mode=0o700) + self.start = self.root / "start" + self.start.mkdir(mode=0o700) + self.project = self.root / "project" + self.project.mkdir() + self.fake_bin = self.root / "bin" + self.fake_bin.mkdir() + self.boot = self.root / "boot-id" + self.boot.write_text("fixture-boot\n", encoding="utf-8") + self.inventory = self.root / "inventory.py" + self.inventory.write_text( + "#!/usr/bin/env python3\n" + "import re, sys\n" + "if sys.argv[1] == 'validate-worker-model':\n" + " provider, model = sys.argv[2:]\n" + " safe = re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{0,127}', model)\n" + " ok = bool(safe) and ((provider == 'claude' and model.startswith('claude-')) or " + "(provider == 'codex' and model.startswith(('gpt-', 'o3', 'o4', 'codex-'))))\n" + " if ok: print(model)\n" + " raise SystemExit(0 if ok else 1)\n" + "if sys.argv[1:3] == ['color', 'launch-pick']:\n" + " print('lime')\n" + " raise SystemExit(0)\n" + "if sys.argv[1:3] == ['lifecycle', 'provider-exited']: raise SystemExit(0)\n" + "raise SystemExit(1)\n", + encoding="utf-8", + ) + self.inventory.chmod(0o755) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_exact_model_and_launch_key_reach_each_provider(self) -> None: + values = { + "claude": ("claude-sonnet-4-5", "worker:review:1"), + "codex": ("gpt-5.6-codex", "worker:implementation:2"), + } + inner = ( + 'shell_start=$(awk "{print \\$22}" /proc/$$/stat); ' + 'daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat); ' + 'printf "%s\\t%s\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\t\\tnew\\n" ' + '"$4" "$2" "$$" "$shell_start" "$PPID" "$daemon_start" > "$3/modeled.expected"; ' + 'printf "%s\\t%s\\t%s\\t%s\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\n" ' + '"$4" "$2" "$5" "$6" "$$" "$shell_start" "$PPID" "$daemon_start" > "$3/modeled.launch"; ' + 'chmod 600 "$3/modeled.expected" "$3/modeled.launch"; source "$1"' + ) + for provider, (model, launch_key) in values.items(): + with self.subTest(provider=provider): + for path in self.start.iterdir(): + path.unlink() + log = self.root / f"{provider}.log" + executable = self.fake_bin / provider + executable.write_text( + "#!/usr/bin/env bash\n" + "printf 'argv=' > \"$PROVIDER_LOG\"\n" + "printf '<%s>' \"$@\" >> \"$PROVIDER_LOG\"\n" + "printf '\\nmodel=%s\\nkey=%s\\n' \"$SESSION_KIT_REQUESTED_MODEL\" " + "\"$SESSION_KIT_LAUNCH_IDEMPOTENCY_KEY\" >> \"$PROVIDER_LOG\"\n", + encoding="utf-8", + ) + executable.chmod(0o755) + start = self.start / "modeled" + start.write_text(f"{provider}\t{self.project}\t\tnew\n", encoding="utf-8") + start.chmod(0o600) + environment = os.environ.copy() + environment.update( + { + "HOME": str(self.home), + "PATH": f"{self.fake_bin}:{os.environ['PATH']}", + "PROVIDER_LOG": str(log), + "SESSION_KIT_BOOT_ID_FILE": str(self.boot), + "SESSION_KIT_INVENTORY_CORE": str(self.inventory), + "SESSION_KIT_START_DIR": str(self.start), + "SESSION_KIT_STATE_DIR": str(self.state), + "SHPOOL_JOURNAL": "disabled", + "SHPOOL_SESSION_NAME": "modeled", + } + ) + launched = run( + [ + "bash", "-c", + 'bash --noprofile --norc -ic "$1" model-inner "$2" "$3" "$4" "$5" "$6" "$7"', + "model-launch-test", inner, BASHRC, self.project, self.start, + provider, model, launch_key, + ], + env=environment, + ) + self.assertEqual(0, launched.returncode, launched.stderr) + observed = log.read_text(encoding="utf-8") + self.assertIn(f"<--model><{model}>", observed) + if provider == "codex": + self.assertIn('<-c><tui.theme="sk-lime">', observed) + self.assertIn(f"model={model}", observed) + self.assertIn(f"key={launch_key}", observed) + self.assertFalse(start.exists()) + self.assertFalse(Path(f"{start}.expected").exists()) + self.assertFalse(Path(f"{start}.launch").exists()) + + def test_a_model_launch_with_no_launch_key_still_starts_the_provider(self) -> None: + """The default `sp new --model` writes an EMPTY launch key. + + Tab is IFS whitespace, so `IFS=$'\\t' read` collapses the run of tabs + around an empty field and shifts every generation value one place + left: the boot id lands in the key, the daemon start lands nowhere, + and the cross-check refuses a record that was armed perfectly. The + session then sits as a shell with no provider, three of them in one + night, and the test suite never saw it because every case here + passed a key. + """ + provider, model = "claude", "claude-opus-5" + log = self.root / "nokey.log" + executable = self.fake_bin / provider + executable.write_text( + "#!/usr/bin/env bash\n" + "printf 'argv=' > \"$PROVIDER_LOG\"\n" + "printf '<%s>' \"$@\" >> \"$PROVIDER_LOG\"\n" + "printf '\\nmodel=%s\\nkey=[%s]\\n' \"$SESSION_KIT_REQUESTED_MODEL\" " + "\"$SESSION_KIT_LAUNCH_IDEMPOTENCY_KEY\" >> \"$PROVIDER_LOG\"\n", + encoding="utf-8", + ) + executable.chmod(0o755) + # The fourth field is empty, exactly as sk_arm_launch_request writes it + # when nobody passed --launch-key. + inner = ( + 'shell_start=$(awk "{print \\$22}" /proc/$$/stat); ' + 'daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat); ' + 'printf "%s\\t%s\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\t\\tnew\\n" ' + '"$4" "$2" "$$" "$shell_start" "$PPID" "$daemon_start" > "$3/nokey.expected"; ' + 'printf "%s\\t%s\\t%s\\t\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\n" ' + '"$4" "$2" "$5" "$$" "$shell_start" "$PPID" "$daemon_start" > "$3/nokey.launch"; ' + 'chmod 600 "$3/nokey.expected" "$3/nokey.launch"; source "$1"' + ) + start = self.start / "nokey" + start.write_text(f"{provider}\t{self.project}\t\tnew\n", encoding="utf-8") + start.chmod(0o600) + environment = os.environ.copy() + environment.update( + { + "HOME": str(self.home), + "PATH": f"{self.fake_bin}:{os.environ['PATH']}", + "PROVIDER_LOG": str(log), + "SESSION_KIT_BOOT_ID_FILE": str(self.boot), + "SESSION_KIT_INVENTORY_CORE": str(self.inventory), + "SESSION_KIT_START_DIR": str(self.start), + "SESSION_KIT_STATE_DIR": str(self.state), + "SHPOOL_JOURNAL": "disabled", + "SHPOOL_SESSION_NAME": "nokey", + } + ) + launched = run( + [ + "bash", "-c", + 'bash --noprofile --norc -ic "$1" model-inner "$2" "$3" "$4" "$5" "$6"', + "model-launch-test", inner, BASHRC, self.project, self.start, + provider, model, + ], + env=environment, + ) + self.assertEqual(0, launched.returncode, launched.stderr) + self.assertTrue(log.exists(), "the provider never started") + observed = log.read_text(encoding="utf-8") + self.assertIn(f"<--model><{model}>", observed) + self.assertIn("key=[]", observed) + # A launch consumes its records; a refusal leaves them behind. + self.assertFalse(start.exists()) + self.assertFalse(Path(f"{start}.launch").exists()) + + def test_unsafe_or_generation_mismatched_model_record_never_launches(self) -> None: + provider_log = self.root / "refused.log" + executable = self.fake_bin / "claude" + executable.write_text( + "#!/usr/bin/env bash\nprintf launched > \"$PROVIDER_LOG\"\n", + encoding="utf-8", + ) + executable.chmod(0o755) + start = self.start / "modeled" + start.write_text(f"claude\t{self.project}\t\tnew\n", encoding="utf-8") + start.chmod(0o600) + inner = ( + 'shell_start=$(awk "{print \\$22}" /proc/$$/stat); ' + 'daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat); ' + 'printf "claude\\t%s\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\t\\tnew\\n" ' + '"$2" "$$" "$shell_start" "$PPID" "$daemon_start" > "$3/modeled.expected"; ' + 'printf "claude\\t%s\\t%s\\tworker:bad:1\\twrong-boot\\t1\\t%s\\t%s\\t%s\\t%s\\n" ' + '"$2" "$4" "$$" "$shell_start" "$PPID" "$daemon_start" > "$3/modeled.launch"; ' + 'chmod 600 "$3/modeled.expected" "$3/modeled.launch"; source "$1"' + ) + environment = os.environ.copy() + environment.update( + { + "HOME": str(self.home), + "PATH": f"{self.fake_bin}:{os.environ['PATH']}", + "PROVIDER_LOG": str(provider_log), + "SESSION_KIT_BOOT_ID_FILE": str(self.boot), + "SESSION_KIT_INVENTORY_CORE": str(self.inventory), + "SESSION_KIT_START_DIR": str(self.start), + "SESSION_KIT_STATE_DIR": str(self.state), + "SHPOOL_JOURNAL": "disabled", + "SHPOOL_SESSION_NAME": "modeled", + } + ) + refused = run( + [ + "bash", "-c", + 'bash --noprofile --norc -ic "$1" model-inner "$2" "$3" "$4" "$5"', + "model-launch-refusal", inner, BASHRC, self.project, self.start, + "claude-sonnet-4-5", + ], + env=environment, + ) + self.assertIn("stale or mismatched launch record retained", refused.stderr) + self.assertFalse(provider_log.exists()) + self.assertTrue(start.exists()) + self.assertTrue(Path(f"{start}.launch").exists()) + + def test_codex_seed_prompt_is_delivered_once_and_consumed(self) -> None: + log = self.root / "seeded.log" + executable = self.fake_bin / "codex" + executable.write_text( + "#!/usr/bin/env bash\n" + "printf '<%s>' \"$@\" > \"$PROVIDER_LOG\"\n", + encoding="utf-8", + ) + executable.chmod(0o755) + start = self.start / "seeded" + start.write_text(f"codex\t{self.project}\t\tnew\n", encoding="utf-8") + start.chmod(0o600) + handoff = self.start / "seeded.prompt" + prompt = "Inspect both providers.\nReturn the exact parity gaps." + handoff.write_text(prompt, encoding="utf-8") + handoff.chmod(0o600) + inner = ( + 'shell_start=$(awk "{print \\$22}" /proc/$$/stat); ' + 'daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat); ' + 'printf "codex\\t%s\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\t\\tnew\\n" ' + '"$2" "$$" "$shell_start" "$PPID" "$daemon_start" > "$3/seeded.expected"; ' + 'chmod 600 "$3/seeded.expected"; source "$1"' + ) + environment = os.environ.copy() + environment.update( + { + "HOME": str(self.home), + "PATH": f"{self.fake_bin}:{os.environ['PATH']}", + "PROVIDER_LOG": str(log), + "SESSION_KIT_BOOT_ID_FILE": str(self.boot), + "SESSION_KIT_INVENTORY_CORE": str(self.inventory), + "SESSION_KIT_START_DIR": str(self.start), + "SESSION_KIT_STATE_DIR": str(self.state), + "SHPOOL_JOURNAL": "disabled", + "SHPOOL_SESSION_NAME": "seeded", + } + ) + launched = run( + [ + "bash", "-c", + 'bash --noprofile --norc -ic "$1" seed-inner "$2" "$3" "$4"', + "seed-prompt-test", inner, BASHRC, self.project, self.start, + ], + env=environment, + ) + self.assertEqual(0, launched.returncode, launched.stderr) + observed = log.read_text(encoding="utf-8") + self.assertIn(f"<--><{prompt}>", observed) + self.assertEqual(1, observed.count(prompt)) + self.assertFalse(handoff.exists()) + + def test_codex_seed_prompt_never_follows_a_symlink(self) -> None: + log = self.root / "symlinked.log" + executable = self.fake_bin / "codex" + executable.write_text( + "#!/usr/bin/env bash\nprintf '<%s>' \"$@\" > \"$PROVIDER_LOG\"\n", + encoding="utf-8", + ) + executable.chmod(0o755) + start = self.start / "symlinked" + start.write_text(f"codex\t{self.project}\t\tnew\n", encoding="utf-8") + start.chmod(0o600) + secret = self.root / "must-not-read" + secret.write_text("sensitive fixture text\n", encoding="utf-8") + handoff = self.start / "symlinked.prompt" + handoff.symlink_to(secret) + inner = ( + 'shell_start=$(awk "{print \\$22}" /proc/$$/stat); ' + 'daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat); ' + 'printf "codex\\t%s\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\t\\tnew\\n" ' + '"$2" "$$" "$shell_start" "$PPID" "$daemon_start" > "$3/symlinked.expected"; ' + 'chmod 600 "$3/symlinked.expected"; source "$1"' + ) + environment = os.environ.copy() + environment.update( + { + "HOME": str(self.home), + "PATH": f"{self.fake_bin}:{os.environ['PATH']}", + "PROVIDER_LOG": str(log), + "SESSION_KIT_BOOT_ID_FILE": str(self.boot), + "SESSION_KIT_INVENTORY_CORE": str(self.inventory), + "SESSION_KIT_START_DIR": str(self.start), + "SESSION_KIT_STATE_DIR": str(self.state), + "SHPOOL_JOURNAL": "disabled", + "SHPOOL_SESSION_NAME": "symlinked", + } + ) + launched = run( + [ + "bash", "-c", + 'bash --noprofile --norc -ic "$1" seed-inner "$2" "$3" "$4"', + "seed-symlink-test", inner, BASHRC, self.project, self.start, + ], + env=environment, + ) + self.assertEqual(0, launched.returncode, launched.stderr) + self.assertNotIn("sensitive fixture text", log.read_text(encoding="utf-8")) + self.assertTrue(handoff.is_symlink()) + + +class PreBakedResumeFallbackTests(unittest.TestCase): + """A resume that cannot succeed must still open a session. + + The pre-bake mints its throwaway conversation inside one profile and the + session shell resolves its own; if those ever disagree the launch records + have already been consumed, so `claude --resume` failed with "No + conversation found", that was read as a crash, the SAME conversation was + reopened once, it failed the same way, and the person was left with an + open window containing no provider at all. A conversation nothing can + find is nothing to lose: open a new one instead, which still takes its + colour from the kit. + """ + + UUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory( + prefix=".resume-fallback-", dir=REPO.parent + ) + self.root = Path(self.temporary.name) + self.home = self.root / "home" + (self.home / ".claude" / "projects").mkdir(parents=True, mode=0o700) + self.state = self.root / "state" + self.state.mkdir(mode=0o700) + self.start = self.root / "start" + self.start.mkdir(mode=0o700) + self.project = self.root / "project" + self.project.mkdir() + self.fake_bin = self.root / "bin" + self.fake_bin.mkdir() + self.boot = self.root / "boot-id" + self.boot.write_text("fixture-boot\n", encoding="utf-8") + self.inventory = self.root / "inventory.py" + self.inventory.write_text( + "#!/usr/bin/env python3\n" + "import sys\n" + "if sys.argv[1:3] == ['lifecycle', 'provider-exited']: raise SystemExit(0)\n" + "if sys.argv[1] == 'action-log': raise SystemExit(0)\n" + "raise SystemExit(1)\n", + encoding="utf-8", + ) + self.inventory.chmod(0o755) + self.log = self.root / "claude.log" + executable = self.fake_bin / "claude" + executable.write_text( + "#!/usr/bin/env bash\n" + "printf '<%s>' \"$@\" >> \"$PROVIDER_LOG\"\n" + "printf '\\n' >> \"$PROVIDER_LOG\"\n", + encoding="utf-8", + ) + executable.chmod(0o755) + self.addCleanup(self.temporary.cleanup) + + def launch(self): + start = self.start / "resumed" + start.write_text( + f"claude\t{self.project}\t{self.UUID}\tresume\n", encoding="utf-8" + ) + start.chmod(0o600) + inner = ( + 'shell_start=$(awk "{print \\$22}" /proc/$$/stat); ' + 'daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat); ' + 'printf "claude\\t%s\\tfixture-boot\\t1\\t%s\\t%s\\t%s\\t%s\\t%s\\tresume\\n" ' + '"$2" "$$" "$shell_start" "$PPID" "$daemon_start" "$4" ' + '> "$3/resumed.expected"; ' + 'chmod 600 "$3/resumed.expected"; source "$1"' + ) + environment = os.environ.copy() + environment.update( + { + "HOME": str(self.home), + "PATH": f"{self.fake_bin}:{os.environ['PATH']}", + "PROVIDER_LOG": str(self.log), + "SESSION_KIT_BOOT_ID_FILE": str(self.boot), + "SESSION_KIT_INVENTORY_CORE": str(self.inventory), + "SESSION_KIT_START_DIR": str(self.start), + "SESSION_KIT_STATE_DIR": str(self.state), + "XDG_STATE_HOME": str(self.state), + "SHPOOL_JOURNAL": "disabled", + "SHPOOL_SESSION_NAME": "resumed", + } + ) + environment.pop("CLAUDE_CONFIG_DIR", None) + launched = run( + [ + "bash", + "-c", + 'bash --noprofile --norc -ic "$1" resume-inner "$2" "$3" "$4" "$5"', + "resume-launch-test", + inner, + str(BASHRC), + str(self.project), + str(self.start), + self.UUID, + ], + env=environment, + ) + self.assertEqual(0, launched.returncode, launched.stderr) + return launched, self.log.read_text(encoding="utf-8") + + def test_a_conversation_this_profile_does_not_hold_opens_a_new_one(self) -> None: + launched, observed = self.launch() + + self.assertNotIn("<--resume>", observed) + self.assertIn("<--session-id>", observed) + # A NEW conversation, never the one that could not be found. + self.assertNotIn(self.UUID, observed) + self.assertIn("opening a new one instead", launched.stderr) + # Exactly one launch: a fallback, not a loop. + self.assertEqual(1, observed.count("<--session-id>")) + + def test_a_conversation_that_is_there_is_still_resumed(self) -> None: + """The fallback must not fire on a resume that can work.""" + project = self.home / ".claude" / "projects" / "-fixture" + project.mkdir(parents=True) + (project / f"{self.UUID}.jsonl").write_text( + '{"type":"user"}\n', encoding="utf-8" + ) + + launched, observed = self.launch() + + self.assertIn(f"<--resume><{self.UUID}>", observed) + self.assertNotIn("<--session-id>", observed) + self.assertNotIn("opening a new one instead", launched.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_name_authority.py b/tests/test_name_authority.py new file mode 100644 index 0000000..c76a5db --- /dev/null +++ b/tests/test_name_authority.py @@ -0,0 +1,149 @@ +"""One name wins everywhere, and a new session has one within a minute. + +Two owners used to write a session's name and nothing reconciled them: the +kit's own name, and the title the provider window puts on its bar. A rename +through `sp name` reached the kit and the provider's store, but the running +window kept the title it read at start, so the list and the terminal +disagreed until something restarted, and nobody could say which was right. + +The other half is the process rule paid for in a real session's death: an +unnamed session is clutter, and clutter gets closed. A session that names +nothing yet still carries the project it runs in, from its first refresh. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +import sys +import unittest + +from tests.support import REPO, run +from tests.test_commands import CommandFixture, inventory_document, session_row + +SP = REPO / "bin" / "sp" +CORE = REPO / "lib" / "session_inventory.py" +UUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" +KEY = f"claude:{UUID}" + + +def facade() -> object: + sys.path.insert(0, os.fspath(REPO / "lib")) + spec = importlib.util.spec_from_file_location("session_inventory_names", CORE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class OneNameWinsTests(unittest.TestCase): + def setUp(self) -> None: + self.core = facade() + + def title(self, **kwargs: object) -> tuple[str, str]: + return self.core._provider_title_info( + "claude", + UUID, + kwargs.pop("native", ""), + kwargs.pop("aliases", {}), + "/srv/demo-app/v2", + 1_786_576_000_000, + **kwargs, + ) + + def test_a_new_session_carries_its_project_from_the_first_refresh(self) -> None: + name, source = self.title() + self.assertEqual("context", source) + self.assertIn("demo-app", name) + # Never a bare provider word, and never a UUID. + self.assertNotEqual("Claude", name) + self.assertNotIn(UUID, name) + + def test_the_kit_name_outranks_the_bar_title_it_pushed(self) -> None: + name, source = self.title( + native="Kit Name", + aliases={KEY: "Kit Name"}, + pushed_titles={KEY: "Kit Name"}, + ) + self.assertEqual(("Kit Name", "alias"), (name, source)) + + def test_a_rename_typed_into_the_provider_after_the_push_wins(self) -> None: + """Newest act, newest name: the person renamed in the window last.""" + name, source = self.title( + native="Typed In The Window", + aliases={KEY: "Kit Name"}, + pushed_titles={KEY: "Kit Name"}, + ) + self.assertEqual(("Typed In The Window", "native"), (name, source)) + + def test_derived_placeholder_yields_to_automatic_alias(self) -> None: + common = { + "native": "v2-5e", + "aliases": {KEY: "Session Kit Closeout"}, + "automatic_titles": {KEY: "Session Kit Closeout"}, + "pushed_titles": {KEY: "Session Kit Closeout"}, + } + self.assertEqual( + ("Session Kit Closeout", "alias"), + self.title(**common, native_name_source="derived"), + ) + self.assertEqual(("v2-5e", "native"), self.title(**common)) + + def test_pre_push_registry_value_is_pending_but_third_value_wins(self) -> None: + common = { + "aliases": {KEY: "New Kit Name"}, + "automatic_titles": {KEY: "New Kit Name"}, + "pushed_titles": {KEY: "New Kit Name"}, + "pending_native_titles": { + KEY: { + "title": "Old Registry Name", + "nameSince": 100, + "nameSource": "", + } + }, + } + self.assertEqual( + ("New Kit Name", "alias"), + self.title(native="Old Registry Name", native_name_since=100, **common), + ) + self.assertEqual( + ("A Third Human Name", "native"), + self.title(native="A Third Human Name", native_name_since=200, **common), + ) + + +class RenameReachesTheBarTests(unittest.TestCase): + def setUp(self) -> None: + self.fixture = CommandFixture() + row = session_row("main1", provider="claude", uuid=UUID) + row["title"] = "Old Name" + row["native_title"] = "Old Name" + row["agent_status"] = "idle" + self.fixture.inventory.write_text( + json.dumps(inventory_document(row)), encoding="utf-8" + ) + + def tearDown(self) -> None: + self.fixture.close() + + def test_naming_a_session_marks_its_window_for_the_new_name(self) -> None: + named = run([SP, "name", "main1", "Cache Sweep"], env=self.fixture.env()) + self.assertEqual("Named the Claude session Cache Sweep.\n", named.stdout) + marker = self.fixture.state / "provider-untitled" / "main1" + self.assertTrue(marker.is_file(), sorted(self.fixture.state.iterdir())) + # Marking is not restarting: nothing was killed to rename a session. + self.assertFalse(self.fixture.shpool_log.exists()) + + def test_clearing_a_name_leaves_the_window_alone(self) -> None: + run([SP, "name", "reset", "main1"], env=self.fixture.env()) + self.assertFalse((self.fixture.state / "provider-untitled" / "main1").exists()) + + def test_the_stored_name_is_the_one_the_kit_now_holds(self) -> None: + run([SP, "name", "main1", "Cache Sweep"], env=self.fixture.env()) + stored = json.loads(self.fixture.config.read_text(encoding="utf-8")) + self.assertEqual("Cache Sweep", stored["aliases"][KEY]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_notice_routing.py b/tests/test_notice_routing.py new file mode 100644 index 0000000..a44080b --- /dev/null +++ b/tests/test_notice_routing.py @@ -0,0 +1,1956 @@ +"""Every notice that is not a picker row still reaches the operator. + +The picker is being narrowed to "sessions waiting for you". Four things it used +to carry are not that -- a repair that failed, a session reported quiet with +nothing changed, a stalled session, and another window's open question -- and +three of those four had NO other route to a person at all. These tests are the +proof that removing them from the screen costs nothing: + + * presence is decided by evidence and fails SAFE; + * the away transport gets what the terminal cannot take; + * the terminal route delivers over a session's own message socket, never a + pty, and is proved against a real socket rather than a mock; + * one incident is one notice, and a notice that did not land is never + recorded as delivered; + * nothing here writes anything either picker reads. +""" + +from __future__ import annotations + +import hashlib +import importlib.machinery +import importlib.util +import json +import os +from pathlib import Path +import socket +import subprocess +import tempfile +import threading +import time +from types import SimpleNamespace +import unittest +from unittest import mock + +from tests.support import REPO +from tests.test_watchdog import WatchdogFixture, session, write_executable + +NOTICE = REPO / "bin" / "session_kit_notice" +WATCHDOG = REPO / "bin" / "session_kit_watchdog" +UUID = "00000000-0000-4000-8000-000000000abc" + + +def load_notice_module(): + loader = importlib.machinery.SourceFileLoader( + "session_kit_notice_under_test", str(NOTICE) + ) + spec = importlib.util.spec_from_loader(loader.name, loader) + if spec is None: + raise RuntimeError("could not load session_kit_notice") + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +NOTICE_MODULE = load_notice_module() + +from sessionkit_inventory import transcript_text # noqa: E402 + + +def stamp(age_seconds: int = 0) -> str: + return time.strftime( + "%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - age_seconds) + ) + + +def snapshot(*, attached: list[dict] | None = None, age_seconds: int = 0, **overrides): + document = { + "schema_version": 1, + "source": "live", + "stale": False, + "generated_at": stamp(age_seconds), + "sessions": attached or [], + "outside_agents": [], + } + document.update(overrides) + return document + + +def attached_session( + *, + provider: str = "claude", + uuid: str = UUID, + needs_you: bool = False, + title: str = "A session", + age: int = 5, +) -> dict: + return { + "shpool_id": "s20260815-000000-1", + "display_title": title, + "title": title, + "provider": provider, + "availability": "attached", + "needs_you": needs_you, + "recent_output_age_seconds": age, + "identity": {"uuid": uuid, "confidence": "exact"}, + } + + +class Sandbox: + """One notice router, wired to recorders instead of anything real.""" + + def __init__(self, **files: object) -> None: + # Short prefix: a Unix socket path is capped at 108 bytes and the + # terminal-route fixture binds one under here. + self.temp = tempfile.TemporaryDirectory(prefix="nr-") + self.base = Path(self.temp.name) + self.state = self.base / "state" + self.state.mkdir() + self.bin = self.base / "bin" + self.bin.mkdir() + self.shpool = self.bin / "shpool" + write_executable( + self.shpool, + "#!/bin/sh\n" + "echo 'test fixture refuses shpool' >&2\n" + "exit 97\n", + ) + self.fleet = self.base / "fleet" + (self.fleet / "inbox").mkdir(parents=True) + self.away_log = self.base / "away.log" + self.away = self.base / "away" + write_executable( + self.away, + f"""#!/usr/bin/env bash +# ONE line per invocation, whatever the body contains: a batch body has +# real line breaks in it, and counting log lines would count each of them as +# a separate message -- which is the number under test. +printf '%s\\n' "$*" | tr '\\n' ' ' >> {str(self.away_log)!r} +printf '\\n' >> {str(self.away_log)!r} +""", + ) + self.snapshot = self.base / "snapshot.json" + self.snapshot.write_text(json.dumps(snapshot()), encoding="utf-8") + self.repairs = self.state / "watchdog-repairs.json" + for name, payload in files.items(): + (self.base / name).write_text(json.dumps(payload), encoding="utf-8") + + def env(self, **overrides: str) -> dict[str, str]: + # Every path a kit binary can reach is pinned inside this sandbox. On + # 2026-08-15 three of the operator's live sessions were closed by an + # unsandboxed run from an agent worktree; a test that can see the real + # estate is one bad default away from being that run. + environment = { + **os.environ, + "HOME": str(self.base), + "XDG_STATE_HOME": str(self.base / "xdg-state"), + "XDG_DATA_HOME": str(self.base / "xdg-data"), + "SESSION_KIT_STATE_DIR": str(self.state), + "SESSION_KIT_JOURNAL_DIR": str(self.base / "journals"), + "SESSION_KIT_ARCHIVE_DIR": str(self.base / "archives"), + "SESSION_KIT_JOURNAL_RECOVERY_DIR": str(self.base / "recovery"), + "SESSION_KIT_START_DIR": str(self.base), + "SESSION_KIT_PROJECTS_FILE": str(self.base / "projects.json"), + "SESSION_KIT_CONFIG": str(self.base / "config.toml"), + "PATH": f"{self.bin}{os.pathsep}{os.environ.get('PATH', '')}", + "SESSION_KIT_NONINTERACTIVE": "1", + "SESSION_KIT_BACKGROUND": "1", + # Never the real session manager: a fake that refuses, and a + # snapshot file the presence test reads instead of asking it. + "SESSION_KIT_SHPOOL_CMD": str(self.shpool), + "SESSION_KIT_STATUS_CMD": str(self.base / "no-such-status"), + "SESSION_KIT_SP_CMD": str(self.base / "no-such-sp"), + "SESSION_KIT_FLEET_DIR": str(self.fleet), + "SESSION_KIT_NOTICE_AWAY_CMD": str(self.away), + "SESSION_KIT_NOTICE_SNAPSHOT": str(self.snapshot), + "SESSION_KIT_WATCHDOG_REPAIRS": str(self.repairs), + } + environment.update(overrides) + return environment + + def write_snapshot(self, document: object) -> None: + self.snapshot.write_text(json.dumps(document), encoding="utf-8") + + def typed_in( + self, + uuid: str = UUID, + *, + seconds_ago: float = 5, + event: str = "UserPromptSubmit", + ) -> None: + """Record that a PERSON submitted a prompt in this session. + + This is the kit's own evidence of input: the Claude UserPromptSubmit + hook writes it, and `attention_hook.sh` calls that branch "The person + answered". It is the only thing the presence test accepts, because + provider OUTPUT keeps happening after everyone has gone home. + """ + directory = self.state / "attention" / "claude" + directory.mkdir(parents=True, exist_ok=True) + (directory / f"{uuid}.json").write_text( + json.dumps( + { + "schema_version": 1, + "session_id": uuid, + "hook_event": event, + "notification_type": "", + "message": "", + "needs_you": False, + "recorded_at_ms": int((time.time() - seconds_ago) * 1000), + } + ), + encoding="utf-8", + ) + + def run(self, *argv: str, **overrides: str) -> subprocess.CompletedProcess: + return subprocess.run( + [str(NOTICE), *argv], + env=self.env(**overrides), + capture_output=True, + text=True, + timeout=120, + ) + + def route(self, **overrides: str) -> dict: + result = self.run( + "route", + "--class", overrides.pop("notice_class", "quiet-session"), + "--key", overrides.pop("key", "quiet:s1"), + "--subject", overrides.pop("subject", "Session quiet: A session"), + "--body", overrides.pop("body", "Nothing was changed."), + "--evidence", overrides.pop("evidence", "silence-only"), + **overrides, + ) + return json.loads(result.stdout or "{}") + + def away_lines(self) -> list[str]: + if not self.away_log.exists(): + return [] + return [ + line + for line in self.away_log.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + def close(self) -> None: + self.temp.cleanup() + + +class FakeSession: + """A real Unix socket standing in for a live Claude session. + + Nothing is mocked: the router's terminal route is exercised against the + vendor's own registry shape, its auth-key file and a socket that answers, + because "it writes to a socket" is the whole claim being made -- that a + notice never goes near a terminal a provider is drawing. + """ + + def __init__( + self, + base: Path, + *, + uuid: str = UUID, + linger: float = 1.0, + records_transcript: bool = True, + ) -> None: + self.uuid = uuid + self.linger = linger + # A real session writes what it renders into its own transcript, and + # that file is the transport's documented receipt of record. A session + # that accepts the bytes and records nothing is the lost-after-write + # case, and it must NOT count as delivered. + self.records_transcript = records_transcript + self.runtime = base / "run" + (self.runtime / "cc-socks").mkdir(parents=True) + os.chmod(self.runtime, 0o700) + os.chmod(self.runtime / "cc-socks", 0o700) + self.config = base / "claude" + (self.config / "sessions").mkdir(parents=True) + (self.config / "projects" / "a-project").mkdir(parents=True) + self.transcript = self.config / "projects" / "a-project" / f"{uuid}.jsonl" + self.transcript.write_text("", encoding="utf-8") + self.pid = os.getpid() + self.path = self.runtime / "cc-socks" / f"{self.pid}.sock" + self.received: list[str] = [] + self.server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.server.bind(str(self.path)) + os.chmod(self.path, 0o600) + self.server.listen(4) + self.server.settimeout(30) + (self.config / "sessions" / f"{self.pid}.json").write_text( + json.dumps( + { + "pid": self.pid, + "sessionId": uuid, + "messagingSocketPath": str(self.path), + "kind": "interactive", + "status": "idle", + } + ), + encoding="utf-8", + ) + digest = hashlib.sha256( + os.path.normpath(os.path.abspath(str(self.path))).encode("utf-8") + ).hexdigest() + key = self.config / "sessions" / f"{self.pid}.{digest}.key" + key.write_text( + json.dumps({"peerToken": "a" * 32, "procStart": "1"}), encoding="utf-8" + ) + os.chmod(key, 0o600) + self.thread = threading.Thread(target=self._serve, daemon=True) + self.thread.start() + + def _serve(self) -> None: + while True: + try: + connection, _ = self.server.accept() + except OSError: + return + with connection: + connection.settimeout(10) + buffer = b"" + try: + # Auth line first, then the body. The connection is held + # open across both, because closing it is how a real + # session says "token refused". + while len(buffer.splitlines()) < 2 and len(buffer) < 200_000: + chunk = connection.recv(65536) + if not chunk: + break + buffer += chunk + except OSError: + pass + for line in buffer.decode("utf-8", "replace").splitlines(): + self.received.append(line) + if self.records_transcript and '"type": "user"' in line.replace( + '"type":"user"', '"type": "user"' + ): + # What a live session does with a message it renders. + with open(self.transcript, "a", encoding="utf-8") as handle: + handle.write(line + "\n") + if self.linger: + # A live session does not slam the connection shut the + # instant it has the body; holding it open here is what + # makes the ORDINARY outcome the one under test. + time.sleep(self.linger) + + def messages(self) -> list[dict]: + found = [] + for line in list(self.received): + try: + record = json.loads(line) + except ValueError: + continue + if record.get("type") == "user": + found.append(record) + return found + + def close(self) -> None: + try: + self.server.close() + except OSError: + pass + + +class CountedTerminal: + """A terminal boundary that records calls and can change live state.""" + + SENT_NO_RECEIPT = "sent-no-receipt" + CLOSED_AFTER_SEND = "closed-after-send" + + def __init__(self, after_capability=None, during_send=None, outcome=None) -> None: + self.after_capability = after_capability + self.during_send = during_send + self.outcome = outcome + self.capability_calls = 0 + self.send_calls = 0 + + def capability(self, uuid: str) -> dict: + self.capability_calls += 1 + if self.after_capability is not None: + self.after_capability() + return {"available": True, "reason": "fixture terminal"} + + def send(self, *args, **kwargs) -> SimpleNamespace: + self.send_calls += 1 + if self.during_send is not None: + self.during_send() + return self.outcome or SimpleNamespace( + delivered=True, + how="fixture queue acknowledgement", + extra={"message_uuid": "fixture-message-uuid"}, + ) + + +class PresenceTests(unittest.TestCase): + """At the machine is EVIDENCE, and every other answer is away.""" + + def setUp(self) -> None: + self.sandbox = Sandbox() + self.addCleanup(self.sandbox.close) + + def verdict(self, document: object) -> dict: + self.sandbox.write_snapshot(document) + result = self.sandbox.run("presence") + self.assertEqual(0, result.returncode, result.stderr) + return json.loads(result.stdout) + + def test_a_person_typing_is_the_evidence_of_presence(self) -> None: + self.sandbox.typed_in(seconds_ago=5) + answer = self.verdict(snapshot(attached=[attached_session()])) + self.assertTrue(answer["present"], answer) + self.assertIn("a person typed", answer["why"]) + self.assertEqual(1, len(answer["targets"])) + self.assertEqual(UUID, answer["targets"][0]["uuid"]) + + def test_a_busy_session_nobody_is_watching_is_not_presence(self) -> None: + """Provider output is AUTONOMOUS and proves nothing about a person. + + A session keeps printing for as long as its turn takes, so a window they + walked away from mid-turn looked more present than one they were reading + and the notice was delivered into that unattended conversation and + latched as said. Output age is no longer consulted at all. + """ + answer = self.verdict(snapshot(attached=[attached_session(age=1)])) + self.assertFalse(answer["present"], answer) + self.assertIn("no sign of a person typing", answer["why"]) + self.assertEqual([], answer["targets"]) + + def test_input_that_has_gone_cold_is_a_window_left_open(self) -> None: + self.sandbox.typed_in(seconds_ago=900) + answer = self.verdict(snapshot(attached=[attached_session(age=1)])) + self.assertFalse(answer["present"], answer) + self.assertIn("nobody has typed", answer["why"]) + + def test_input_from_more_than_five_seconds_in_the_future_is_not_evidence(self) -> None: + """A wall-clock step backwards must not manufacture a person.""" + self.sandbox.typed_in(seconds_ago=-3600) + answer = self.verdict(snapshot(attached=[attached_session(age=1)])) + self.assertFalse(answer["present"], answer) + self.assertIn("no sign of a person typing", answer["why"]) + + def test_a_notification_is_not_a_person_typing(self) -> None: + """The record is replaced per session, so the newest event decides. + + A notification is the session asking for a person, not a person + answering, and it must not read as one. + """ + self.sandbox.typed_in(seconds_ago=5, event="Notification") + answer = self.verdict(snapshot(attached=[attached_session(age=1)])) + self.assertFalse(answer["present"], answer) + self.assertIn("no sign of a person typing", answer["why"]) + + def test_nothing_attached_is_away(self) -> None: + detached = attached_session() + detached["availability"] = "ready" + answer = self.verdict(snapshot(attached=[detached])) + self.assertFalse(answer["present"]) + self.assertIn("no terminal is attached", answer["why"]) + + def test_a_snapshot_that_is_not_live_is_away(self) -> None: + answer = self.verdict( + snapshot(attached=[attached_session()], source="cache", stale=True) + ) + self.assertFalse(answer["present"]) + self.assertIn("presence unknown", answer["why"]) + + def test_an_attached_but_silent_session_is_a_window_left_open(self) -> None: + """`attached` means "open in another window", not "someone is there". + + The watchdog beside this file exists partly because a session can stay + wired to an SSH window that no longer exists. Treating that as presence + suppressed the away route AND prompted an abandoned conversation. + """ + answer = self.verdict( + snapshot(attached=[attached_session(age=99999, title="SSH window left open")]) + ) + self.assertFalse(answer["present"], answer) + self.assertEqual([], answer["targets"]) + + def test_two_attached_terminals_are_ambiguous_and_go_away(self) -> None: + """Nothing in the snapshot says which window they are in. + + Provider output age is not focus: a session working by itself prints + constantly while they read another. Ranking by it chose the busiest + conversation and PROMPTED it, because the frame is a user message. + """ + self.sandbox.typed_in(seconds_ago=5) + self.sandbox.typed_in("00000000-0000-4000-8000-000000000def", seconds_ago=5) + answer = self.verdict( + snapshot( + attached=[ + attached_session(age=20, title="One", uuid=UUID), + attached_session( + age=1, title="Two", + uuid="00000000-0000-4000-8000-000000000def", + ), + ] + ) + ) + self.assertFalse(answer["present"], answer) + self.assertIn("which one they are in", answer["why"]) + self.assertEqual([], answer["targets"]) + + def test_a_snapshot_from_the_future_is_not_evidence(self) -> None: + """A clock a minute ahead is not something to decide presence on.""" + self.sandbox.typed_in(seconds_ago=5) + answer = self.verdict( + snapshot(attached=[attached_session()], age_seconds=-50) + ) + self.assertFalse(answer["present"], answer) + self.assertIn("presence unknown", answer["why"]) + + def test_asking_who_is_here_never_uses_a_writing_mode(self) -> None: + """Presence is a question, not a refresh. + + `shpool_status --json` is listed by its own usage under "Modes that + refresh the cached inventory as they run": it hydrates and PUSHES + automatic titles and publishes snapshot state. This ran it every 60 + seconds under the watchdog, doubling the machine's refresh and + title-push rate to ask a read-only question. `--strict-json` is the + documented no-write answer. + """ + argv_log = self.sandbox.base / "status-argv.log" + status = self.sandbox.base / "status" + write_executable( + status, + f"""#!/usr/bin/env bash +printf '%s\\n' "$*" >> {str(argv_log)!r} +printf '%s\\n' '{json.dumps(snapshot())}' +""", + ) + result = self.sandbox.run( + "presence", + SESSION_KIT_NOTICE_SNAPSHOT="", + SESSION_KIT_STATUS_CMD=str(status), + ) + self.assertEqual(0, result.returncode, result.stderr) + recorded = argv_log.read_text(encoding="utf-8") + self.assertIn("--strict-json", recorded) + self.assertNotIn("--json", recorded.replace("--strict-json", "")) + for writing_mode in ("--waiting-count", "--detail", "--lookup"): + self.assertNotIn(writing_mode, recorded) + + def test_a_status_command_that_refuses_is_away_not_a_guess(self) -> None: + """`--strict-json` exits 3 rather than answer from ambiguous evidence, + and a refusal must read as presence unknown.""" + status = self.sandbox.base / "status" + write_executable( + status, + "#!/usr/bin/env bash\n" + "echo 'session inventory: strict live snapshot unavailable' >&2\n" + "exit 3\n", + ) + result = self.sandbox.run( + "presence", + SESSION_KIT_NOTICE_SNAPSHOT="", + SESSION_KIT_STATUS_CMD=str(status), + ) + answer = json.loads(result.stdout) + self.assertFalse(answer["present"], answer) + self.assertIn("presence unknown", answer["why"]) + + def test_an_old_snapshot_cannot_answer_for_now(self) -> None: + """An hour-old "attached" is a truthful record of an hour ago. + + Reading it as "they are here" delivers the notice to an empty chair, which + is the one direction this must never be wrong in. + """ + self.sandbox.typed_in(seconds_ago=5) + answer = self.verdict( + snapshot(attached=[attached_session()], age_seconds=3600) + ) + self.assertFalse(answer["present"]) + self.assertIn("presence unknown", answer["why"]) + + +class AwayRouteTests(unittest.TestCase): + def setUp(self) -> None: + self.sandbox = Sandbox() + self.addCleanup(self.sandbox.close) + + def test_away_gets_the_notice_on_the_existing_notifier_contract(self) -> None: + answer = self.sandbox.route() + self.assertTrue(answer["routed"], answer) + self.assertEqual("away", answer["route"]) + line = self.sandbox.away_lines()[0] + for flag in ("--type=", "--severity=warning", "--title=", "--body="): + self.assertIn(flag, line) + self.assertIn("Session quiet", line) + + def test_presence_that_cannot_be_determined_still_reaches_the_operator(self) -> None: + """Fail SAFE: unknown presence is away, never silence.""" + self.sandbox.snapshot.unlink() + answer = self.sandbox.route() + self.assertTrue(answer["routed"], answer) + self.assertEqual(1, len(self.sandbox.away_lines())) + + def test_a_transport_that_failed_is_never_recorded_as_delivered(self) -> None: + """A drop is not a delivery. + + A dropped alert once got stamped as told and suppressed for six hours. + The record says plainly that nothing landed, and the next pass retries. + """ + write_executable(self.sandbox.away, "#!/usr/bin/env bash\nexit 1\n") + answer = self.sandbox.route() + self.assertFalse(answer["routed"], answer) + latch_file = self.sandbox.state / "notice-latch.json" + latch = json.loads(latch_file.read_text(encoding="utf-8")) + entry = latch["notices"]["quiet-session:quiet:s1"] + self.assertFalse(entry["landed"]) + + # No successful handoff means there is nothing truthful to suppress. + write_executable( + self.sandbox.away, + f"""#!/usr/bin/env bash +printf '%s\\n' "$*" | tr '\\n' ' ' >> {str(self.sandbox.away_log)!r} +printf '\\n' >> {str(self.sandbox.away_log)!r} +""", + ) + again = self.sandbox.route() + self.assertTrue(again["routed"], again) + + def test_no_transport_configured_says_so_instead_of_claiming_success(self) -> None: + """An unwired away leg is a wiring fault, and it has to READ like one. + + This is the live state of the machine it was written for: nothing is + configured, so every away notice reaches nobody. That must be loud in + the log, non-zero on the way out, and impossible to mistake for a quiet + night. + """ + answer = self.sandbox.route(SESSION_KIT_NOTICE_AWAY_CMD="") + self.assertFalse(answer["routed"], answer) + self.assertIn("SESSION_KIT_NOTICE_AWAY_CMD", answer["why"]) + log = (self.sandbox.state / "notice.log").read_text(encoding="utf-8") + self.assertIn("UNWIRED", log) + + def test_a_sweep_that_reached_nobody_exits_non_zero(self) -> None: + self.sandbox.repairs.write_text( + json.dumps( + { + "schema_version": 1, + "repairs": [ + { + "at_unix_ms": int(time.time() * 1000), + "old_shpool_id": "s1", "new_shpool_id": "", + "title": "Some work", "provider": "codex", + "outcome": "reported", "acknowledged": False, + "reason": "no output for far longer than any normal pause", + } + ], + } + ), + encoding="utf-8", + ) + result = self.sandbox.run("sweep", SESSION_KIT_NOTICE_AWAY_CMD="") + self.assertEqual(1, result.returncode, result.stdout) + answer = json.loads(result.stdout) + self.assertEqual("UNSET", answer["away_transport"]) + self.assertEqual(1, answer["messages"]) + self.assertEqual(0, answer["routed"]) + + def test_the_kill_switch_routes_nothing(self) -> None: + answer = self.sandbox.route(SESSION_KIT_NOTICE="off") + self.assertFalse(answer["routed"]) + self.assertEqual([], self.sandbox.away_lines()) + + +class ShippedAwayTransportTests(unittest.TestCase): + """The worked example is a real transport, proved against a recorder. + + Never against a live notification path: the card command here writes a + file. The two things being proved are the two the example exists for -- + the text reaches the card command as ONE argument, and a card that did not + go out reports a failure instead of a success. + """ + + def setUp(self) -> None: + self.sandbox = Sandbox() + self.addCleanup(self.sandbox.close) + self.card_log = self.sandbox.base / "card.log" + self.card = self.sandbox.base / "card" + write_executable( + self.card, + f"""#!/usr/bin/env bash +printf 'ARGS=%s\\n' "$#" >> {str(self.card_log)!r} +printf '%s\\n' "$1" >> {str(self.card_log)!r} +""", + ) + + def test_the_example_hands_the_notice_over_as_one_argument(self) -> None: + answer = self.sandbox.route( + SESSION_KIT_NOTICE_AWAY_CMD=str(REPO / "extras" / "notify-chat-card"), + SESSION_KIT_NOTICE_CARD_CMD=str(self.card), + ) + self.assertTrue(answer["routed"], answer) + recorded = self.card_log.read_text(encoding="utf-8") + self.assertIn("ARGS=1", recorded) + self.assertIn("Session quiet", recorded) + + def test_an_unwired_card_command_is_a_failure_not_a_silent_success(self) -> None: + answer = self.sandbox.route( + SESSION_KIT_NOTICE_AWAY_CMD=str(REPO / "extras" / "notify-chat-card"), + SESSION_KIT_NOTICE_CARD_CMD="", + ) + self.assertFalse(answer["routed"], answer) + + +class LatchTests(unittest.TestCase): + """One incident, one notice -- and genuinely new evidence is a new notice.""" + + def setUp(self) -> None: + self.sandbox = Sandbox() + self.addCleanup(self.sandbox.close) + + def test_the_same_statement_is_not_made_twice(self) -> None: + self.sandbox.route() + second = self.sandbox.route() + self.assertEqual("latched", second["route"]) + self.assertEqual(1, len(self.sandbox.away_lines())) + + def test_stronger_evidence_about_the_same_session_is_a_new_notice(self) -> None: + self.sandbox.route(evidence="silence-only") + second = self.sandbox.route(evidence="daemon-marker") + self.assertTrue(second["routed"], second) + self.assertEqual(2, len(self.sandbox.away_lines())) + + def test_a_repeated_condition_cannot_buzz_every_pass(self) -> None: + for _ in range(5): + self.sandbox.route() + self.assertEqual(1, len(self.sandbox.away_lines())) + + +class SweepTests(unittest.TestCase): + """The classes that live in a record and were never announced anywhere.""" + + def setUp(self) -> None: + self.sandbox = Sandbox() + self.addCleanup(self.sandbox.close) + + def write_repairs(self, *entries: dict) -> None: + self.sandbox.repairs.write_text( + json.dumps({"schema_version": 1, "repairs": list(entries)}), + encoding="utf-8", + ) + + def quiet_record(self, session_id: str = "s1", evidence: str = "silence-only") -> dict: + return { + "at_unix_ms": int(time.time() * 1000), + "old_shpool_id": session_id, + "new_shpool_id": "", + "title": "Some work", + "provider": "codex", + "outcome": "reported", + "evidence": evidence, + "reason": "no output for far longer than any normal pause", + "acknowledged": False, + } + + def sweep(self, expect_status: int = 0, **overrides: str) -> dict: + result = self.sandbox.run("sweep", **overrides) + self.assertEqual(expect_status, result.returncode, result.stderr) + return json.loads(result.stdout) + + def test_a_session_reported_quiet_now_reaches_the_operator(self) -> None: + """The class with no route at all before this: outcome=reported.""" + self.write_repairs(self.quiet_record()) + answer = self.sweep() + self.assertEqual(1, answer["routed"], answer) + line = self.sandbox.away_lines()[0] + self.assertIn("Some work", line) + self.assertIn("nothing was changed", line.casefold()) + + def test_a_stalled_session_reaches_the_operator(self) -> None: + (self.sandbox.fleet / "stalls.json").write_text( + json.dumps( + { + "generated_at": time.time(), + "stalled": [{"key": "claude:abc", "reason": "silent"}], + } + ), + encoding="utf-8", + ) + answer = self.sweep() + self.assertEqual(1, answer["routed"], answer) + self.assertIn("stalled", self.sandbox.away_lines()[0].casefold()) + + def test_a_dead_stall_observer_says_nothing(self) -> None: + """A flag file nobody is updating is not news; it is a last opinion.""" + (self.sandbox.fleet / "stalls.json").write_text( + json.dumps( + { + "generated_at": time.time() - 4000, + "stalled": [{"key": "claude:abc", "reason": "silent"}], + } + ), + encoding="utf-8", + ) + self.assertEqual(0, self.sweep()["seen"]) + + def test_a_background_question_reaches_the_operator_while_away(self) -> None: + """The fourth class, and it has to have a route like the other three. + + A log line and a latch entry are not a delivery route. This shipped + held-by-default and that left the class with no away route at all, + which is the exact hole the branch exists to close. + """ + (self.sandbox.fleet / "inbox" / "q1.json").write_text( + json.dumps( + { + "id": "q1", + "state": "open", + "header": "Blueprint", + "session": {"title": "Draft work", "uuid": UUID}, + } + ), + encoding="utf-8", + ) + answer = self.sweep() + self.assertEqual(1, answer["routed"], answer) + self.assertEqual(0, answer["held"]) + line = self.sandbox.away_lines()[0] + self.assertIn("Blueprint", line) + # It says a decision is waiting and where. It never carries the + # question itself or its options. + self.assertIn("question inbox", line) + + def test_the_question_push_can_be_switched_back_off_by_name(self) -> None: + """`hold` restores the operator's earlier ruling exactly, in one word, + and a held question is recorded and counted rather than dropped.""" + (self.sandbox.fleet / "inbox" / "q1.json").write_text( + json.dumps({"id": "q1", "state": "open", "header": "Blueprint"}), + encoding="utf-8", + ) + answer = self.sweep(SESSION_KIT_NOTICE_QUESTION_AWAY="hold") + self.assertEqual(1, answer["held"], answer) + self.assertEqual([], self.sandbox.away_lines()) + latch = json.loads( + (self.sandbox.state / "notice-latch.json").read_text(encoding="utf-8") + ) + self.assertEqual("held", latch["notices"]["background-question:question:q1"]["route"]) + self.assertIn("HELD", (self.sandbox.state / "notice.log").read_text()) + + def test_one_record_is_one_notice_however_often_the_sweep_runs(self) -> None: + self.write_repairs(self.quiet_record()) + for _ in range(4): + self.sweep() + self.assertEqual(1, len(self.sandbox.away_lines())) + + def test_a_record_that_retires_takes_its_latch_with_it(self) -> None: + """Otherwise a session that breaks again is silenced by bookkeeping + nobody can see.""" + self.write_repairs(self.quiet_record()) + self.sweep() + self.write_repairs() + self.assertEqual(1, self.sweep()["forgotten"]) + self.write_repairs(self.quiet_record()) + self.assertEqual(1, self.sweep()["routed"]) + self.assertEqual(2, len(self.sandbox.away_lines())) + + def test_a_failed_repair_reaches_the_operator(self) -> None: + record = self.quiet_record() + record["outcome"] = "failed" + self.write_repairs(record) + answer = self.sweep() + self.assertEqual(1, answer["routed"], answer) + self.assertIn("repair failed", self.sandbox.away_lines()[0].casefold()) + + def test_a_successful_repair_is_not_a_notice(self) -> None: + """Nothing for them to do. It keeps the route it already has.""" + record = self.quiet_record() + record["outcome"] = "repaired" + record["new_shpool_id"] = "s2" + self.write_repairs(record) + self.assertEqual(0, self.sweep()["seen"]) + + def test_five_records_about_one_session_are_one_notice(self) -> None: + """Live data, 2026-08-15: one wedged session wrote five records in a + day, and 50 records covered only 21 sessions.""" + self.write_repairs(*[self.quiet_record() for _ in range(5)]) + answer = self.sweep() + self.assertEqual(1, answer["seen"], answer) + self.assertEqual(1, len(self.sandbox.away_lines())) + + def test_everything_new_arrives_as_one_message(self) -> None: + """The volume finding: one message per pass, whatever it covers. + + Fifty separate messages is not a notification design, it is a reason to + turn notifications off. + """ + self.write_repairs( + *[self.quiet_record(session_id=f"s{index}") for index in range(9)] + ) + answer = self.sweep() + self.assertEqual(9, answer["new"], answer) + self.assertEqual(1, answer["messages"]) + lines = self.sandbox.away_lines() + self.assertEqual(1, len(lines)) + self.assertIn("9 sessions need a look", lines[0]) + # Names a few, counts the rest, and never pastes nine paragraphs. + self.assertIn("and 4 more", lines[0]) + + def test_an_old_unresolved_record_is_counted_not_quietly_swallowed(self) -> None: + """Age is not acknowledgement. + + This used to take a record older than the bound into the latch WITHOUT + sending it, which converted an unresolved warning into "said" on the + strength of a clock. The bound now decides what gets NAMED; everything + unresolved is still counted in the one message that goes out. + """ + old = self.quiet_record(session_id="old") + old["at_unix_ms"] = int((time.time() - 30 * 3600) * 1000) + self.write_repairs(old, self.quiet_record(session_id="new")) + answer = self.sweep() + self.assertEqual(1, answer["older"], answer) + self.assertEqual(2, answer["new"]) + self.assertEqual(2, answer["routed"]) + lines = self.sandbox.away_lines() + self.assertEqual(1, len(lines), "still one message, not one per record") + self.assertIn("2 sessions need a look", lines[0]) + self.assertIn("unresolved for over", lines[0]) + + def test_a_backlog_of_only_old_records_is_still_said_once(self) -> None: + """Every record on the live estate this was measured against was hours + old. If age alone silenced them, the whole class would have arrived + already suppressed.""" + records = [] + for index in range(4): + record = self.quiet_record(session_id=f"old{index}") + record["at_unix_ms"] = int((time.time() - 20 * 3600) * 1000) + records.append(record) + self.write_repairs(*records) + answer = self.sweep() + self.assertEqual(4, answer["older"], answer) + self.assertEqual(4, answer["routed"]) + self.assertEqual(1, len(self.sandbox.away_lines())) + # Said once, then quiet: the backlog is one buzz, not a nightly one. + self.assertEqual(0, self.sweep()["new"]) + self.assertEqual(1, len(self.sandbox.away_lines())) + + def test_evidence_escalates_but_never_downgrades(self) -> None: + """Within one incident: stronger evidence speaks, weaker does not. + + The same ranking the watchdog's own record latch uses, so the two + cannot disagree about one event stream. + """ + first = self.quiet_record(evidence="silence-only") + self.write_repairs(first) + self.assertEqual(1, self.sweep()["routed"]) + stronger = self.quiet_record(evidence="daemon-marker") + stronger["at_unix_ms"] = first["at_unix_ms"] # same incident + self.write_repairs(stronger) + self.assertEqual(1, self.sweep()["routed"]) + self.assertEqual(2, len(self.sandbox.away_lines())) + weaker = self.quiet_record(evidence="silence-only") + weaker["at_unix_ms"] = first["at_unix_ms"] + self.write_repairs(weaker) + self.assertEqual(0, self.sweep()["new"]) + self.assertEqual(2, len(self.sandbox.away_lines())) + + def test_a_second_incident_after_a_recovery_is_said(self) -> None: + """ONE latch rule, and it is the watchdog's. + + The paired watchdog treats output after the previous record as recovery + and writes a fresh record for the same session and the same evidence. + This file used to collapse those to one key and call the second one + already-said, so a real second failure produced a watchdog record and + NO notice, information loss of exactly the kind the branch exists to + prevent. A record written after the last thing said about a session is + the watchdog telling us the last incident was over. + """ + first = self.quiet_record(session_id="s1") + first["at_unix_ms"] = int((time.time() - 900) * 1000) + self.write_repairs(first) + self.assertEqual(1, self.sweep()["routed"]) + self.assertEqual(1, len(self.sandbox.away_lines())) + + # Same session, same evidence, and the watchdog wrote it AFTER the one + # we spoke about: it recovered and broke again. + again = self.quiet_record(session_id="s1") + self.write_repairs(first, again) + answer = self.sweep() + self.assertEqual(1, answer["routed"], answer) + self.assertEqual(0, answer["latched"]) + self.assertEqual(2, len(self.sandbox.away_lines())) + + # And re-reading the same records says nothing further. + self.assertEqual(0, self.sweep()["new"]) + self.assertEqual(2, len(self.sandbox.away_lines())) + + def test_a_batch_that_did_not_land_comes_back(self) -> None: + write_executable(self.sandbox.away, "#!/usr/bin/env bash\nexit 1\n") + self.write_repairs(self.quiet_record(session_id="a"), self.quiet_record(session_id="b")) + # A sweep that had something to say and reached nobody EXITS NON-ZERO. + # It used to exit 0, so an unwired transport looked like a quiet night. + answer = self.sweep(expect_status=1) + self.assertEqual(0, answer["routed"], answer) + latch = json.loads( + (self.sandbox.state / "notice-latch.json").read_text(encoding="utf-8") + ) + self.assertEqual(2, len(latch["notices"])) + for entry in latch["notices"].values(): + self.assertFalse(entry["landed"]) + entry["at_unix"] = time.time() - 1000 + (self.sandbox.state / "notice-latch.json").write_text( + json.dumps(latch), encoding="utf-8" + ) + write_executable( + self.sandbox.away, + f"""#!/usr/bin/env bash +printf '%s\\n' "$*" | tr '\\n' ' ' >> {str(self.sandbox.away_log)!r} +printf '\\n' >> {str(self.sandbox.away_log)!r} +""", + ) + self.assertEqual(2, self.sweep()["routed"]) + + def test_a_failed_batch_is_retried_on_the_next_pass_not_called_quiet(self) -> None: + write_executable(self.sandbox.away, "#!/bin/sh\nexit 1\n") + self.write_repairs( + self.quiet_record(session_id="a"), + self.quiet_record(session_id="b"), + ) + first = self.sweep(expect_status=1) + self.assertEqual(0, first["routed"], first) + write_executable( + self.sandbox.away, + f"""#!/bin/sh +printf '%s\\n' "$*" | tr '\\n' ' ' >> {str(self.sandbox.away_log)!r} +printf '\\n' >> {str(self.sandbox.away_log)!r} +""", + ) + second = self.sweep() + self.assertEqual(2, second["routed"], second) + self.assertFalse(second["quiet"], second) + + def test_a_batch_never_leaves_a_latch_entry_nothing_can_retire(self) -> None: + self.write_repairs(self.quiet_record(session_id="a"), self.quiet_record(session_id="b")) + self.sweep() + latch = json.loads( + (self.sandbox.state / "notice-latch.json").read_text(encoding="utf-8") + ) + self.assertEqual( + {"quiet-session:quiet:a", "quiet-session:quiet:b"}, + set(latch["notices"]), + ) + + def test_an_acknowledged_record_is_not_re_announced(self) -> None: + record = self.quiet_record() + record["acknowledged"] = True + self.write_repairs(record) + self.assertEqual(0, self.sweep()["seen"]) + + +class PickerUntouchedTests(unittest.TestCase): + def setUp(self) -> None: + self.sandbox = Sandbox() + self.addCleanup(self.sandbox.close) + + def test_routing_writes_nothing_either_picker_reads(self) -> None: + """No row, no count, no line: the router owns its own two files.""" + self.sandbox.repairs.write_text( + json.dumps( + { + "schema_version": 1, + "repairs": [ + { + "at_unix_ms": int(time.time() * 1000), + "old_shpool_id": "s1", + "new_shpool_id": "", + "title": "Some work", + "provider": "codex", + "outcome": "reported", + "reason": "no output for far longer than any normal pause", + "acknowledged": False, + } + ], + } + ), + encoding="utf-8", + ) + (self.sandbox.fleet / "stalls.json").write_text( + json.dumps( + {"generated_at": time.time(), "stalled": [{"key": "k", "reason": "silent"}]} + ), + encoding="utf-8", + ) + (self.sandbox.fleet / "inbox" / "q1.json").write_text( + json.dumps({"id": "q1", "state": "open", "header": "Blueprint"}), + encoding="utf-8", + ) + watched = { + path: path.read_bytes() + for path in ( + self.sandbox.repairs, + self.sandbox.fleet / "stalls.json", + self.sandbox.fleet / "inbox" / "q1.json", + ) + } + self.sandbox.run("sweep") + self.sandbox.route() + for path, before in watched.items(): + self.assertEqual(before, path.read_bytes(), f"{path} was modified") + self.assertFalse((self.sandbox.state / "inventory.json").exists()) + self.assertEqual( + {"notice-latch.json", "notice.log", "watchdog-repairs.json"}, + {item.name for item in self.sandbox.state.iterdir()}, + ) + + def test_no_picker_module_calls_the_router(self) -> None: + for relative in ( + "lib/sh/shpool_login_render.sh", + "lib/sh/shpool_login_view.sh", + "bin/shpool_login", + "bin/shpool_login_tui", + ): + self.assertNotIn( + "session_kit_notice", + (REPO / relative).read_text(encoding="utf-8"), + f"{relative} reaches into the notice router", + ) + for module in sorted((REPO / "lib" / "sessionkit_tui").glob("*.py")): + self.assertNotIn( + "session_kit_notice", module.read_text(encoding="utf-8") + ) + + +class InProcessTerminalDecisionTests(unittest.TestCase): + """Terminal decisions with a counted boundary and no transport socket.""" + + def setUp(self) -> None: + self.sandbox = Sandbox() + self.addCleanup(self.sandbox.close) + self.sandbox.write_snapshot(snapshot(attached=[attached_session()])) + self.sandbox.typed_in(seconds_ago=5) + self.confirmations = 0 + + def route(self, terminal: CountedTerminal) -> dict: + def transcript_record(*args, **kwargs) -> bool: + self.confirmations += 1 + return True + + with mock.patch.dict(os.environ, self.sandbox.env(), clear=True): + with mock.patch.object(NOTICE_MODULE, "claude_socket", terminal): + with mock.patch.object( + NOTICE_MODULE, + "confirm_in_transcript", + transcript_record, + ): + return NOTICE_MODULE.route( + "quiet-session", + "quiet:s1", + "Session quiet: A session", + "Nothing was changed.", + evidence="silence-only", + ) + + def assert_confirmation_interval_detach_routes_away(self, stage: str) -> None: + """Exercise every boundary from authorization through reconciliation.""" + ready = attached_session() + ready["availability"] = "ready" + + def detach() -> None: + self.sandbox.write_snapshot(snapshot(attached=[ready])) + + terminal = CountedTerminal( + after_capability=detach if stage == "before-send" else None, + during_send=detach if stage == "inside-send" else None, + outcome=SimpleNamespace( + delivered=False, + how=CountedTerminal.SENT_NO_RECEIPT, + extra={"message_uuid": "fixture-message-uuid"}, + ), + ) + original_presence = NOTICE_MODULE.presence + presence_calls = 0 + + def staged_presence(*args, **kwargs): + nonlocal presence_calls + presence_calls += 1 + if stage == "between-send-and-confirmation" and presence_calls == 3: + detach() + if stage == "after-confirmation" and presence_calls == 4: + detach() + return original_presence(*args, **kwargs) + + def staged_confirmation(*args, **kwargs) -> bool: + if stage == "during-confirmation": + detach() + return True + + with mock.patch.dict(os.environ, self.sandbox.env(), clear=True): + with mock.patch.object(NOTICE_MODULE, "claude_socket", terminal): + with mock.patch.object(NOTICE_MODULE, "presence", staged_presence): + with mock.patch.object( + NOTICE_MODULE, + "confirm_in_transcript", + staged_confirmation, + ): + answer = NOTICE_MODULE.route( + "quiet-session", + "quiet:s1", + "Session quiet: A session", + "Nothing was changed.", + evidence="silence-only", + ) + + self.assertTrue(answer["routed"], answer) + self.assertEqual("away", answer["route"], answer) + self.assertEqual(0 if stage == "before-send" else 1, terminal.send_calls) + self.assertEqual(1, len(self.sandbox.away_lines())) + latch = json.loads( + (self.sandbox.state / "notice-latch.json").read_text(encoding="utf-8") + )["notices"]["quiet-session:quiet:s1"] + self.assertEqual("away", latch["route"]) + self.assertTrue(latch["landed"]) + self.assertTrue(latch["reconciled"]) + self.assertEqual("reconciled", latch["state"]) + + def assert_presence_change_routes_away(self, rows: list[dict]) -> None: + terminal = CountedTerminal( + after_capability=lambda: self.sandbox.write_snapshot( + snapshot(attached=rows) + ) + ) + answer = self.route(terminal) + self.assertTrue(answer["routed"], answer) + self.assertEqual("away", answer["route"], answer) + self.assertEqual(0, terminal.send_calls) + self.assertEqual(1, len(self.sandbox.away_lines())) + + @staticmethod + def production_transcript_record(message_uuid: str, body: str) -> dict: + """The user-message shape emitted by claude_socket.send().""" + return { + "type": "user", + "message": {"content": body}, + "session_id": UUID, + "uuid": message_uuid, + "msg_id": "fixture-message-id", + "priority": "next", + "from": "fixture-receipt-listener", + } + + def route_with_transcript( + self, raw_transcript: bytes, message_uuid: str + ) -> tuple[dict, CountedTerminal, Path]: + config = self.sandbox.base / "claude" + transcript = config / "projects" / "fixture" / f"{UUID}.jsonl" + transcript.parent.mkdir(parents=True) + transcript.write_bytes(raw_transcript) + terminal = CountedTerminal( + outcome=SimpleNamespace( + delivered=True, + how="delivered", + extra={"message_uuid": message_uuid}, + ) + ) + environment = self.sandbox.env( + CLAUDE_CONFIG_DIR=str(config), + SESSION_KIT_NOTICE_CONFIRM_SECONDS="0", + ) + with mock.patch.dict(os.environ, environment, clear=True): + with mock.patch.object(NOTICE_MODULE, "claude_socket", terminal): + answer = NOTICE_MODULE.route( + "quiet-session", + "quiet:s1", + "Session quiet: A session", + "Nothing was changed.", + evidence="silence-only", + ) + return answer, terminal, transcript + + def test_a_future_input_record_routes_away_and_is_never_latched_as_terminal( + self, + ) -> None: + self.sandbox.typed_in(seconds_ago=-3600) + terminal = CountedTerminal() + answer = self.route(terminal) + self.assertTrue(answer["routed"], answer) + self.assertEqual("away", answer["route"], answer) + self.assertEqual(0, terminal.send_calls) + self.assertEqual(1, len(self.sandbox.away_lines())) + latch = json.loads( + (self.sandbox.state / "notice-latch.json").read_text(encoding="utf-8") + )["notices"]["quiet-session:quiet:s1"] + self.assertTrue(latch["landed"]) + self.assertEqual("away", latch["route"]) + + def test_an_unchanged_target_is_sent_after_the_second_presence_check(self) -> None: + terminal = CountedTerminal() + answer = self.route(terminal) + self.assertTrue(answer["routed"], answer) + self.assertEqual("terminal", answer["route"], answer) + self.assertEqual(1, terminal.capability_calls) + self.assertEqual(1, terminal.send_calls) + self.assertEqual(1, self.confirmations) + self.assertEqual([], self.sandbox.away_lines()) + + def test_interval_detach_before_send_reconciles_away(self) -> None: + self.assert_confirmation_interval_detach_routes_away("before-send") + + def test_interval_detach_inside_send_reconciles_away(self) -> None: + self.assert_confirmation_interval_detach_routes_away("inside-send") + + def test_interval_detach_between_send_and_confirmation_reconciles_away( + self, + ) -> None: + self.assert_confirmation_interval_detach_routes_away( + "between-send-and-confirmation" + ) + + def test_interval_detach_during_confirmation_reconciles_away(self) -> None: + self.assert_confirmation_interval_detach_routes_away("during-confirmation") + + def test_interval_detach_after_confirmation_reconciles_away(self) -> None: + self.assert_confirmation_interval_detach_routes_away("after-confirmation") + + def test_a_queue_receipt_without_a_transcript_record_reconciles_away( + self, + ) -> None: + """The socket queue is not the target conversation's receipt of record.""" + terminal = CountedTerminal( + outcome=SimpleNamespace( + delivered=True, + how="delivered", + extra={"message_uuid": "fixture-message-uuid"}, + ) + ) + confirmations = [] + + def missing_record(*args, **kwargs) -> bool: + confirmations.append((args, kwargs)) + return False + + with mock.patch.dict(os.environ, self.sandbox.env(), clear=True): + with mock.patch.object(NOTICE_MODULE, "claude_socket", terminal): + with mock.patch.object( + NOTICE_MODULE, + "confirm_in_transcript", + missing_record, + ): + answer = NOTICE_MODULE.route( + "quiet-session", + "quiet:s1", + "Session quiet: A session", + "Nothing was changed.", + evidence="silence-only", + ) + + self.assertEqual(1, len(confirmations)) + self.assertEqual("away", answer["route"], answer) + self.assertTrue(answer["routed"], answer) + self.assertEqual(1, terminal.send_calls) + self.assertEqual(1, len(self.sandbox.away_lines())) + + def test_a_transcript_line_truncated_after_its_uuid_routes_away(self) -> None: + message_uuid = "11111111-1111-4111-8111-a11111111111" + complete = json.dumps( + self.production_transcript_record(message_uuid, "Nothing was changed.") + ) + raw = complete[: complete.index('"msg_id"')].encode("utf-8") + + answer, terminal, transcript = self.route_with_transcript(raw, message_uuid) + + self.assertEqual([], transcript_text.render_transcript(transcript)) + self.assertEqual("away", answer["route"], answer) + self.assertEqual(1, terminal.send_calls) + self.assertEqual(1, len(self.sandbox.away_lines())) + + def test_a_uuid_in_malformed_json_before_valid_unrelated_lines_routes_away( + self, + ) -> None: + message_uuid = "22222222-2222-4222-8222-a22222222222" + malformed = json.dumps( + self.production_transcript_record(message_uuid, "Nothing was changed.") + ) + malformed = malformed[: malformed.index('"msg_id"')] + unrelated = json.dumps( + self.production_transcript_record( + "33333333-3333-4333-8333-a33333333333", + "A separate complete conversation message.", + ) + ) + + answer, terminal, transcript = self.route_with_transcript( + f"{malformed}\n{unrelated}\n".encode("utf-8"), message_uuid + ) + + self.assertIn( + "A separate complete conversation message.", + transcript_text.render_transcript(transcript), + ) + self.assertEqual("away", answer["route"], answer) + self.assertEqual(1, terminal.send_calls) + self.assertEqual(1, len(self.sandbox.away_lines())) + + def test_a_valid_renderable_record_with_the_uuid_reconciles_terminal( + self, + ) -> None: + message_uuid = "44444444-4444-4444-8444-a44444444444" + body = "Nothing was changed." + raw = json.dumps( + self.production_transcript_record(message_uuid, body) + ).replace(message_uuid, message_uuid.replace("a", r"\u0061")) + + answer, terminal, transcript = self.route_with_transcript( + f"{raw}\n".encode("utf-8"), message_uuid + ) + + self.assertIn(body, transcript_text.render_transcript(transcript)) + self.assertEqual("terminal", answer["route"], answer) + self.assertEqual(1, terminal.send_calls) + self.assertEqual([], self.sandbox.away_lines()) + + def test_a_cut_window_line_is_discarded_without_losing_later_records( + self, + ) -> None: + message_uuid = "55555555-5555-4555-8555-a55555555555" + unrelated = json.dumps( + self.production_transcript_record( + "66666666-6666-4666-8666-a66666666666", + "A complete unrelated message.", + ) + ) + config = self.sandbox.base / "claude" + transcript = config / "projects" / "fixture" / f"{UUID}.jsonl" + transcript.parent.mkdir(parents=True) + environment = self.sandbox.env(CLAUDE_CONFIG_DIR=str(config)) + + # The 256 KiB tail starts inside this malformed first line. Its UUID + # bytes are not a record and must not confirm the complete unrelated + # line that follows it. + transcript.write_bytes( + (b"x" * 300_000) + + message_uuid.encode("ascii") + + b"\n" + + unrelated.encode("utf-8") + + b"\n" + ) + with mock.patch.dict(os.environ, environment, clear=True): + self.assertFalse( + NOTICE_MODULE.confirm_in_transcript( + UUID, message_uuid, deadline=time.time() + ) + ) + + # Discarding the cut first line must discard only that line. A later + # complete production record remains eligible, including normal JSON + # escaping that makes raw byte-substring matching insufficient. + target = json.dumps( + self.production_transcript_record( + message_uuid, "The complete target message." + ) + ).replace(message_uuid, message_uuid.replace("a", r"\u0061")) + transcript.write_bytes( + (b"y" * 300_000) + b"\n" + target.encode("utf-8") + b"\n" + ) + with mock.patch.dict(os.environ, environment, clear=True): + self.assertTrue( + NOTICE_MODULE.confirm_in_transcript( + UUID, message_uuid, deadline=time.time() + ) + ) + + def test_queue_receipt_establishes_transcript_before_final_presence( + self, + ) -> None: + """The attachment answer used for promotion cannot predate the receipt.""" + terminal = CountedTerminal( + outcome=SimpleNamespace( + delivered=True, + how="delivered", + extra={"message_uuid": "fixture-message-uuid"}, + ) + ) + ready = attached_session() + ready["availability"] = "ready" + evidence_order = [] + original_presence = NOTICE_MODULE.presence + + def ordered_presence(*args, **kwargs): + if kwargs.get("require_current"): + evidence_order.append("current-presence") + return original_presence(*args, **kwargs) + + def confirm_then_detach(*args, **kwargs) -> bool: + evidence_order.append("transcript-record") + self.sandbox.write_snapshot(snapshot(attached=[ready])) + return True + + with mock.patch.dict(os.environ, self.sandbox.env(), clear=True): + with mock.patch.object(NOTICE_MODULE, "claude_socket", terminal): + with mock.patch.object(NOTICE_MODULE, "presence", ordered_presence): + with mock.patch.object( + NOTICE_MODULE, + "confirm_in_transcript", + confirm_then_detach, + ): + answer = NOTICE_MODULE.route( + "quiet-session", + "quiet:s1", + "Session quiet: A session", + "Nothing was changed.", + evidence="silence-only", + ) + + self.assertEqual( + ["transcript-record", "current-presence"], evidence_order[-2:] + ) + self.assertEqual("away", answer["route"], answer) + self.assertTrue(answer["routed"], answer) + self.assertEqual(1, terminal.send_calls) + self.assertEqual(1, len(self.sandbox.away_lines())) + + def test_stable_queue_receipt_confirms_real_transcript_once(self) -> None: + """Ordinary use adds one immediate file check, not another notice.""" + message_uuid = "fixture-message-uuid" + config = self.sandbox.base / "claude" + transcript = config / "projects" / "fixture" / f"{UUID}.jsonl" + transcript.parent.mkdir(parents=True) + transcript.write_text( + json.dumps( + self.production_transcript_record( + message_uuid, "Nothing was changed." + ) + ) + + "\n", + encoding="utf-8", + ) + terminal = CountedTerminal( + outcome=SimpleNamespace( + delivered=True, + how="delivered", + extra={"message_uuid": message_uuid}, + ) + ) + presence_calls = 0 + confirmation_calls = 0 + original_presence = NOTICE_MODULE.presence + original_confirmation = NOTICE_MODULE.confirm_in_transcript + + def counted_presence(*args, **kwargs): + nonlocal presence_calls + presence_calls += 1 + return original_presence(*args, **kwargs) + + def counted_confirmation(*args, **kwargs) -> bool: + nonlocal confirmation_calls + confirmation_calls += 1 + return original_confirmation(*args, **kwargs) + + environment = self.sandbox.env( + CLAUDE_CONFIG_DIR=str(config), + SESSION_KIT_NOTICE_CONFIRM_SECONDS="1", + ) + started = time.monotonic() + with mock.patch.dict(os.environ, environment, clear=True): + with mock.patch.object(NOTICE_MODULE, "claude_socket", terminal): + with mock.patch.object(NOTICE_MODULE, "presence", counted_presence): + with mock.patch.object( + NOTICE_MODULE, + "confirm_in_transcript", + counted_confirmation, + ): + answer = NOTICE_MODULE.route( + "quiet-session", + "quiet:s1", + "Session quiet: A session", + "Nothing was changed.", + evidence="silence-only", + ) + elapsed = time.monotonic() - started + + self.assertEqual("terminal", answer["route"], answer) + self.assertTrue(answer["routed"], answer) + self.assertEqual(4, presence_calls) + self.assertEqual(1, confirmation_calls) + self.assertEqual(1, terminal.send_calls) + self.assertEqual([], self.sandbox.away_lines()) + self.assertLess(elapsed, 1.0) + + def test_terminal_latch_is_provisional_until_receipt_reconciliation(self) -> None: + terminal = CountedTerminal( + outcome=SimpleNamespace( + delivered=False, + how=CountedTerminal.SENT_NO_RECEIPT, + extra={"message_uuid": "fixture-message-uuid"}, + ) + ) + observed = [] + + def confirm_while_provisional(*args, **kwargs) -> bool: + entry = json.loads( + (self.sandbox.state / "notice-latch.json").read_text( + encoding="utf-8" + ) + )["notices"]["quiet-session:quiet:s1"] + observed.append(entry) + return True + + with mock.patch.dict(os.environ, self.sandbox.env(), clear=True): + with mock.patch.object(NOTICE_MODULE, "claude_socket", terminal): + with mock.patch.object( + NOTICE_MODULE, + "confirm_in_transcript", + confirm_while_provisional, + ): + answer = NOTICE_MODULE.route( + "quiet-session", + "quiet:s1", + "Session quiet: A session", + "Nothing was changed.", + evidence="silence-only", + ) + + self.assertEqual(1, len(observed)) + self.assertEqual("provisional", observed[0]["state"]) + self.assertFalse(observed[0]["reconciled"]) + self.assertFalse(observed[0]["landed"]) + self.assertEqual("terminal", answer["route"], answer) + self.assertTrue(answer["routed"], answer) + self.assertEqual([], self.sandbox.away_lines()) + final = json.loads( + (self.sandbox.state / "notice-latch.json").read_text(encoding="utf-8") + )["notices"]["quiet-session:quiet:s1"] + self.assertEqual("reconciled", final["state"]) + self.assertTrue(final["reconciled"]) + self.assertTrue(final["landed"]) + + def test_only_a_reconciled_success_can_suppress_a_replay(self) -> None: + provisional = { + "evidence": "silence-only", + "at_unix": time.time(), + "route": "terminal", + "landed": True, + "reconciled": False, + "state": "provisional", + } + self.assertFalse( + NOTICE_MODULE.already_said(provisional, "silence-only", 3600) + ) + + def test_presence_is_reproved_after_the_session_capability_check(self) -> None: + """The only attached row can detach after the first verdict.""" + ready = attached_session() + ready["availability"] = "ready" + self.assert_presence_change_routes_away([ready]) + + def test_a_detach_during_send_routes_away_despite_a_terminal_receipt(self) -> None: + """A receipt cannot call an unattended conversation a delivery.""" + ready = attached_session() + ready["availability"] = "ready" + terminal = CountedTerminal( + during_send=lambda: self.sandbox.write_snapshot( + snapshot(attached=[ready]) + ) + ) + answer = self.route(terminal) + self.assertTrue(answer["routed"], answer) + self.assertEqual("away", answer["route"], answer) + self.assertEqual(1, terminal.capability_calls) + self.assertEqual(1, terminal.send_calls) + self.assertEqual(1, len(self.sandbox.away_lines())) + self.assertIn("may have accepted", answer["why"]) + latch = json.loads( + (self.sandbox.state / "notice-latch.json").read_text(encoding="utf-8") + )["notices"]["quiet-session:quiet:s1"] + self.assertEqual("away", latch["route"]) + self.assertTrue(latch["landed"]) + + def test_a_detach_during_send_never_latches_a_failed_away_leg(self) -> None: + """Possible terminal acceptance is not proof enough to suppress retry.""" + write_executable(self.sandbox.away, "#!/bin/sh\nexit 1\n") + ready = attached_session() + ready["availability"] = "ready" + terminal = CountedTerminal( + during_send=lambda: self.sandbox.write_snapshot( + snapshot(attached=[ready]) + ) + ) + answer = self.route(terminal) + self.assertFalse(answer["routed"], answer) + self.assertEqual("away", answer["route"], answer) + self.assertEqual(1, terminal.send_calls) + latch = json.loads( + (self.sandbox.state / "notice-latch.json").read_text(encoding="utf-8") + )["notices"]["quiet-session:quiet:s1"] + self.assertEqual("away", latch["route"]) + self.assertFalse(latch["landed"]) + + def test_post_send_proof_never_uses_the_published_inventory_fallback(self) -> None: + """A recent cache cannot say what stayed attached during this send.""" + with mock.patch.dict(os.environ, self.sandbox.env(), clear=True): + initial = NOTICE_MODULE.presence() + (self.sandbox.state / "inventory.json").write_text( + self.sandbox.snapshot.read_text(encoding="utf-8"), encoding="utf-8" + ) + terminal = CountedTerminal() + environment = self.sandbox.env(SESSION_KIT_NOTICE_SNAPSHOT="") + with mock.patch.dict(os.environ, environment, clear=True): + with mock.patch.object(NOTICE_MODULE, "claude_socket", terminal): + answer = NOTICE_MODULE.route( + "quiet-session", + "quiet:s1", + "Session quiet: A session", + "Nothing was changed.", + evidence="silence-only", + verdict=initial, + ) + self.assertTrue(answer["routed"], answer) + self.assertEqual("away", answer["route"], answer) + self.assertEqual(1, terminal.send_calls) + self.assertEqual(1, len(self.sandbox.away_lines())) + self.assertIn("no current answer from the session manager", answer["why"]) + + def test_a_second_attachment_after_the_verdict_routes_away(self) -> None: + self.assert_presence_change_routes_away( + [ + attached_session(), + attached_session( + uuid="00000000-0000-4000-8000-000000000def", + title="Another session", + ), + ] + ) + + def test_a_question_opened_after_the_verdict_routes_away(self) -> None: + self.assert_presence_change_routes_away( + [attached_session(needs_you=True)] + ) + + +class TerminalRouteTests(unittest.TestCase): + """When they are at the machine the notice goes into the session, not the pty.""" + + def setUp(self) -> None: + self.sandbox = Sandbox() + self.addCleanup(self.sandbox.close) + self.fake = FakeSession(self.sandbox.base) + self.addCleanup(self.fake.close) + self.sandbox.write_snapshot(snapshot(attached=[attached_session()])) + # A person typed in it five seconds ago: the only thing that makes the + # terminal route apply at all. + self.sandbox.typed_in(seconds_ago=5) + + def env(self, **overrides: str) -> dict[str, str]: + return { + "XDG_RUNTIME_DIR": str(self.fake.runtime), + "CLAUDE_CONFIG_DIR": str(self.fake.config), + "SESSION_KIT_NOTICE_TIMEOUT": "2", + **overrides, + } + + def test_an_attached_session_receives_it_over_its_own_socket(self) -> None: + answer = self.sandbox.route(**self.env()) + self.assertTrue(answer["routed"], answer) + self.assertEqual("terminal", answer["route"]) + # And nothing was pushed to their phone while they were sitting right there. + self.assertEqual([], self.sandbox.away_lines()) + deadline = time.time() + 10 + while time.time() < deadline and not self.fake.messages(): + time.sleep(0.1) + messages = self.fake.messages() + self.assertEqual(1, len(messages), self.fake.received) + self.assertEqual(UUID, messages[0]["session_id"]) + self.assertIn("Session quiet", json.dumps(messages[0])) + + def test_a_session_that_closes_the_connection_after_the_body_still_counts( + self, + ) -> None: + """A close AFTER the body is never a refusal (the module's own rule). + + Treating it as one would send the notice to their phone a second time + while they watched it arrive in front of them. + """ + self.fake.linger = 0 + answer = self.sandbox.route(**self.env()) + self.assertEqual("terminal", answer["route"], answer) + self.assertEqual([], self.sandbox.away_lines()) + + def test_a_send_the_conversation_never_recorded_is_not_a_delivery(self) -> None: + """The lost-after-write case, and the transport says it cannot see it. + + `send()` returns delivered=False for `sent-no-receipt` and names the + target's own transcript as the receipt of record. Calling that landed + which this did, reported a message they never saw AND latched it, so + for a swept record it stayed suppressed while the record lived. + """ + self.fake.records_transcript = False + answer = self.sandbox.route(**self.env(SESSION_KIT_NOTICE_CONFIRM_SECONDS="1")) + self.assertEqual("away", answer["route"], answer) + self.assertTrue(answer["routed"]) + self.assertIn("never appeared in the conversation", answer["why"]) + # It reached them by the other road rather than being counted as sent. + self.assertEqual(1, len(self.sandbox.away_lines())) + + def test_two_attached_terminals_send_nothing_into_either(self) -> None: + self.sandbox.write_snapshot( + snapshot( + attached=[ + attached_session(title="One", uuid=UUID), + attached_session( + title="Two", uuid="00000000-0000-4000-8000-000000000def" + ), + ] + ) + ) + self.sandbox.typed_in("00000000-0000-4000-8000-000000000def", seconds_ago=5) + answer = self.sandbox.route(**self.env()) + self.assertEqual("away", answer["route"], answer) + self.assertEqual([], self.fake.messages()) + self.assertEqual(1, len(self.sandbox.away_lines())) + + def test_a_session_with_its_own_question_open_is_not_interrupted(self) -> None: + """A delivery over an open picker pushed the question off their screen + once already. A notice is exactly the thing that can wait.""" + self.sandbox.write_snapshot( + snapshot(attached=[attached_session(needs_you=True)]) + ) + answer = self.sandbox.route(**self.env()) + self.assertTrue(answer["routed"], answer) + self.assertEqual("away", answer["route"]) + self.assertEqual([], self.fake.messages()) + self.assertEqual(1, len(self.sandbox.away_lines())) + + def test_a_provider_this_repository_cannot_address_falls_through(self) -> None: + """Codex sessions are steered from outside this repository. Naming that + is the point: the notice goes away rather than nowhere.""" + self.sandbox.write_snapshot( + snapshot(attached=[attached_session(provider="codex")]) + ) + answer = self.sandbox.route(**self.env()) + self.assertTrue(answer["routed"], answer) + self.assertEqual("away", answer["route"]) + self.assertIn("cannot be addressed here", answer["why"]) + + def test_an_unreachable_session_falls_through_rather_than_vanishing(self) -> None: + self.fake.path.unlink() + answer = self.sandbox.route(**self.env()) + self.assertTrue(answer["routed"], answer) + self.assertEqual("away", answer["route"]) + self.assertEqual(1, len(self.sandbox.away_lines())) + + def test_the_terminal_route_can_be_switched_off(self) -> None: + answer = self.sandbox.route(**self.env(SESSION_KIT_NOTICE_TERMINAL="off")) + self.assertEqual("away", answer["route"]) + self.assertEqual([], self.fake.messages()) + + +class WatchdogWiringTests(unittest.TestCase): + """The watchdog's own alerts take the same route, and its sweep feeds it.""" + + def fixture(self, **kwargs) -> WatchdogFixture: + made = WatchdogFixture(**kwargs) + self.addCleanup(made.close) + made.notice_log = made.base / "notice-calls.log" + made.notice = made.bin / "notice" + write_executable( + made.notice, + f"""#!/usr/bin/env bash +printf '%s\\n' "$*" >> {str(made.notice_log)!r} +echo '{{"routed": true, "route": "away"}}' +""", + ) + return made + + def notice_calls(self, made: WatchdogFixture) -> list[str]: + if not made.notice_log.exists(): + return [] + return [ + line + for line in made.notice_log.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + def test_the_alert_path_is_left_exactly_as_it_was(self) -> None: + """`notify()` is not a route to them and this branch does not touch it. + + Its notifier is an alert board with a 7-day retention, and granting it + push would mean claiming an emergency policy id, an alerting decision + that belongs to the operator, not to this branch. The router is a + SEPARATE road; `notify()` stays byte-identical to what it was. + """ + source = (REPO / "bin" / "session_kit_watchdog").read_text(encoding="utf-8") + body = source[source.index("\nnotify() {") : source.index("\nflagged_events()")] + self.assertNotIn("session_kit_notice", body) + self.assertNotIn("NOTICE_CMD", body) + self.assertNotIn("route_notice", source) + + def test_a_manager_alert_is_not_diverted_through_the_router(self) -> None: + made = self.fixture(sessions=[session()]) + write_executable( + made.shpool, + """#!/usr/bin/env bash +if [ "$1" = list ]; then sleep 300; fi +exit 0 +""", + ) + made.run( + SESSION_KIT_NOTICE_CMD=str(made.notice), + SESSION_KIT_WATCHDOG_MANAGER_TIMEOUT="2", + ) + self.assertIn("manager-stuck", made.log_text()) + # The router saw one call, and it was the record sweep -- not the alert. + calls = self.notice_calls(made) + self.assertEqual(["sweep"], calls) + + def test_every_run_sweeps_the_records_the_picker_no_longer_shows(self) -> None: + made = self.fixture(sessions=[session()]) + made.run(SESSION_KIT_NOTICE_CMD=str(made.notice)) + self.assertIn("sweep", " ".join(self.notice_calls(made))) + + def test_an_idle_pass_writes_no_notice_line(self) -> None: + """Against the REAL router, because a stub is how this got shipped. + + The suppression test read `"routed": 0` followed by `"held": 0`, and + the router sorts its keys, so the quiet case could never match: every + pass of a 60-second watchdog wrote a line saying nothing had happened, + 1,440 a day. The branch's own test used a stub whose JSON omitted + `held`, so it never exercised the branch it was guarding. + """ + # Recent output, so the watchdog's own sweep records nothing and the + # notice sweep genuinely has nothing to say. + made = self.fixture(sessions=[session(recent_output_age_seconds=30)]) + made.run( + SESSION_KIT_NOTICE_CMD=str(REPO / "bin" / "session_kit_notice"), + SESSION_KIT_FLEET_DIR=str(made.base / "fleet"), + SESSION_KIT_NOTICE_AWAY_CMD="", + ) + self.assertNotIn("notice sweep", made.log_text()) + + def test_a_pass_with_something_to_say_does_write_one(self) -> None: + made = self.fixture(sessions=[session()]) + made.repairs.write_text( + json.dumps( + { + "schema_version": 1, + "repairs": [ + { + "at_unix_ms": int(time.time() * 1000), + "old_shpool_id": "s1", "new_shpool_id": "", + "title": "Some work", "provider": "codex", + "outcome": "reported", "acknowledged": False, + "reason": "quiet", + } + ], + } + ), + encoding="utf-8", + ) + made.run( + SESSION_KIT_NOTICE_CMD=str(REPO / "bin" / "session_kit_notice"), + SESSION_KIT_FLEET_DIR=str(made.base / "fleet"), + SESSION_KIT_NOTICE_AWAY_CMD="", + ) + # And with no away transport it says the loud thing, not the quiet one. + self.assertIn("notice sweep DID NOT REACH THEM", made.log_text()) + + def test_the_watchdog_sentinel_silences_the_router_too(self) -> None: + made = self.fixture(sessions=[session()]) + made.sentinel.write_text("", encoding="utf-8") + made.run(SESSION_KIT_NOTICE_CMD=str(made.notice)) + self.assertEqual([], self.notice_calls(made)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_operator_drill.py b/tests/test_operator_drill.py new file mode 100644 index 0000000..45d026a --- /dev/null +++ b/tests/test_operator_drill.py @@ -0,0 +1,238 @@ +"""The operator drill: isolated by construction, and the husk rides in it. + +Two things are proved here, and they are the reasons the drill exists. + +*Isolation.* The drill builds a sandbox with its own HOME, state directory and +a stub `shpool` that starts no daemon. A fixture daemon can answer its own +client with names that must never be combined with the operator daemon's +process tree, so the drill asserts every path it hands the kit is a fixture +path and refuses itself otherwise. These tests drive that refusal directly. + +*The husk.* `IFS=$'\t' read` collapses a run of tabs and drops the empty fields +between them. The launch record's launch key is empty for every `sp new +--model` without `--launch-key`, so the generation fields shifted one place +left and the launch was refused as mismatched. Three sessions in one night +became shells with no provider. The last test here puts the bug back into a +throwaway copy of the release and proves the drill fails on it. +""" + +from __future__ import annotations + +import importlib.machinery +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +from tests.support import REPO + + +DRILL = REPO / "tools" / "operator-drill" + + +def load_drill(): + """Import the drill, which ships without a .py suffix.""" + spec = importlib.util.spec_from_loader( + "operator_drill", + importlib.machinery.SourceFileLoader("operator_drill", os.fspath(DRILL)), + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class DrillIsolationTests(unittest.TestCase): + """The fence, driven from both sides.""" + + def setUp(self) -> None: + self.drill = load_drill() + self.temporary = tempfile.TemporaryDirectory(prefix="session-kit-drill-test.") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + (self.root / self.drill.SANDBOX_MARK).touch() + self.binaries = Path(self.drill.write_stub(os.fspath(self.root))) + self.stub_state = self.root / "stub" + self.stub_state.mkdir() + for name in ("home", "state", "state/shpool-start", "run"): + (self.root / name).mkdir(exist_ok=True) + + def environment(self, **overrides) -> dict: + base = { + "HOME": os.fspath(self.root / "home"), + "XDG_STATE_HOME": os.fspath(self.root / "state"), + "XDG_RUNTIME_DIR": os.fspath(self.root / "run"), + "SESSION_KIT_STATE_DIR": os.fspath(self.root / "state"), + "SESSION_KIT_START_DIR": os.fspath(self.root / "state/shpool-start"), + "SESSION_KIT_JOURNAL_DIR": os.fspath(self.root / "state/journal"), + "SESSION_KIT_SHPOOL_CMD": os.fspath(self.binaries / "shpool"), + "SHPOOL_SOCKET": os.fspath(self.stub_state / "shpool.socket"), + "DRILL_SANDBOX": os.fspath(self.root), + "DRILL_STUB_STATE": os.fspath(self.stub_state), + "PATH": os.fspath(self.binaries) + ":/usr/bin:/bin", + } + base.update(overrides) + return base + + def test_a_fixture_environment_is_accepted(self) -> None: + evidence = self.drill.assert_isolated(os.fspath(self.root), self.environment()) + self.assertTrue(any("stub" in line for line in evidence)) + + def test_a_home_outside_the_sandbox_is_refused(self) -> None: + with self.assertRaises(self.drill.IsolationError) as refusal: + self.drill.assert_isolated( + os.fspath(self.root), self.environment(HOME=os.path.expanduser("~")) + ) + self.assertIn("HOME", str(refusal.exception)) + + def test_a_socket_path_outside_the_sandbox_is_refused(self) -> None: + with self.assertRaises(self.drill.IsolationError) as refusal: + self.drill.assert_isolated( + os.fspath(self.root), + self.environment( + SHPOOL_SOCKET=os.path.expanduser("~/.local/state/shpool.socket") + ), + ) + self.assertIn("SHPOOL_SOCKET", str(refusal.exception)) + + def test_a_state_directory_outside_the_sandbox_is_refused(self) -> None: + with self.assertRaises(self.drill.IsolationError) as refusal: + self.drill.assert_isolated( + os.fspath(self.root), + self.environment(SESSION_KIT_STATE_DIR="/tmp"), + ) + self.assertIn("SESSION_KIT_STATE_DIR", str(refusal.exception)) + + def test_a_real_shpool_on_the_path_is_refused(self) -> None: + """The stub must be the only `shpool` the drill can reach.""" + elsewhere = self.root / "elsewhere" + elsewhere.mkdir() + impostor = elsewhere / "shpool" + impostor.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + impostor.chmod(0o700) + with self.assertRaises(self.drill.IsolationError) as refusal: + self.drill.assert_isolated( + os.fspath(self.root), + self.environment(PATH=f"{elsewhere}:{self.binaries}:/usr/bin:/bin"), + ) + self.assertIn("not the stub", str(refusal.exception)) + + def test_an_unmarked_sandbox_is_refused(self) -> None: + (self.root / self.drill.SANDBOX_MARK).unlink() + with self.assertRaises(self.drill.IsolationError) as refusal: + self.drill.assert_isolated(os.fspath(self.root), self.environment()) + self.assertIn("marker", str(refusal.exception)) + + def test_the_stub_refuses_to_start_a_daemon(self) -> None: + refused = subprocess.run( + [os.fspath(self.binaries / "shpool"), "daemon"], + env={**os.environ, **self.environment()}, + capture_output=True, + text=True, + ) + self.assertEqual(3, refused.returncode) + self.assertIn("never starts a daemon", refused.stderr) + self.assertFalse((self.stub_state / "shpool.socket").exists()) + + def test_the_stub_refuses_a_state_directory_outside_its_sandbox(self) -> None: + outside = self.root.parent / "outside-stub-state" + outside.mkdir(exist_ok=True) + self.addCleanup(lambda: outside.rmdir() if outside.exists() else None) + refused = subprocess.run( + [os.fspath(self.binaries / "shpool"), "list"], + env={ + **os.environ, + **self.environment(DRILL_STUB_STATE=os.fspath(outside)), + }, + capture_output=True, + text=True, + ) + self.assertEqual(3, refused.returncode) + self.assertIn("outside the sandbox", refused.stderr) + + +class DrillRunTests(unittest.TestCase): + """The drill itself, run the way the release gate runs it.""" + + def drive(self, *arguments: str, release: Path | None = None): + completed = subprocess.run( + [ + os.fspath(DRILL), + "--json", + "--release", + os.fspath(release or REPO), + *arguments, + ], + capture_output=True, + text=True, + ) + payload = json.loads(completed.stdout) if completed.stdout.strip() else {} + return completed, payload + + @unittest.skipUnless( + os.path.exists("/proc/self/stat"), "the launch path reads process generations" + ) + def test_every_stub_step_passes_against_this_tree(self) -> None: + completed, payload = self.drive() + verdicts = {step["step"]: step["verdict"] for step in payload.get("steps", [])} + self.assertEqual( + 0, completed.returncode, f"{verdicts}\n{completed.stdout}\n{completed.stderr}" + ) + self.assertEqual("PASS", verdicts.get("0 isolation")) + self.assertEqual("PASS", verdicts.get("1 launch")) + self.assertEqual("PASS", verdicts.get("2 model launch")) + self.assertEqual("PASS", verdicts.get("3 stale record")) + self.assertEqual("PASS", verdicts.get("4 no daemon")) + self.assertFalse(payload["failed"]) + + @unittest.skipUnless( + os.path.exists("/proc/self/stat"), "the launch path reads process generations" + ) + def test_the_run_names_no_path_of_the_operators(self) -> None: + _, payload = self.drive("--steps", "1") + home = os.path.realpath(os.path.expanduser("~")) + rendered = json.dumps(payload) + self.assertNotIn(f'"{home}', rendered) + self.assertNotIn(f"{home}/.local/state/session-kit", rendered) + + def test_an_unknown_step_is_a_usage_error(self) -> None: + completed, _ = self.drive("--steps", "9") + self.assertEqual(2, completed.returncode) + self.assertIn("no step 9", completed.stderr) + + @unittest.skipUnless( + os.path.exists("/proc/self/stat"), "the launch path reads process generations" + ) + def test_the_drill_fails_when_the_tab_collapse_husk_returns(self) -> None: + """Put the bug back; the drill must refuse to call the release good. + + A regression case that cannot fail is decoration. This rewrites the + launch record's read back to the collapsing form in a throwaway copy of + the release, and expects step 2 to fail on it. + """ + source = (REPO / "bashrc" / "shpool.bashrc").read_text(encoding="utf-8") + translation = "__sk_launch_line=${__sk_launch_line//$'\\t'/$'\\034'}\n" + read = "IFS=$'\\034' read -r __sk_launch_provider __sk_launch_cwd" + self.assertIn(translation, source, "the launch record's \\034 translation moved") + self.assertIn(read, source, "the launch record's read moved") + husked = source.replace(translation, "", 1).replace( + read, "IFS=$'\\t' read -r __sk_launch_provider __sk_launch_cwd", 1 + ) + + with tempfile.TemporaryDirectory(prefix="session-kit-husk.") as scratch: + release = Path(scratch) / "release" + (release / "bashrc").mkdir(parents=True) + (release / "bashrc" / "shpool.bashrc").write_text(husked, encoding="utf-8") + (release / "lib").symlink_to(REPO / "lib") + completed, payload = self.drive("--steps", "1,2", release=release) + + verdicts = {step["step"]: step["verdict"] for step in payload.get("steps", [])} + self.assertEqual("PASS", verdicts.get("1 launch"), completed.stdout) + self.assertEqual("FAIL", verdicts.get("2 model launch"), completed.stdout) + self.assertEqual(1, completed.returncode) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_picker_attention_truth.py b/tests/test_picker_attention_truth.py new file mode 100644 index 0000000..3224009 --- /dev/null +++ b/tests/test_picker_attention_truth.py @@ -0,0 +1,410 @@ +"""What needs you is a fact about the estate, not about the current view. + +Three ways the attention count used to disagree with reality, all proven on a +pseudo-terminal before they were fixed: + + * a search filter, including the preview drawn while the line was still + being typed, emptied the count, and the Needs-you screen answered + "Nothing needs you." while a session waited; + * the home summary line de-duplicated a session against its own stall flag + and the Needs-you screen did not, so the two screens printed different + totals for the same estate. That summary line is gone (operator ruling, + 2026-08-15), so every count here is now asserted where the truth lives -- + on the Needs-you screen itself, which is the surface a person acts from; + * a session whose shell generation could not be proven was dropped before + any counter saw it, so the row most likely to need attention was the one + row guaranteed not to be mentioned. +""" + +from __future__ import annotations + +import json +import time +import unittest + +from tests.test_login import LoginFixture, inventory, row, run_pty + + +def waiting(shpool_id: str, *, number: int, provider: str = "codex") -> dict: + return row(shpool_id, number=number, provider=provider, needs_you=True) + + +def working(shpool_id: str, *, number: int, provider: str = "codex") -> dict: + """A turn the model is driving, which is nobody's turn to answer.""" + + item = row(shpool_id, number=number, provider=provider, needs_you=False) + item["agent_status"] = "working" + return item + + +def quarantined(shpool_id: str, *, provider: str = "claude") -> dict: + """A real, designed state: the shell generation could not be proven.""" + item = row(shpool_id, number=99, provider=provider, needs_you=True) + item["terminal_number"] = None + item["mutation_allowed"] = False + item["mutation_rejection_reason"] = "unprovable-generation" + item["recent_output_at_unix_ms"] = 1 + return item + + +def write_fleet(fixture: LoginFixture, *, stalls: list[dict]) -> None: + fleet = fixture.home / ".local" / "state" / "fleet" + fleet.mkdir(parents=True, exist_ok=True) + (fleet / "inbox").mkdir(exist_ok=True) + (fleet / "stalls.json").write_text( + json.dumps({"generated_at": 4_000_000_000, "stalled": stalls}), + encoding="utf-8", + ) + + +class AttentionSummaryLineTests(unittest.TestCase): + """The home summary line is gone (operator ruling, 2026-08-15). + + It read `needs you: 56 · 6 sessions, 50 repair failures` on their screen. The + 50 were watchdog records that had never described a failed repair, so the + loudest number on the login screen was the least true thing on it. They chose + removal over a trimmed version. What a person can ACT on is unchanged: the + rows still say "needs you" and the `a` screen still lists every item. + """ + + def test_the_home_screen_carries_no_attention_summary_line(self) -> None: + fixture = LoginFixture( + inventory(waiting("alpha", number=3), row("bravo", number=17)) + ) + try: + code, output = run_pty(fixture, b"q\n", columns=120) + self.assertEqual(0, code) + # No summary line, at any count. + self.assertNotIn("needs you: 1 ·", output) + self.assertNotIn("needs you: 2 ·", output) + self.assertNotIn("repair failure", output) + # The row itself still says it, and the footer still offers the + # screen that lists it. + self.assertIn("needs you", output) + self.assertIn("needs you a", output) + finally: + fixture.close() + + def test_the_line_is_gone_even_with_unresolved_watchdog_records(self) -> None: + """The exact shape that produced their 56: sessions plus records.""" + fixture = LoginFixture(inventory(waiting("alpha", number=3))) + (fixture.state / "watchdog-repairs.json").write_text( + json.dumps( + { + "schema_version": 1, + "repairs": [ + { + "at_unix_ms": 1, + "old_shpool_id": f"gone-{index}", + "new_shpool_id": "", + "title": f"Worker {index}", + "provider": "codex", + "outcome": "reported", + "reason": "no output for far longer than any normal pause", + "acknowledged": False, + } + for index in range(3) + ], + } + ), + encoding="utf-8", + ) + try: + code, output = run_pty(fixture, b"a\nq\nq\n", columns=120) + self.assertEqual(0, code) + self.assertNotIn("needs you: 4 ·", output) + self.assertNotIn("3 repair failures", output) + # The review screen still shows every record, and now says what + # actually happened rather than calling all three failed repairs. + review = output[output.index("needs you: 4\n") :] + self.assertIn("Watchdog reports", review) + self.assertIn("reported quiet, no repair attempted", review) + self.assertNotIn("automatic repair failed", review) + finally: + fixture.close() + + +class AttentionCountTests(unittest.TestCase): + def test_a_filter_never_empties_the_attention_count(self) -> None: + fixture = LoginFixture( + inventory( + waiting("alpha", number=3), + row("bravo", number=17), + ) + ) + try: + # Filter to the row that is NOT waiting, then open the review. + code, output = run_pty(fixture, b"/bravo\na\nq\nq\n", columns=120) + self.assertEqual(0, code) + filtered = output[output.index("Search: bravo") :] + # The review survives the filter: the waiting session is still + # counted and still listed, though the filter hides its row. + self.assertNotIn("Nothing needs you.", filtered) + self.assertIn("needs you: 1\n", filtered) + self.assertIn("Codex alpha", filtered[filtered.index("needs you: 1\n") :]) + finally: + fixture.close() + + def test_the_home_line_and_the_review_report_the_same_total(self) -> None: + session = waiting("alpha", number=3, provider="claude") + uuid = session["identity"]["uuid"] + fixture = LoginFixture(inventory(session)) + write_fleet( + fixture, + stalls=[{"key": uuid, "reason": "unsurfaced", "since": 4_000_000_000}], + ) + try: + code, output = run_pty(fixture, b"a\nq\nq\n", columns=120) + self.assertEqual(0, code) + # One session, one fact, one number: the stall flag and the row + # are the same session and are counted once. + self.assertIn("needs you: 1\n", output) + review = output[output.index("needs you: 1\n") :] + self.assertNotIn("Stalled ·", review) + self.assertIn("Claude alpha", review) + finally: + fixture.close() + + def test_a_stall_flag_for_a_session_the_filter_hides_is_still_its_row( + self, + ) -> None: + session = waiting("alpha", number=3, provider="claude") + uuid = session["identity"]["uuid"] + fixture = LoginFixture(inventory(session, row("bravo", number=17))) + write_fleet( + fixture, + stalls=[{"key": uuid, "reason": "unsurfaced", "since": 4_000_000_000}], + ) + try: + # Filter to the OTHER row, then open the review behind the filter. + code, output = run_pty(fixture, b"/bravo\na\nq\nq\n", columns=120) + self.assertEqual(0, code) + filtered = output[output.index("Search: bravo") :] + # Re-filing the same session as a stalled session was the defect: + # the category moved because the row was hidden. + self.assertIn("needs you: 1\n", filtered) + self.assertNotIn("Stalled ·", filtered[filtered.index("needs you: 1\n") :]) + self.assertIn("Claude alpha", filtered[filtered.index("needs you: 1\n") :]) + finally: + fixture.close() + + def test_a_quarantined_session_that_needs_you_still_surfaces(self) -> None: + fixture = LoginFixture( + inventory(waiting("alpha", number=3), quarantined("bravo")) + ) + try: + code, output = run_pty(fixture, b"a\nq\nq\n", columns=120) + self.assertEqual(0, code) + # Two sessions need the operator, and the review says two. + review = output[output.index("needs you: 2\n") :] + self.assertIn("Claude bravo", review) + # It is listed as unactionable rather than offered as a number. + self.assertIn("-- Claude bravo", review) + self.assertIn("unavailable", review) + self.assertIn("needs you since", review) + self.assertIn("sp help unavailable", review) + finally: + fixture.close() + + def test_a_quarantined_setup_row_keeps_setup_age_and_unavailable(self) -> None: + item = quarantined("setup") + item["setup_incomplete"] = True + item["recent_output_at_unix_ms"] = int((time.time() - 7200) * 1000) + fixture = LoginFixture(inventory(item)) + try: + code, output = run_pty(fixture, b"a\nq\nq\n", columns=120) + self.assertEqual(0, code) + review = output[output.index("needs you: 1\n") :] + self.assertIn("pending", review) + self.assertIn("needs you 2 hr", review) + self.assertIn("unavailable", review) + finally: + fixture.close() + + +class ViewSurvivesAnActionTests(unittest.TestCase): + """The filter, the page and the jump marker belong to the person.""" + + def test_a_close_keeps_the_search_the_person_was_reading(self) -> None: + first = row("bravo-one", number=17, provider="codex") + second = row("bravo-two", number=18, provider="codex") + fixture = LoginFixture( + inventory(row("alpha", number=3), first, second), + # The refresh after the action reports the estate without 17. + refreshed_document=inventory(row("alpha", number=3), second), + ) + try: + code, output = run_pty(fixture, b"/bravo\nk 17\nq\n", columns=120) + self.assertEqual(0, code) + # The frame the close drew: one match of the two that are left, + # still filtered. Before the fix this frame was page 1 of the + # whole estate with no filter and no notice. + after = [ + frame + for frame in output.split("\x1b[2J") + if "1 match of 2 sessions" in frame + ] + self.assertTrue(after, msg=output) + self.assertIn("Search: bravo", after[0]) + self.assertNotIn("Codex alpha", after[0]) + finally: + fixture.close() + + def test_the_r_key_still_clears_the_filter(self) -> None: + fixture = LoginFixture( + inventory(row("alpha", number=3), row("bravo", number=17)) + ) + try: + code, output = run_pty(fixture, b"/bravo\nr\nq\n", columns=120) + self.assertEqual(0, code) + refreshed = output[output.rindex("2 sessions") :] + self.assertNotIn("Search:", refreshed) + finally: + fixture.close() + + +class ActionReceiptTests(unittest.TestCase): + """Every action says what it did, and says it before the refresh.""" + + def test_a_close_names_what_it_closed(self) -> None: + fixture = LoginFixture( + inventory(row("alpha", number=3), row("bravo", number=17)), + refreshed_document=inventory(row("alpha", number=3)), + ) + try: + code, output = run_pty(fixture, b"k 17\nq\n") + self.assertEqual(0, code) + self.assertIn("Closed session 17.", output) + finally: + fixture.close() + + def test_a_close_says_so_even_when_the_refresh_fails(self) -> None: + """The receipt cannot depend on the refresh that follows it. + + A failed refresh prints "Showing the last confirmed list" -- and that + list still holds the session just closed, with nothing on screen saying + it was closed. The operator's only reasonable reading was that the close + had not happened. + """ + fixture = LoginFixture(inventory(row("alpha", number=3), row("bravo", number=17))) + broken = fixture.base / "one-shot-status" + broken.write_text( + "#!/usr/bin/env bash\n" + f"count={fixture.base / 'status-calls'}\n" + 'printf x >> "$count"\n' + 'if [[ ${1:-} == --json && $(wc -c < "$count") -gt 1 ]]; then exit 1; fi\n' + f"exec {fixture.fake_status} \"$@\"\n", + encoding="utf-8", + ) + broken.chmod(0o755) + try: + code, output = run_pty( + fixture, + b"k 17\nq\n", + env_updates={"SESSION_KIT_STATUS_CMD": str(broken)}, + ) + self.assertEqual(0, code) + self.assertIn("Closed session 17.", output) + self.assertIn("Live refresh failed", output) + # The receipt comes first: the operator reads what happened, then + # why the list behind it is the old one. + self.assertLess( + output.index("Closed session 17."), + output.index("Live refresh failed"), + ) + finally: + fixture.close() + + def test_history_refuses_a_number_that_is_not_on_the_screen(self) -> None: + sessions = [row(f"task-{number}", number=number) for number in range(1, 13)] + fixture = LoginFixture(inventory(*sessions)) + try: + # 12 rows on a 14-line terminal is two pages; every other row key + # already refuses an off-page number, and history did not. + code, output = run_pty(fixture, b"h 12\nq\n", lines=14) + self.assertEqual(0, code) + self.assertIn("There is no session 12 on this screen", output) + self.assertEqual( + [], + [ + entry + for entry in fixture.sp_entries() + if entry.get("args", [""])[0] == "picker-history" + ], + ) + finally: + fixture.close() + + + + +class TheScreenAgreesWithTheListTests(unittest.TestCase): + """One definition of a waiting session, on both surfaces. + + The 2026-08-15 ruling is that a finished provider turn is `needs you` + however the vendor spells it, and it lives in labels.STATE_WORDS: a Claude + session parked at its prompt raises the vendor's own notification and a + Codex session parked at exactly the same prompt writes `task_complete`, so + both map to the one word. The list reads that word through + labels.session_state. This screen used to read the raw `needs_you` and + `blocking_question` flags instead, which the ruling never touched, so a + normal finished Codex turn printed `needs you` on the list and was absent + here -- six of nine sessions on one live estate. + """ + + def finished_turn(self, shpool_id: str, *, number: int) -> dict: + """A provider turn that ended normally: the shape the collector writes. + + `agent_status: idle` with `needs_you: false` is what collector.py + produces for a Codex rollout ending in `task_complete`, and it is the + ordinary resting state of a session waiting on a person. + """ + + item = row(shpool_id, number=number, provider="codex", needs_you=False) + item["agent_status"] = "idle" + return item + + def test_a_finished_turn_is_on_the_screen_that_names_who_is_waiting( + self, + ) -> None: + fixture = LoginFixture(inventory(self.finished_turn("alpha", number=4))) + try: + code, output = run_pty(fixture, b"a\nq\nq\n", columns=120) + self.assertEqual(0, code) + self.assertIn("needs you: 1", output) + self.assertNotIn("Nothing needs you.", output) + finally: + fixture.close() + + def test_both_surfaces_count_the_same_sessions(self) -> None: + """The list's own state words are the count this screen must match.""" + + fixture = LoginFixture( + inventory( + self.finished_turn("alpha", number=4), + self.finished_turn("bravo", number=5), + row("charlie", number=6, provider="claude", needs_you=True), + working("delta", number=7), + ) + ) + try: + code, output = run_pty(fixture, b"a\nq\nq\n", columns=120) + self.assertEqual(0, code) + self.assertIn("needs you: 3", output) + finally: + fixture.close() + + def test_a_working_session_is_not_called_waiting(self) -> None: + """The superset must not have swallowed the whole estate.""" + + fixture = LoginFixture(inventory(working("delta", number=7))) + try: + code, output = run_pty(fixture, b"a\nq\nq\n", columns=120) + self.assertEqual(0, code) + self.assertIn("Nothing needs you.", output) + finally: + fixture.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_picker_events.py b/tests/test_picker_events.py new file mode 100644 index 0000000..de343c1 --- /dev/null +++ b/tests/test_picker_events.py @@ -0,0 +1,1036 @@ +"""The picker learns from the daemon's push events, and survives losing them. + +The list used to be re-collected every five seconds whether or not anything +had happened -- a whole status collection, in the background, forever, on an +estate that changes a few times an hour. The daemon publishes exactly the four +changes that move the list (created, attached, detached, removed; EVENTS.md), +so the picker subscribes and the timed collection becomes the net under a +stream rather than the way the picker learns. + +Everything here is about what happens when that stream is NOT there: + + * the daemon drops subscribers that fall behind and never replays what they + missed, so a stream that ends has to be answered by a full collection and a + new subscription -- treating the silence as "nothing changed" is exactly + the staleness this replaces; + * a shpool without the subcommand, and an operator who turns events off, both + have to land on the five-second poll that shipped before this, unchanged; + * a subscription that cannot be kept has to stop trying and say so, rather + than restarting a failing child under a person's prompt forever. + +The stub shpool below is the whole event source: `events --help` answers the +capability probe, `events` prints event lines on a schedule and then behaves +the way the real daemon does with a subscriber it keeps. +""" + +from __future__ import annotations + +import errno +import json +import os +from pathlib import Path +import pty +import select +import shlex +import shutil +import signal +import subprocess +import tempfile +import threading +import time +import unittest + +from tests.test_login import ( + LOGIN, + REPO, + LoginFixture, + inventory, + row, + run_pty, + write_executable, +) + + +HELP_OK_STREAM = """#!/usr/bin/env bash +if [[ ${1:-} == events ]]; then + if [[ ${2:-} == --help ]]; then + echo "Subscribe to the daemon's push-event stream" + exit 0 + fi + # A real subscription: lines arrive when the table moves, and the stream + # stays open in between. `exec` so the process the picker kills IS this one. + printf '{"type":"session.created"}\\n' + sleep 2.2 + printf '{"type":"session.removed"}\\n' + exec sleep 30 +fi +exit 0 +""" + +HELP_OK_STREAM_DIES = """#!/usr/bin/env bash +if [[ ${1:-} == events ]]; then + if [[ ${2:-} == --help ]]; then + echo "Subscribe to the daemon's push-event stream" + exit 0 + fi + # The daemon dropped this subscriber. No line, no replay, stream over. + exit 0 +fi +exit 0 +""" + +NO_EVENTS_SUBCOMMAND = """#!/usr/bin/env bash +if [[ ${1:-} == events ]]; then + echo "error: unrecognized subcommand 'events'" >&2 + exit 2 +fi +exit 0 +""" + + +def two_rows() -> dict: + return inventory( + row("s20260813-000001-1", number=1, provider="claude"), + row("s20260813-000002-2", number=2, provider="codex"), + ) + + +def picker_actions(fixture: LoginFixture, action: str) -> list[str]: + log = fixture.state / "action-events.jsonl" + if not log.exists(): + return [] + outcomes = [] + for line in log.read_text(encoding="utf-8").splitlines(): + try: + record = json.loads(line) + except ValueError: + continue + if record.get("action") == action: + outcomes.append(record.get("outcome")) + return outcomes + + +def collections(fixture: LoginFixture) -> int: + if not fixture.status_log.exists(): + return 0 + return sum(1 for entry in fixture.status_entries() if entry == ["--json"]) + + +def run_picker_with( + fixture: LoginFixture, + *, + seconds: float, + extra: dict[str, str], + during=None, + case: unittest.TestCase | None = None, +) -> str: + """Hold the picker open, optionally doing something part-way through.""" + environment = { + "SESSION_KIT_PICKER_REFRESH_SECONDS": "30", + "SESSION_KIT_PICKER_EVENT_POLL_SECONDS": "300", + "SESSION_KIT_PICKER_EVENTS": "auto", + } + environment.update(extra) + if during is not None: + timer = threading.Timer(1.6, during) + timer.daemon = True + timer.start() + if case is not None: + case.addCleanup(timer.cancel) + _, output = run_pty( + fixture, + deferred=("❯ ", b"q\n"), + deferred_delay_seconds=seconds, + env_updates=environment, + ) + return output + + +SLOW_STREAM = """#!/usr/bin/env bash +if [[ ${1:-} == events ]]; then + if [[ ${2:-} == --help ]]; then + echo "Subscribe to the daemon's push-event stream" + exit 0 + fi + exec sleep 30 +fi +exit 0 +""" + +# A collection that takes real time, so an event can land in the middle of one. +SLOW_STATUS = """#!/usr/bin/env python3 +import json,os,pathlib,sys,time +args=sys.argv[1:] +with pathlib.Path(os.environ["LOGIN_STATUS_LOG"]).open("a") as log: + log.write(json.dumps(args)+"\\n") +if args == ["--json"]: + counter=pathlib.Path(os.environ["LOGIN_SNAPSHOT_COUNT"]) + try: count=int(counter.read_text()) + except (OSError,ValueError): count=0 + count += 1 + counter.write_text(str(count)) + time.sleep(float(os.environ.get("LOGIN_SLOW_SECONDS","1.5"))) + source=( + pathlib.Path(os.environ["LOGIN_REFRESHED_INVENTORY"]) + if count > 1 + else pathlib.Path(os.environ["LOGIN_INVENTORY"]) + ) + print(source.read_text(),end="") +elif args == ["--recovery-pending-list"]: + print(pathlib.Path(os.environ["LOGIN_PENDING"]).read_text(),end="") +elif len(args) == 2 and args[0] == "--recovery-remember-printed": + # Records what the screen is about to print; prints nothing itself. + raise SystemExit(0) +else: + raise SystemExit(2) +""" + + +class AttentionLatencyTests(unittest.TestCase): + """The bound this whole item is allowed to exist under. + + The daemon's stream cannot carry "a session is waiting for a person" -- no + create, no attach, no detach, no remove -- proven live: 25 s of + `shpool events` over eleven busy sessions, zero lines. So widening the poll + on the strength of that stream alone would make the one column an operator + watches six times slower. Two rules keep the old bound: + + * the poll only widens while the attention watcher is ALSO live, and + * an attention change is a pushed reason to collect, so it arrives in + about a second rather than at the end of a poll window. + """ + + def setUp(self) -> None: + self.fixture = LoginFixture(two_rows()) + self.addCleanup(self.fixture.close) + + def test_without_the_attention_watcher_the_poll_never_widens(self) -> None: + """The guarantee, stated as the picker states it.""" + write_executable(self.fixture.fake_shpool, SLOW_STREAM) + _, output = run_pty( + self.fixture, + deferred=("❯ ", b"q\n"), + deferred_delay_seconds=3.4, + env_updates={ + "SESSION_KIT_PICKER_EVENTS": "auto", + "SESSION_KIT_PICKER_PULSE": "0", + "SESSION_KIT_PICKER_REFRESH_SECONDS": "2", + "SESSION_KIT_PICKER_EVENT_POLL_SECONDS": "300", + }, + ) + # With the pulse off, the 300 s widened setting must be ignored + # entirely: boot, the subscribe resync, and the 2 s poll still running. + self.assertGreaterEqual( + collections(self.fixture), + 3, + f"the poll widened without an attention watcher: " + f"{self.fixture.status_entries()}", + ) + + def test_an_attention_change_reaches_the_screen_inside_the_old_bound( + self, + ) -> None: + """A hook record is written; a collection must follow within ~5 s.""" + write_executable(self.fixture.fake_shpool, SLOW_STREAM) + records = self.fixture.state / "attention" / "claude" + marks: list[float] = [] + + def raise_a_hand() -> None: + records.mkdir(parents=True, exist_ok=True) + marks.append(time.monotonic()) + (records / "00000000-0000-4000-8000-000000000001.json").write_text( + json.dumps( + { + "schema_version": 1, + "session_id": "00000000-0000-4000-8000-000000000001", + "hook_event": "Notification", + "notification_type": "idle_prompt", + "message": "", + "needs_you": True, + "recorded_at_ms": 1, + } + ), + encoding="utf-8", + ) + + before = collections(self.fixture) + started = time.monotonic() + run_picker_with( + self.fixture, + case=self, + seconds=6.0, + extra={ + "SESSION_KIT_PICKER_REFRESH_SECONDS": "30", + "SESSION_KIT_PICKER_EVENT_POLL_SECONDS": "300", + "SESSION_KIT_PICKER_PULSE_SECONDS": "0.5", + }, + during=raise_a_hand, + ) + self.assertTrue(marks, "the hand was never raised") + # Boot + subscribe resync are the only collections the setup explains; + # the poll is 30/300 s away. Anything beyond them came from the pulse, + # and it happened inside the window this test ran in. + self.assertGreaterEqual( + collections(self.fixture), + before + 3, + "an attention change did not produce a collection: " + f"{self.fixture.status_entries()}", + ) + self.assertLess(time.monotonic() - started, 12) + + def test_an_event_during_a_collection_is_answered_by_a_later_one( + self, + ) -> None: + """codex 1: a snapshot in flight may have read the table too early. + + The collection here takes 2 s and the event lands inside it, with a + whole typing-loop tick still to come before it finishes. Without + a trailing collection the change waits for the next poll -- 300 s away + in this fixture, which is why call-counting alone could not see it. + """ + write_executable(self.fixture.fake_shpool, SLOW_STREAM) + write_executable(self.fixture.fake_status, SLOW_STATUS) + events = self.fixture.base / "events-in-flight" + + def event_during_the_snapshot() -> None: + # The subscriber is a stub `sleep`; the picker reads its stream + # from the file, so writing a line there IS an event arriving. + # It has to land INSIDE the collection the subscription's own + # resync started -- that is the race being measured. + # + # The stream file appearing is not that moment. One subscription + # creates the file and starts the resync, so a write fired on the + # file alone can land a breath BEFORE the collection begins, and a + # collection that starts after an event is already the answer to + # it: nothing is deferred, no trailing collection is earned, and + # this test fails having measured nothing. That is the 2026-08-18 + # ubuntu-22.04/3.12 failure, with the window already at 12 s. + # + # The stub status appends its arguments the instant it is called + # and only then sleeps LOGIN_SLOW_SECONDS, so a second `--json` + # in the log means the resync is running and has seconds left to + # run. Wait for that, and the write is inside it by construction. + deadline = time.monotonic() + 8 + stream = None + while time.monotonic() < deadline: + if stream is None: + for path in sorted(self.fixture.state.iterdir()): + if path.name.startswith("login-events.json."): + stream = path + break + if stream is not None and collections(self.fixture) >= 2: + with stream.open("a", encoding="utf-8") as handle: + handle.write('{"type":"session.created"}\n') + events.write_text(stream.name, encoding="utf-8") + return + time.sleep(0.05) + + run_picker_with( + self.fixture, + case=self, + # Three 2 s collections have to finish inside this window; 7 s + # left one second of slack and a loaded CI runner spent it, ending + # the run with the trailing collection still in flight. The poll + # is 300 s and the refresh 30 s away, so the extra headroom cannot + # add a collection of its own. + seconds=12.0, + extra={ + "SESSION_KIT_PICKER_REFRESH_SECONDS": "30", + "SESSION_KIT_PICKER_EVENT_POLL_SECONDS": "300", + "SESSION_KIT_PICKER_PULSE": "0", + "LOGIN_SLOW_SECONDS": "2.0", + }, + during=event_during_the_snapshot, + ) + self.assertTrue(events.exists(), "the test never found the event stream") + # Boot, the subscribe resync (still running when the event lands), and + # the trailing collection the event earned. + self.assertGreaterEqual( + collections(self.fixture), + 3, + "an event that landed during a collection was swallowed: " + f"{self.fixture.status_entries()}", + ) + + +class PushEventTests(unittest.TestCase): + def setUp(self) -> None: + self.fixture = LoginFixture(two_rows()) + self.addCleanup(self.fixture.close) + + def run_picker(self, *, seconds: float, extra: dict[str, str]) -> str: + """Hold the picker open for a while, then leave it on purpose.""" + environment = { + # Far longer than this test runs: any collection beyond the two the + # subscription itself explains can only have come from an event. + "SESSION_KIT_PICKER_REFRESH_SECONDS": "30", + "SESSION_KIT_PICKER_EVENT_POLL_SECONDS": "300", + "SESSION_KIT_PICKER_EVENTS": "auto", + } + environment.update(extra) + _, output = run_pty( + self.fixture, + deferred=("❯ ", b"q\n"), + deferred_delay_seconds=seconds, + env_updates=environment, + ) + return output + + def test_an_event_collects_without_waiting_for_the_poll(self) -> None: + write_executable(self.fixture.fake_shpool, HELP_OK_STREAM) + started = time.monotonic() + self.run_picker(seconds=4.2, extra={}) + elapsed = time.monotonic() - started + self.assertLess( + elapsed, + 25, + "the test itself must finish well inside one poll window", + ) + self.assertEqual(["subscribed"], picker_actions(self.fixture, "picker_events")) + # One collection at boot, one because subscribing resyncs, and one for + # each of the two events. The poll is 30 seconds away and cannot have + # produced any of them. + self.assertGreaterEqual( + collections(self.fixture), + 3, + f"status calls: {self.fixture.status_entries()}", + ) + + def test_a_dropped_stream_resyncs_resubscribes_then_falls_back(self) -> None: + write_executable(self.fixture.fake_shpool, HELP_OK_STREAM_DIES) + self.run_picker(seconds=4.2, extra={}) + outcomes = picker_actions(self.fixture, "picker_events") + self.assertEqual("subscribed", outcomes[0]) + self.assertIn("dropped", outcomes) + self.assertIn("resubscribed_resync", outcomes) + # Two subscriptions that end immediately are a broken stream, not a + # race: the picker stops restarting it and says which mechanism it is + # running on now. + self.assertEqual("fallback_poll", outcomes[-1]) + self.assertLessEqual( + outcomes.count("subscribed"), + 2, + f"subscription restarted without a bound: {outcomes}", + ) + + def test_a_shpool_without_the_subcommand_keeps_the_timed_poll(self) -> None: + write_executable(self.fixture.fake_shpool, NO_EVENTS_SUBCOMMAND) + self.run_picker( + seconds=1.2, extra={"SESSION_KIT_PICKER_REFRESH_SECONDS": "2"} + ) + self.assertEqual( + ["unsupported"], picker_actions(self.fixture, "picker_events") + ) + self.assertEqual( + [], + [path for path in self.fixture.picker_temps() if "events" in path.name], + "no subscriber may be started when the capability probe refuses", + ) + + def test_the_kill_switch_restores_the_five_second_poll_exactly(self) -> None: + write_executable(self.fixture.fake_shpool, HELP_OK_STREAM) + self.run_picker( + seconds=2.6, + extra={ + "SESSION_KIT_PICKER_EVENTS": "0", + "SESSION_KIT_PICKER_REFRESH_SECONDS": "2", + }, + ) + self.assertEqual([], picker_actions(self.fixture, "picker_events")) + # The poll is the only mechanism left, and it still runs at its own + # cadence rather than the widened one. + self.assertGreaterEqual(collections(self.fixture), 2) + + def test_the_subscriber_process_is_gone_after_the_picker(self) -> None: + """codex 9: unlinking the files is not the same as ending the child. + + The subscriber is a long-lived process holding a descriptor on a file + the picker is about to delete. Counting files would still pass with the + kill removed, so this counts processes. + """ + write_executable(self.fixture.fake_shpool, HELP_OK_STREAM) + before = subprocess.run( + ["pgrep", "-fa", str(self.fixture.fake_shpool)], + capture_output=True, + text=True, + ).stdout + self.assertEqual("", before.strip(), "a stray stub was already running") + self.run_picker(seconds=1.6, extra={}) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + alive = subprocess.run( + ["pgrep", "-fa", str(self.fixture.fake_shpool)], + capture_output=True, + text=True, + ).stdout.strip() + if not alive: + break + time.sleep(0.2) + self.assertEqual("", alive, f"the subscriber outlived the picker: {alive}") + + def test_the_subscriber_and_its_temp_files_are_gone_after_the_picker( + self, + ) -> None: + write_executable(self.fixture.fake_shpool, HELP_OK_STREAM) + self.run_picker(seconds=1.6, extra={}) + leftovers = [ + path for path in self.fixture.picker_temps() if "events" in path.name + ] + self.assertEqual([], leftovers, f"picker left event temps: {leftovers}") + + +PULSE_TOOL = REPO / "lib" / "sessionkit_inventory" / "pulse.py" + + +def _alive(pid: int) -> bool: + return Path(f"/proc/{pid}").exists() + + +class PulseLifetimeTests(unittest.TestCase): + """The always-on child has to end with the picker in EVERY exit path. + + The picker stops it from its EXIT trap, and a trap is exactly what a + SIGKILLed picker does not run -- an OOM kill of the picker's shell is this + shape, and an OOM kill of that shell is what prompted this test. Nothing + else ends the child: its stdout is the picker's event file, a REGULAR + file, so no reader can go away and the BrokenPipeError handler at the foot + of pulse.py is unreachable. + + Two things make this test see what the shipped ones could not: + + * it pgreps for `pulse.py` itself. The two tests above pgrep for the fake + shpool stub, so the watcher was never looked at; + * the terminal is HELD OPEN by a shell that outlives the picker, the way + a real shpool session holds one when the picker is a subprocess of the + session's shell. When the picker is itself the pty session leader, + killing it hangs up the terminal and the kernel reaps the orphan -- + a property of the harness, not of the product. + """ + + def setUp(self) -> None: + self.fixture = LoginFixture(two_rows()) + self.addCleanup(self.fixture.close) + write_executable(self.fixture.fake_shpool, HELP_OK_STREAM) + # A byte-for-byte copy of the shipped watcher, run from a path no other + # process on this box can be running, so the pgrep below cannot match + # somebody else's picker -- or a second copy of this suite. + self.tool = self.fixture.base / "pulse.py" + shutil.copyfile(PULSE_TOOL, self.tool) + self.assertEqual( + PULSE_TOOL.read_bytes(), + self.tool.read_bytes(), + "the copy under test is not the shipped watcher", + ) + self.terminal_pid: int | None = None + self.descriptor: int | None = None + self.addCleanup(self.tear_down_processes) + + # -- the held-open terminal --------------------------------------------- + def tear_down_processes(self) -> None: + for pid in self.pulse_pids() + self.stub_pids(): + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + if self.terminal_pid is not None: + try: + os.kill(self.terminal_pid, signal.SIGKILL) + os.waitpid(self.terminal_pid, 0) + except OSError: + pass + self.terminal_pid = None + if self.descriptor is not None: + try: + os.close(self.descriptor) + except OSError: + pass + self.descriptor = None + + def pgrep(self, pattern: str) -> list[int]: + completed = subprocess.run( + ["pgrep", "-f", pattern], capture_output=True, text=True + ) + return [int(line) for line in completed.stdout.split() if line.isdigit()] + + def pulse_pids(self) -> list[int]: + return self.pgrep(os.fspath(self.tool)) + + def stub_pids(self) -> list[int]: + return self.pgrep(os.fspath(self.fixture.fake_shpool)) + + def start_picker_under_a_held_open_terminal(self) -> int: + """Start the picker as a CHILD of a shell that keeps the pty open.""" + environment = self.fixture.env() + environment.update( + { + "SESSION_KIT_PICKER_EVENTS": "auto", + "SESSION_KIT_PICKER_REFRESH_SECONDS": "30", + "SESSION_KIT_PICKER_EVENT_POLL_SECONDS": "300", + "SESSION_KIT_PICKER_PULSE_SECONDS": "0.3", + "SESSION_KIT_PULSE_TOOL": os.fspath(self.tool), + } + ) + script = f"{shlex.quote(os.fspath(LOGIN))}; exec sleep 25" + pid, descriptor = pty.fork() + if pid == 0: + try: + os.chdir(self.fixture.base) + os.execve( + "/usr/bin/bash", ["/usr/bin/bash", "-c", script], environment + ) + finally: + os._exit(127) + self.terminal_pid = pid + self.descriptor = descriptor + self.wait_for_the_menu() + return pid + + def wait_for_the_menu(self) -> None: + output = bytearray() + deadline = time.monotonic() + 12 + while b"\xe2\x9d\xaf " not in output and time.monotonic() < deadline: + ready, _, _ = select.select([self.descriptor], [], [], 0.05) + if not ready: + continue + try: + chunk = os.read(self.descriptor, 65536) + except OSError as exc: + if exc.errno != errno.EIO: + raise + break + if not chunk: + break + output.extend(chunk) + if b"\xe2\x9d\xaf " not in output: + raise AssertionError( + "the picker never painted under the held-open terminal: " + f"{output.decode(errors='replace')!r}" + ) + + def wait_for_the_pulse(self) -> int: + deadline = time.monotonic() + 8 + while time.monotonic() < deadline: + pids = self.pulse_pids() + if len(pids) == 1: + return pids[0] + self.assertLessEqual(len(pids), 1, f"more than one watcher: {pids}") + time.sleep(0.1) + raise AssertionError("the picker never started its attention watcher") + + # -- the test ------------------------------------------------------------ + def test_the_watcher_dies_with_a_picker_that_was_killed_outright(self) -> None: + terminal = self.start_picker_under_a_held_open_terminal() + pulse = self.wait_for_the_pulse() + children = subprocess.run( + ["pgrep", "-P", str(terminal)], capture_output=True, text=True + ).stdout.split() + self.assertEqual(1, len(children), f"expected one picker: {children}") + picker = int(children[0]) + self.assertNotEqual(picker, pulse) + # Five intervals with the picker alive and well: the parent check must + # not fire on a picker that is still there, or the push half of the + # attention answer is dead and the poll silently carries it alone. + time.sleep(5 * 0.3) + self.assertTrue( + _alive(pulse), "the watcher stopped while its picker was running" + ) + + os.kill(picker, signal.SIGKILL) + deadline = time.monotonic() + 10 + while _alive(pulse) and time.monotonic() < deadline: + time.sleep(0.1) + # The terminal is the whole point: it is still open, so no SIGHUP + # reached the orphan and nothing but the watcher itself can have + # ended it. + self.assertTrue( + _alive(terminal), + "the terminal closed, so this proves nothing about the watcher", + ) + self.assertFalse( + _alive(pulse), + "the attention watcher outlived a SIGKILLed picker: " + + subprocess.run( + ["ps", "-o", "pid=,ppid=,args=", "-p", str(pulse)], + capture_output=True, + text=True, + ).stdout.strip(), + ) + + def test_the_watcher_still_stops_when_the_picker_leaves_on_purpose(self) -> None: + """The trap-based stop is not replaced by the parent check.""" + self.start_picker_under_a_held_open_terminal() + pulse = self.wait_for_the_pulse() + os.write(self.descriptor, b"q\n") + deadline = time.monotonic() + 10 + while _alive(pulse) and time.monotonic() < deadline: + time.sleep(0.1) + self.assertFalse(_alive(pulse), "a clean quit left the watcher running") + + +# The reaper's own comment: "A label absent from this list has no backstop at +# all." These two labels are written by the event subscription for as long as +# the picker runs, and the picker that leaves them behind is the one that ran +# no trap -- so the backstop is the only thing left. +REAPER = REPO / "bin" / "shpool_reaper" + +STUB_SHPOOL_LIST = """#!/usr/bin/env bash +if [[ ${1:-} == list ]]; then + echo '{"sessions": []}' + exit 0 +fi +exit 0 +""" + + +class EventTempReaperTests(unittest.TestCase): + """Every temp label the picker writes has to be in the hourly sweep.""" + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix=".events-reap-") + self.addCleanup(self.temporary.cleanup) + self.base = Path(self.temporary.name) + self.state = self.base / "state" + self.state.mkdir(mode=0o700) + os.chmod(self.state, 0o700) + self.proc = self.base / "proc" + self.proc.mkdir() + self.shpool = self.base / "fake-shpool" + write_executable(self.shpool, STUB_SHPOOL_LIST) + + def aged_temp(self, name: str) -> Path: + path = self.state / name + path.write_text("{}\n", encoding="utf-8") + os.chmod(path, 0o600) + old = time.time() - 25 * 60 * 60 + os.utime(path, (old, old)) + return path + + def test_the_pickers_event_temp_files_are_reaped_like_every_other_label( + self, + ) -> None: + events = self.aged_temp("login-events.json.Ab12Cd") + down = self.aged_temp("login-events-down.json.Ab12Cd") + # A label that was already in the list, aged the same way: if the sweep + # did not run at all, this one would survive too and the failure below + # would be about the harness rather than about the labels. + control = self.aged_temp("login-live.json.Ab12Cd") + fresh = self.state / "login-events.json.Zz99Yy" + fresh.write_text("{}\n", encoding="utf-8") + os.chmod(fresh, 0o600) + + environment = os.environ.copy() + environment.update( + { + "HOME": os.fspath(self.base), + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_STATE_DIR": os.fspath(self.state), + "SESSION_KIT_PROC_ROOT": os.fspath(self.proc), + "SESSION_KIT_SHPOOL_CMD": os.fspath(self.shpool), + "SESSION_KIT_REAPER_SENTINEL": os.fspath(self.base / "no-sentinel"), + } + ) + subprocess.run( + [os.fspath(REAPER)], + env=environment, + capture_output=True, + text=True, + check=False, + ) + self.assertFalse(control.exists(), "the sweep did not run at all") + self.assertFalse(events.exists(), "login-events has no reaper backstop") + self.assertFalse( + down.exists(), "login-events-down has no reaper backstop" + ) + # The 24-hour cutoff still protects a picker that is running now. + self.assertTrue(fresh.exists(), "the sweep took a file a live picker owns") + + +SWITCH_PROBE = r""" +source "$LOGIN_LIVE_MODULE" +PICKER_SCREEN=1 +SK_SHPOOL=$FAKE_SHPOOL +PICKER_EVENTS_FILE=$EVENTS_FILE +events=off +pulse=off +if picker_events_wanted; then + events=on + PICKER_EVENTS_STATE=live + if picker_pulse_start; then pulse=on; fi +fi +picker_live_interval 5 +printf '%s %s %s\n' "$events" "$pulse" "$PICKER_LIVE_INTERVAL" +picker_pulse_stop +""" + +PULSE_STUB = """#!/usr/bin/env python3 +import time + +time.sleep(20) +""" + + +class KillSwitchSpellingTests(unittest.TestCase): + """The documented rollback levers, driven as the picker drives them. + + `SESSION_KIT_PICKER_EVENTS` and `SESSION_KIT_PICKER_PULSE` are what an + operator reaches for when a new always-on child misbehaves. Matched + against bare lowercase patterns, `OFF` left both of them ON and said + nothing -- and doctor's kill-switches row does not know these variables, + so nothing else would have said so either. Every switch this round added + on the Python side already reads `.strip().casefold()`. + """ + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix=".switch-") + self.addCleanup(self.temporary.cleanup) + self.base = Path(self.temporary.name) + self.events_file = self.base / "events" + self.events_file.write_text("", encoding="utf-8") + self.shpool = self.base / "fake-shpool" + write_executable(self.shpool, "#!/usr/bin/env bash\nexit 0\n") + # Never the real watcher: this probe is about the switch, and a stub + # keeps a stray process out of the suite. + self.tool = self.base / "pulse-stub.py" + write_executable(self.tool, PULSE_STUB) + + def probe(self, **switches: str) -> tuple[str, str, str]: + environment = { + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "LOGIN_LIVE_MODULE": os.fspath( + REPO / "lib" / "sh" / "shpool_login_live.sh" + ), + "FAKE_SHPOOL": os.fspath(self.shpool), + "EVENTS_FILE": os.fspath(self.events_file), + "SESSION_KIT_PULSE_TOOL": os.fspath(self.tool), + "SESSION_KIT_PICKER_EVENT_POLL_SECONDS": "300", + } + environment.update(switches) + completed = subprocess.run( + ["bash", "-c", SWITCH_PROBE], + env=environment, + capture_output=True, + text=True, + check=True, + ) + events, pulse, interval = completed.stdout.split()[:3] + return events, pulse, interval + + def test_with_no_switch_set_both_children_run_and_the_poll_widens(self) -> None: + """The control: this is what the two spellings below have to undo.""" + self.assertEqual(("on", "on", "300"), self.probe()) + + def test_the_pulse_switch_is_read_the_way_it_is_typed(self) -> None: + for spelling in ("0", "off", "no", "false", "OFF", "Off", " off ", "FALSE"): + with self.subTest(spelling=spelling): + events, pulse, interval = self.probe( + SESSION_KIT_PICKER_PULSE=spelling + ) + self.assertEqual("on", events, "only the pulse was switched off") + self.assertEqual("off", pulse, f"{spelling!r} did not stop the pulse") + self.assertEqual( + "5", + interval, + f"{spelling!r} left the poll widened past the old bound", + ) + + def test_the_events_switch_is_read_the_way_it_is_typed(self) -> None: + for spelling in ("0", "off", "no", "false", "OFF", "Off", " off ", "NO"): + with self.subTest(spelling=spelling): + events, pulse, interval = self.probe( + SESSION_KIT_PICKER_EVENTS=spelling + ) + self.assertEqual( + "off", events, f"{spelling!r} did not stop the subscription" + ) + self.assertEqual("off", pulse) + self.assertEqual( + "5", + interval, + f"{spelling!r} left the poll widened past the old bound", + ) + + def test_a_value_that_is_not_an_off_word_still_leaves_them_on(self) -> None: + """Normalising is not the same as guessing: only off means off.""" + for spelling in ("auto", "1", "on", "yes", "offline"): + with self.subTest(spelling=spelling): + self.assertEqual( + ("on", "on", "300"), + self.probe( + SESSION_KIT_PICKER_EVENTS=spelling, + SESSION_KIT_PICKER_PULSE=spelling, + ), + ) + + +PULSE_VALUE_PROBE = r""" +source "$LOGIN_LIVE_MODULE" +PICKER_SCREEN=1 +SK_SHPOOL=$FAKE_SHPOOL +PICKER_EVENTS_FILE=$EVENTS_FILE +PICKER_EVENTS_STATE=live +pulse=off +if picker_pulse_start; then pulse=on; fi +# The picker asks for the interval once per loop pass. A watcher that died has +# to be noticed by one of those passes, so this is driven the same way rather +# than asked once at start-up. +ticks=${PROBE_TICKS:-10} +for (( i = 0; i < ticks; i++ )); do + picker_live_interval 5 + if [[ $PICKER_LIVE_INTERVAL == 5 ]]; then break; fi + sleep 0.1 +done +printf '%s %s %s\n' "$pulse" "$PICKER_LIVE_INTERVAL" "$PICKER_PULSE_STATE" +picker_pulse_stop +""" + +# argparse the same way pulse.py does it: --interval is a float, and a value it +# cannot read exits 2 before anything is watched. The argv is recorded FIRST so +# the test can see what the picker actually passed, parseable or not. +ARGV_PULSE_STUB = """#!/usr/bin/env python3 +import argparse +import os +import sys +import time + +with open(os.environ["PULSE_ARGV_FILE"], "w", encoding="utf-8") as handle: + handle.write(" ".join(sys.argv[1:])) +parser = argparse.ArgumentParser() +parser.add_argument("--interval", type=float, default=1.0) +parser.add_argument("--parent-pid", type=int) +parser.parse_args() +time.sleep(20) +""" + +# A watcher that cannot start, for any reason at all. +DEAD_PULSE_STUB = """#!/usr/bin/env python3 +import sys + +sys.exit(2) +""" + + +class PulseIntervalTests(unittest.TestCase): + """The poll may never widen behind a watcher that is not running. + + `pulse.py --interval abc` exits 2 inside argparse in milliseconds. The + value went into the child's argv unchecked and the state was declared + `live` regardless, so one typo in one environment variable took the + attention watcher away AND left the poll widened to 30 seconds (300 with + the documented widening set) -- with nothing on screen, nothing in the log + and nothing in doctor to say so. The direction of that failure is the one + this round exists to prevent: "needs your reply" went from about a second + back to half a minute. + """ + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix=".pulse-value-") + self.addCleanup(self.temporary.cleanup) + self.base = Path(self.temporary.name) + self.events_file = self.base / "events" + self.events_file.write_text("", encoding="utf-8") + self.shpool = self.base / "fake-shpool" + write_executable(self.shpool, "#!/usr/bin/env bash\nexit 0\n") + self.argv_file = self.base / "argv" + self.tool = self.base / "pulse-stub.py" + write_executable(self.tool, ARGV_PULSE_STUB) + + def probe(self, value=None, *, ticks="1") -> tuple[str, str, str]: + environment = { + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "LOGIN_LIVE_MODULE": os.fspath( + REPO / "lib" / "sh" / "shpool_login_live.sh" + ), + "FAKE_SHPOOL": os.fspath(self.shpool), + "EVENTS_FILE": os.fspath(self.events_file), + "SESSION_KIT_PULSE_TOOL": os.fspath(self.tool), + "SESSION_KIT_PICKER_EVENT_POLL_SECONDS": "300", + "PULSE_ARGV_FILE": os.fspath(self.argv_file), + "PROBE_TICKS": ticks, + } + if value is not None: + environment["SESSION_KIT_PICKER_PULSE_SECONDS"] = value + completed = subprocess.run( + ["bash", "-c", PULSE_VALUE_PROBE], + env=environment, + capture_output=True, + text=True, + check=True, + ) + pulse, interval, state = completed.stdout.split()[:3] + return pulse, interval, state + + def argv(self) -> str: + # The watcher is a separate process, so its argv file appears after the + # probe returns. Waiting five seconds was enough on an idle machine and + # not on a loaded CI runner, and the old timeout returned an empty + # string, so the caller's assertion blamed the interval value for a + # watcher that had simply not started yet (2026-08-20). Wait longer, + # require the write to be complete, and say what actually went wrong. + budget = 30 + deadline = time.monotonic() + budget + while time.monotonic() < deadline: + try: + recorded = self.argv_file.read_text(encoding="utf-8") + except FileNotFoundError: + recorded = "" + if recorded.strip(): + return recorded + time.sleep(0.05) + raise AssertionError( + f"the watcher wrote no argv to {self.argv_file} within {budget}s; " + "it never started, so nothing here is a statement about its arguments" + ) + + def test_a_value_argparse_cannot_read_falls_back_to_the_default(self) -> None: + marker = self.base / "pwn" + values = ("abc", "", " ", "1e", "0x2", "1;2", "-1", f"$(touch {marker})") + for value in values: + with self.subTest(value=value): + self.argv_file.unlink(missing_ok=True) + pulse, interval, state = self.probe(value) + self.assertIn( + "--interval 1", + self.argv(), + f"{value!r} reached the watcher's argv", + ) + self.assertEqual("on", pulse, f"{value!r} stopped the watcher") + self.assertEqual("live", state) + self.assertEqual( + "300", interval, "the watcher runs, so the poll may widen" + ) + # Quoted, and it stays quoted: the value is data, never a + # command. (Proven separately by the security lane; kept here + # because this is the function that interpolates it.) + self.assertFalse(marker.exists()) + + def test_a_value_it_can_read_is_passed_through_unchanged(self) -> None: + for value in ("0.5", "2", ".25", " 0.5 "): + with self.subTest(value=value): + self.argv_file.unlink(missing_ok=True) + pulse, interval, state = self.probe(value) + self.assertIn(f"--interval {value.strip()}", self.argv()) + self.assertEqual(("on", "300", "live"), (pulse, interval, state)) + + def test_with_the_variable_unset_the_default_is_still_one_second(self) -> None: + pulse, interval, state = self.probe() + self.assertIn("--interval 1", self.argv()) + self.assertEqual(("on", "300", "live"), (pulse, interval, state)) + + def test_a_watcher_that_cannot_start_never_leaves_the_poll_widened(self) -> None: + """The guarantee itself, driven against a watcher that dies at once. + + A bad interval can no longer produce this, but a missing interpreter, + an import error or an OOM kill still can, and the answer has to be the + same: the poll goes back to the cadence that finds a waiting session + on its own rather than staying widened over nothing. + """ + write_executable(self.tool, DEAD_PULSE_STUB) + _pulse, interval, state = self.probe("1", ticks="30") + self.assertEqual( + "5", interval, "the poll stayed widened behind a dead watcher" + ) + self.assertNotEqual("live", state) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_picker_exit_grammar.py b/tests/test_picker_exit_grammar.py new file mode 100644 index 0000000..5d98dc5 --- /dev/null +++ b/tests/test_picker_exit_grammar.py @@ -0,0 +1,441 @@ +"""One exit grammar, proved on the picker's own surfaces. + +Every screen in the kit now answers the same three ways of leaving: + + q / quit / exit leave this surface for the one above it + Ctrl-C cancel the prompt you are in, back to its menu + Ctrl-D one level, out loud -- never a silent end three menus deep + +Enter is not among them any more: it takes the most likely option on the +screen it is pressed on -- back out of a modal, and on the home list the top +row it just drew (operator ruling, 2026-08-16). + +Before this, `q` meant four different things on four screens, `exit` and +`quit` were unknown words to a menu whose job is leaving, an interrupt could +not cancel a single nested prompt, and a Ctrl-D typed into a confirmation +ended the whole picker without a word. +""" + +from __future__ import annotations + +import json +import signal +import subprocess +import unittest + +from tests.support import REPO +from tests.test_login import LoginFixture, inventory, row, run_pty + +LOGIN = REPO / "bin" / "shpool_login" + + +def exit_reasons(fixture: LoginFixture) -> list[str]: + """Why the picker ended, straight from the action log.""" + log = fixture.state / "action-events.jsonl" + if not log.exists(): + return [] + reasons = [] + for line in log.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + entry = json.loads(line) + if entry.get("action") == "picker_exit": + reasons.append(entry.get("outcome")) + return reasons + + +class ExitCodeTests(unittest.TestCase): + """0 is leaving on purpose; 1 is the picker failing to run. + + Every way out used to be 2 -- the code `sp help exit-codes` reserves for a + wrong argument or a selector that matched nothing -- so the shell that + launched the picker could not tell "the person chose a terminal" from "the + session manager never answered", and treated both as normal. + """ + + def test_leaving_on_purpose_is_a_success(self) -> None: + for typed, reason in ((b"q\n", "quit"), (b"\x04", "input_closed")): + with self.subTest(typed=typed): + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, _output = run_pty(fixture, typed) + self.assertEqual(0, code) + self.assertEqual([reason], exit_reasons(fixture)) + finally: + fixture.close() + + def test_a_picker_without_a_terminal_exits_1_and_says_why(self) -> None: + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + completed = subprocess.run( + [LOGIN], + env=fixture.env(), + cwd=fixture.base, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(1, completed.returncode, completed.stderr) + self.assertIn( + "session-kit: the picker needs a terminal.", completed.stderr + ) + self.assertEqual(["noninteractive"], exit_reasons(fixture)) + finally: + fixture.close() + + def test_a_release_missing_its_helpers_exits_1_and_says_why(self) -> None: + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty( + fixture, + b"", + env_updates={ + "SESSION_KIT_SP_CMD": str(fixture.base / "not-installed"), + }, + ) + self.assertEqual(1, code) + self.assertIn( + "session-kit: the picker's helpers are missing from this release.", + output, + ) + finally: + fixture.close() + + +class ExitWordsTests(unittest.TestCase): + def test_the_dashboard_leaves_on_the_typed_words_exit_and_quit(self) -> None: + """The words a person types when the key does not come to mind.""" + for word in (b"exit\n", b"quit\n", b"EXIT\n"): + with self.subTest(word=word): + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty(fixture, word) + self.assertEqual(0, code) + self.assertNotIn("There is no such key", output) + self.assertEqual(["quit"], exit_reasons(fixture)) + self.assertEqual([], fixture.sp_entries()) + finally: + fixture.close() + + def test_the_help_screen_documents_the_words_it_accepts(self) -> None: + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty(fixture, b"?\n\nq\n", columns=110) + self.assertEqual(0, code) + help_text = output[output.index("Picker help") :] + self.assertIn("q / quit / exit", help_text) + finally: + fixture.close() + + def test_the_help_screen_never_says_enter_leaves(self) -> None: + """A help row is a claim, and this one outlived the behaviour. + + The table carried `Leaving Enter Leave the picker for a regular + shell` after Enter stopped leaving anything. + """ + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty(fixture, b"?\n\nq\n", columns=110) + self.assertEqual(0, code) + help_text = output[output.index("Picker help") :] + enter_rows = [ + line + for line in help_text.splitlines() + if line.strip().startswith("Enter") + ] + self.assertEqual(1, len(enter_rows), help_text) + self.assertNotIn("Leave the picker", enter_rows[0]) + self.assertIn("Takes the most likely choice", enter_rows[0]) + # The door out is named for the keys that actually open it. + self.assertIn("Back from the top is out", help_text) + finally: + fixture.close() + + +class BackOneLevelTests(unittest.TestCase): + def test_q_on_needs_you_returns_to_the_list_instead_of_leaving(self) -> None: + """`q` there used to end the picker; `q3` still resolves a prompt.""" + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty(fixture, b"a\nq\nq\n") + self.assertEqual(0, code) + # The list came back after the Needs You screen, and only the + # deliberate q that followed ended the picker. + needs = output.index("needs you: 0") + self.assertIn("# · kill k #", output[needs:]) + self.assertEqual(["quit"], exit_reasons(fixture)) + finally: + fixture.close() + + def test_q_is_back_on_more_and_on_help(self) -> None: + for label, payload, marker in ( + ("more", b"m\nq\nq\n", "More"), + ("help", b"?\nq\nq\n", "Picker help"), + ): + with self.subTest(label=label): + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty(fixture, payload) + self.assertEqual(0, code) + self.assertIn(marker, output) + # Back to the list, then out through the q after it. + self.assertIn("# · kill k #", output[output.index(marker) :]) + self.assertEqual(["quit"], exit_reasons(fixture)) + finally: + fixture.close() + + def test_q_is_back_on_the_projects_screen(self) -> None: + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty(fixture, b"m\np\nq\n\nq\n") + self.assertEqual(0, code) + self.assertIn("projects ❯", output) + self.assertNotIn("Unknown choice", output) + self.assertEqual(["quit"], exit_reasons(fixture)) + finally: + fixture.close() + + +class WaitingPromptTests(unittest.TestCase): + def test_a_modal_prompt_left_alone_stays_where_it_is(self) -> None: + """The poll that lets Ctrl-C be noticed must not become an exit. + + The modal read waits in one-second steps. A step that ends with + nothing typed is a step, not closed input -- and `$?` after a bare + failed `if` is the `if`'s own zero, which is exactly how a timeout + turns into an end-of-input that walks out of the prompt on its own. + """ + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty( + fixture, + b"m\n", + # Nothing is typed until the More screen has been sitting on + # its prompt for several polls. + deferred=("more ❯", b"q\nq\n"), + deferred_delay_seconds=3, + ) + self.assertEqual(0, code) + self.assertNotIn("End of input", output) + self.assertEqual(["quit"], exit_reasons(fixture)) + # One More screen, not one per poll: it never redrew itself. + self.assertEqual(1, output.count("Help: every picker key")) + finally: + fixture.close() + + +class UnknownKeyTests(unittest.TestCase): + def test_an_unknown_key_on_the_list_names_the_keys_that_work(self) -> None: + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty(fixture, b"zz\nq\n") + self.assertEqual(0, code) + # Enter leads the list: it is a key this screen takes now, and a + # list that omits it teaches the screen it used to be. + self.assertIn( + "There is no such key on this screen. These work: Enter, a session number,", + output, + ) + self.assertIn("and q.", output) + # Every key it names is a key the dispatcher takes. + self.assertNotIn("i number", output) + finally: + fixture.close() + + def test_an_unknown_key_on_needs_you_names_its_own_keys(self) -> None: + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty(fixture, b"a\nzz\n\nq\n", columns=120) + self.assertEqual(0, code) + self.assertIn( + "There is no such key on this screen. These work: d number, ?, b, and Enter.", + output, + ) + finally: + fixture.close() + + def test_an_unknown_key_on_the_new_session_screen_names_its_own_keys(self) -> None: + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty(fixture, b"n\nzz\nq\n") + self.assertEqual(0, code) + self.assertIn( + "There is no such key on this screen. These work: 1, 2, 3, Enter, and b.", + output, + ) + self.assertEqual([], fixture.sp_entries()) + finally: + fixture.close() + + +class EscapeSequenceTests(unittest.TestCase): + """An arrow key is one keystroke, not three characters of an answer.""" + + def test_an_arrow_key_at_a_modal_prompt_is_not_an_answer(self) -> None: + # The modal prompts read whole lines, so Up-arrow arrived as ^[[A + # inside a name, a path, or a confirmation word. + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty(fixture, b"m\n\x1b[A\nq\nq\n") + self.assertEqual(0, code) + self.assertIn("Arrow keys do nothing here.", output) + # It asked again instead of acting: the More screen was still + # there for the q that followed. + self.assertNotIn("There is no such key", output) + self.assertEqual(["quit"], exit_reasons(fixture)) + finally: + fixture.close() + + def test_an_arrow_key_never_reaches_the_list_prompt(self) -> None: + # Typed at the home menu it is swallowed whole: no bytes in the + # buffer, nothing dispatched, no echo of the sequence. + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty( + fixture, + b"", + # Typed after the menu paints, so the character-by-character + # read owns the keyboard when it lands. + deferred=("❯", b"\x1b[Aq\n"), + deferred_delay_seconds=1.5, + ) + self.assertEqual(0, code) + # The buffer held "q" and nothing else: the picker left on the + # key, not on an unknown choice built out of escape bytes. + self.assertEqual(["quit"], exit_reasons(fixture)) + self.assertNotIn("There is no such key", output) + self.assertNotIn("[A", output.split("# · kill k #")[-1]) + finally: + fixture.close() + + +class InterruptTests(unittest.TestCase): + def test_ctrl_c_cancels_a_modal_prompt_back_to_its_menu(self) -> None: + """The 24 `|| return` guards on modal reads were dead code. + + `picker_modal_read` looped on an interrupt and asked again, so a + Ctrl-C inside More, Help, a project form, or a confirmation did + nothing at all. It now cancels the prompt and hands the level back. + """ + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty( + fixture, + b"m\n", + send_signal=signal.SIGINT, + signal_marker="more ❯", + post_signal_bytes=b"q\n", + post_signal_marker="Nothing changed.", + ) + self.assertEqual(0, code) + self.assertIn("Nothing changed.", output) + # Cancelled the prompt, not the picker: the list came back and the + # q after the signal is what ended it. + cancelled = output.index("Nothing changed.") + self.assertIn("# · kill k #", output[cancelled:]) + self.assertEqual(["quit"], exit_reasons(fixture)) + finally: + fixture.close() + + def test_ctrl_c_three_levels_down_returns_one_level(self) -> None: + """The list, More, Projects, then the add form: an interrupt at the + form hands back the Projects screen, not the list and not a shell.""" + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty( + fixture, + b"m\np\na\n", + send_signal=signal.SIGINT, + signal_marker="Directory (Enter for", + post_signal_bytes=b"\n\nq\n", + post_signal_marker="Nothing changed.", + ) + self.assertEqual(0, code) + cancelled = output.index("Nothing changed.") + # The screen that asked is the screen that came back. + self.assertIn("projects ❯", output[cancelled:]) + self.assertEqual(["quit"], exit_reasons(fixture)) + finally: + fixture.close() + + def test_the_home_menu_still_redraws_on_an_interrupt(self) -> None: + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty( + fixture, + b"", + send_signal=signal.SIGINT, + post_signal_bytes=b"q\n", + ) + self.assertEqual(0, code) + self.assertNotIn("Nothing changed.", output) + self.assertEqual(["quit"], exit_reasons(fixture)) + finally: + fixture.close() + + def test_an_interrupt_discards_the_line_it_interrupted(self) -> None: + """Typed first, then interrupted -- the assertion this test needed. + + Asserting only that the picker survived an interrupt with an empty + buffer passed while Ctrl-C was a complete no-op here: the flag was + read inside a branch a signalled read can never reach, so the + abandoned line was still in the buffer and the next Enter submitted + it. A destructive line makes the difference impossible to miss. + """ + fixture = LoginFixture( + inventory(row("ready", number=9), row("other", number=17)) + ) + try: + code, output = run_pty( + fixture, + b"", + # Typed after the opening canonical second, so the picker owns + # the characters and the interrupt has a buffer to throw away. + deferred=("❯ ", b"k 17"), + deferred_delay_seconds=1.3, + send_signal=signal.SIGINT, + post_signal_bytes=b"q\n", + ) + self.assertEqual(0, code) + # The line died with the interrupt: q left the picker instead + # of closing session 17. + self.assertEqual(["quit"], exit_reasons(fixture)) + self.assertEqual( + [], [entry for entry in fixture.sp_entries() if entry.get("args")] + ) + # And the prompt came back, as the help text promises. + self.assertIn("❯", output[output.rindex("k 17") :]) + finally: + fixture.close() + + +class EndOfInputTests(unittest.TestCase): + def test_ctrl_d_in_a_modal_goes_back_one_level_instead_of_killing_it( + self, + ) -> None: + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty(fixture, b"m\n\x04q\n") + self.assertEqual(0, code) + self.assertIn("Back.", output) + back = output.index(" Back.") + self.assertIn("# · kill k #", output[back:]) + # The picker ended on the q after it, not on the Ctrl-D. + self.assertEqual(["quit"], exit_reasons(fixture)) + finally: + fixture.close() + + def test_ctrl_d_on_the_home_menu_leaves_and_says_so(self) -> None: + fixture = LoginFixture(inventory(row("ready", number=9))) + try: + code, output = run_pty(fixture, b"\x04") + self.assertEqual(0, code) + self.assertIn("Leaving the picker.", output) + self.assertEqual(["input_closed"], exit_reasons(fixture)) + finally: + fixture.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_picker_input_safety.py b/tests/test_picker_input_safety.py new file mode 100644 index 0000000..be37f8f --- /dev/null +++ b/tests/test_picker_input_safety.py @@ -0,0 +1,215 @@ +"""Pasted input is data, and an interrupt abandons the line it interrupted. + +Two input defects the picker shipped with, both proven on a pseudo-terminal: + + * a pasted block ran each of its lines as a picker command, so a paste + containing `k 17` closed session 17 without a confirmation and a blank + line inside it dropped the operator to a shell; + * Ctrl-C at the home prompt did nothing at all, and the line it was meant + to abandon was submitted by the next Enter. + +Both are asserted here against the behaviour, not against survival: the tests +type first, then check what the picker did with what was typed. +""" + +from __future__ import annotations + +import errno +import os +import pty +import select +import signal +import time +import unittest + +from tests.support import REPO +from tests.test_login import LoginFixture, inventory, row, run_pty +from tests.test_picker_exit_grammar import exit_reasons + +LOGIN = REPO / "bin" / "shpool_login" + +PASTE_OPEN = b"\x1b[200~" +PASTE_CLOSE = b"\x1b[201~" + + +def sp_actions(fixture: LoginFixture) -> list[str]: + return [ + str(entry.get("args", [""])[0]) for entry in fixture.sp_entries() + ] + + +def run_staged( + fixture: LoginFixture, + stages: list[tuple[str, bytes]], + *, + lines: int = 24, + columns: int = 100, + timeout: float = 20, +) -> tuple[int, str]: + """Type each payload only once the screen shows what it answers. + + run_pty writes everything up front, which is right for most tests and + wrong for a paste: absorbing one moves the terminal in and out of + character-at-a-time reads, and the kernel stops delimiting bytes that were + already queued behind it. Waiting for each prompt keeps the test about the + paste rather than about how much a terminal buffers. + """ + environment = fixture.env(lines=lines, columns=columns) + pid, descriptor = pty.fork() + if pid == 0: + try: + os.chdir(fixture.base) + os.execve(os.fspath(LOGIN), [os.fspath(LOGIN)], environment) + finally: + os._exit(127) + output = bytearray() + deadline = time.monotonic() + timeout + + def pump(seconds: float) -> None: + ready, _, _ = select.select([descriptor], [], [], seconds) + if not ready: + return + try: + chunk = os.read(descriptor, 65536) + except OSError as exc: + if exc.errno != errno.EIO: + raise + return + output.extend(chunk) + + try: + for marker, payload in stages: + seen = len(output) + while marker.encode() not in output[seen:]: + if time.monotonic() > deadline: + os.kill(pid, signal.SIGKILL) + os.waitpid(pid, 0) + raise AssertionError( + f"picker never printed {marker!r}; " + f"output={output.decode(errors='replace')!r}" + ) + pump(0.05) + os.write(descriptor, payload) + status = None + while time.monotonic() < deadline: + pump(0.05) + waited, child_status = os.waitpid(pid, os.WNOHANG) + if waited == pid: + status = child_status + break + if status is None: + os.kill(pid, signal.SIGKILL) + _, status = os.waitpid(pid, 0) + raise AssertionError( + f"picker timed out; output={output.decode(errors='replace')!r}" + ) + while True: + ready, _, _ = select.select([descriptor], [], [], 0) + if not ready: + break + try: + chunk = os.read(descriptor, 65536) + except OSError: + break + if not chunk: + break + output.extend(chunk) + return os.waitstatus_to_exitcode(status), output.decode( + "utf-8", errors="replace" + ).replace("\r\n", "\n") + finally: + os.close(descriptor) + + +class PastedInputTests(unittest.TestCase): + def test_a_pasted_block_never_closes_a_session(self) -> None: + """The audit's exact payload: three lines, one of them `k 17`.""" + fixture = LoginFixture( + inventory( + row("alpha", number=3), + row("bravo", number=17), + row("charlie", number=21), + ) + ) + try: + paste = PASTE_OPEN + b"look at this\nk 17\n\nand this\n" + PASTE_CLOSE + code, output = run_pty(fixture, paste + b"\nq\n") + self.assertEqual(0, code) + # Nothing was acted on. The old behaviour logged picker-close here. + self.assertNotIn("picker-close", sp_actions(fixture)) + # One line arrived at the prompt, and it was refused as one key. + self.assertIn("look at this k 17 and this", output) + self.assertEqual(1, output.count("There is no such key on this screen")) + self.assertEqual(["quit"], exit_reasons(fixture)) + finally: + fixture.close() + + def test_a_blank_line_inside_a_paste_does_not_leave_the_picker(self) -> None: + fixture = LoginFixture(inventory(row("alpha", number=3))) + try: + paste = PASTE_OPEN + b"\n\n\n" + PASTE_CLOSE + # The picker is still there to be quit by name afterwards. Under + # the old reader the first pasted newline was an empty choice, + # which left for a shell as `terminal_requested`. + code, output = run_pty(fixture, paste + b"q\n") + self.assertEqual(0, code) + self.assertEqual(["quit"], exit_reasons(fixture)) + finally: + fixture.close() + + def test_the_picker_arms_bracketed_paste_for_its_own_prompt(self) -> None: + fixture = LoginFixture(inventory(row("alpha", number=3))) + try: + code, output = run_pty(fixture, b"q\n") + self.assertEqual(0, code) + # Without this the terminal sends a paste as plain keystrokes and + # no reader can tell it from typing. + self.assertIn("\x1b[?2004h", output) + self.assertIn("\x1b[?2004l", output) + finally: + fixture.close() + + def test_a_paste_typed_into_a_live_line_stays_in_the_line(self) -> None: + """The character loop, not the opening canonical read.""" + fixture = LoginFixture( + inventory(row("alpha", number=3), row("bravo", number=17)) + ) + try: + paste = PASTE_OPEN + b"k 17\nk 3\n" + PASTE_CLOSE + code, output = run_pty( + fixture, + b"", + deferred=("❯ ", paste + b"\nq\n"), + deferred_delay_seconds=1.4, + ) + self.assertEqual(0, code) + self.assertNotIn("picker-close", sp_actions(fixture)) + self.assertIn("k 17 k 3", output) + self.assertEqual(["quit"], exit_reasons(fixture)) + finally: + fixture.close() + + def test_a_paste_at_a_modal_prompt_is_one_answer(self) -> None: + fixture = LoginFixture(inventory(row("alpha", number=3))) + try: + paste = PASTE_OPEN + b"o\nq\n" + PASTE_CLOSE + code, output = run_staged( + fixture, + [ + ("❯ ", b"m\n"), + ("more ❯ ", paste), + ("There is no such key", b"\n"), + ("# · kill k #", b"q\n"), + ], + ) + self.assertEqual(0, code) + # `o` was not dispatched: the More menu answered one unknown key. + self.assertNotIn("Outside the kit: none.", output) + self.assertIn("These work: o, u, p", output) + self.assertEqual(["quit"], exit_reasons(fixture)) + finally: + fixture.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_picker_latency_budget.py b/tests/test_picker_latency_budget.py new file mode 100644 index 0000000..0d427b0 --- /dev/null +++ b/tests/test_picker_latency_budget.py @@ -0,0 +1,463 @@ +"""A budget for the two costs the operator feels, asserted on a pty. + +"It flashes" and "typing feels laggy" survived several fix attempts because +neither was a number. These are numbers, measured the way a terminal +experiences them: + + * **the blank interval** -- how long a cleared screen is exposed before the + frame that replaces it arrives. The picker computes a frame first and emits + it with its own erase in one write, so this is bounded by one write; the + erase-first shape it replaced left the screen empty for 122 ms on every + keystroke and seconds at launch. + * **the frame's subprocess count** -- one home frame used to start eight + python interpreters over the same file, ~105 ms of which under 4 ms was + real work, plus a whole `shpool_status` in the foreground of the typing + loop for one integer the background collector already had. + +The budgets are the measured post-fix numbers with headroom, so an honest +improvement never fails and a regression to either shape does. +""" + +from __future__ import annotations + +import errno +import fcntl +import os +from pathlib import Path +import pty +import re +import select +import signal +import struct +import sys +import tempfile +import termios +import time +import unittest + +from tests.support import REPO +from tests.test_login import LOGIN, LoginFixture, inventory, row + +sys.path.insert(0, os.fspath(REPO / "lib")) +from sessionkit_inventory import pulse # noqa: E402 + +SHIM = Path(__file__).resolve().parent / "latency" / "shim" +SHIM_PPID = Path(__file__).resolve().parent / "latency" / "shim-ppid" + +# Measured on a 32-core box under a load average near 20, at 3/20/60 rows: +# frame subprocesses 5-6, blank interval under 1 ms (one write). The budgets +# leave room for a slower machine without leaving room for the old shapes. +FRAME_SUBPROCESS_BUDGET = 8 +BLANK_INTERVAL_BUDGET_MS = 60.0 +TYPING_STALL_BUDGET_MS = 400.0 + +ERASE = re.compile(rb"\x1b\[2J|\x1b\[H\x1b\[J|\x1b\[J") +PRINTABLE = re.compile(rb"[\x20-\x7e]") + + +def rows(count: int) -> list[dict]: + names = "alpha bravo charlie delta echo foxtrot golf hotel india juliet".split() + return [ + row( + f"{names[index % len(names)]}{index // len(names)}", + number=index + 1, + provider=("claude", "codex", "shell")[index % 3], + needs_you=(index % 7 == 0), + ) + for index in range(count) + ] + + +class Session: + """One picker on a real pty, with byte arrival times.""" + + def __init__(self, fixture: LoginFixture, updates: dict[str, str]) -> None: + environment = fixture.env(lines=24, columns=100) + # The real ioctl path, not a pinned size: a frame laid out for a + # fallback terminal is its own defect (rows wrapped at 99 columns on + # every width), and pinning these is what hid it. + for name in ("COLUMNS", "LINES"): + environment.pop(name, None) + environment.update(updates) + self.start = time.monotonic() + pid, descriptor = pty.fork() + if pid == 0: + try: + os.chdir(fixture.base) + os.execve(LOGIN, [os.fspath(LOGIN)], environment) + finally: + os._exit(127) + fcntl.ioctl( + descriptor, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 100, 0, 0) + ) + self.pid = pid + self.descriptor = descriptor + self.events: list[tuple[float, bytes]] = [] + self.seen = bytearray() + + def pump(self, seconds: float) -> None: + end = time.monotonic() + seconds + while True: + remaining = end - time.monotonic() + if remaining <= 0: + return + ready, _, _ = select.select( + [self.descriptor], [], [], min(0.02, remaining) + ) + if not ready: + continue + try: + chunk = os.read(self.descriptor, 65536) + except OSError as exc: + if exc.errno != errno.EIO: + raise + return + if not chunk: + return + self.events.append((time.monotonic() - self.start, chunk)) + self.seen.extend(chunk) + + def wait_for(self, marker: bytes, seconds: float = 10.0) -> None: + end = time.monotonic() + seconds + while marker not in self.seen: + if time.monotonic() > end: + raise AssertionError( + f"picker never printed {marker!r}; " + f"output={bytes(self.seen).decode(errors='replace')!r}" + ) + self.pump(0.05) + + def write(self, payload: bytes) -> float: + os.write(self.descriptor, payload) + return time.monotonic() - self.start + + def close(self) -> None: + try: + os.kill(self.pid, signal.SIGKILL) + os.waitpid(self.pid, 0) + except OSError: + pass + os.close(self.descriptor) + + def blank_intervals(self, after: float) -> list[float]: + """Milliseconds between an erase and the next visible byte after it.""" + out: list[float] = [] + pending: float | None = None + for offset, chunk in self.events: + if offset < after: + continue + has_erase = ERASE.search(chunk) is not None + visible = PRINTABLE.search(ERASE.sub(b"", chunk)) is not None + if has_erase and visible: + # Erase and replacement in one write: nothing was ever blank. + pending = None + continue + if has_erase: + pending = offset + continue + if pending is not None and visible: + out.append(round(1000 * (offset - pending), 2)) + pending = None + return out + + +def frame_subprocesses(log: Path, after: float, before: float) -> list[str]: + if not log.exists(): + return [] + calls = [] + for line in log.read_text(encoding="utf-8", errors="replace").splitlines(): + stamp, _, rest = line.partition("\t") + try: + when = float(stamp) + except ValueError: + continue + if after <= when <= before: + calls.append(rest) + return calls + + +class LatencyBudgetTests(unittest.TestCase): + def setUp(self) -> None: + self.log = Path(os.environ.get("TMPDIR", "/tmp")) / ( + f"session-kit-shim-{os.getpid()}.log" + ) + if self.log.exists(): + self.log.unlink() + + def tearDown(self) -> None: + if self.log.exists(): + self.log.unlink() + + def shim_env(self) -> dict[str, str]: + return { + "PATH": f"{SHIM}:{os.environ['PATH']}", + "SK_SHIM_LOG": str(self.log), + "SESSION_KIT_PICKER_REFRESH_SECONDS": "0", + } + + def test_one_home_frame_stays_inside_its_subprocess_budget(self) -> None: + for count in (3, 20, 60): + with self.subTest(rows=count): + if self.log.exists(): + self.log.unlink() + fixture = LoginFixture(inventory(*rows(count))) + session = Session(fixture, self.shim_env()) + try: + session.wait_for("❯".encode()) + session.pump(0.4) + before = time.time() + session.write(b"zzz\n") + session.wait_for(b"no such key") + session.pump(0.3) + after = time.time() + finally: + session.close() + fixture.close() + calls = frame_subprocesses(self.log, before, after) + self.assertLessEqual( + len(calls), + FRAME_SUBPROCESS_BUDGET, + msg=f"{count} rows: one keystroke started {calls}", + ) + + def test_a_keystroke_never_leaves_the_screen_blank(self) -> None: + fixture = LoginFixture(inventory(*rows(20))) + session = Session(fixture, {"SESSION_KIT_PICKER_REFRESH_SECONDS": "0"}) + try: + session.wait_for("❯".encode()) + session.pump(0.4) + typed = session.write(b"zzz\n") + session.wait_for(b"no such key") + session.pump(0.3) + intervals = session.blank_intervals(typed) + finally: + session.close() + fixture.close() + for interval in intervals: + self.assertLess( + interval, + BLANK_INTERVAL_BUDGET_MS, + msg=f"blank intervals after one keystroke: {intervals}", + ) + + def test_boot_never_leaves_the_screen_blank(self) -> None: + fixture = LoginFixture(inventory(*rows(20))) + session = Session(fixture, {"SESSION_KIT_PICKER_REFRESH_SECONDS": "0"}) + try: + session.wait_for("open ".encode()) + session.pump(0.3) + intervals = session.blank_intervals(0.0) + finally: + session.close() + fixture.close() + for interval in intervals: + self.assertLess( + interval, + BLANK_INTERVAL_BUDGET_MS, + msg=f"blank intervals during boot: {intervals}", + ) + + def test_typing_stays_visible_across_a_refresh(self) -> None: + """Nothing the refresh does may sit between a keystroke and its echo. + + The picker echoes each character itself in `-echo` mode, so anything it + runs in the typing loop is invisible typing. A whole `shpool_status` + used to run there on every adopted refresh -- 150 ms of dead keyboard + every five seconds -- for one integer the background collector already + had. Only the pending query is slowed here, which is the real one's + shape: a whole process for one number. + """ + fixture = LoginFixture(inventory(*rows(20))) + slow = fixture.base / "slow-status" + slow.write_text( + "#!/usr/bin/env bash\n" + 'if [[ ${1:-} == --recovery-pending-list ]]; then sleep 1; fi\n' + f"exec {fixture.fake_status} \"$@\"\n", + encoding="utf-8", + ) + slow.chmod(0o755) + session = Session( + fixture, + { + "SESSION_KIT_PICKER_REFRESH_SECONDS": "2", + "SESSION_KIT_STATUS_CMD": str(slow), + }, + ) + # Characters the picker's own frames never print, and that no escape + # sequence contains: a marker already on the screen would read as an + # echo that never happened. + typed: dict[str, float] = {} + try: + session.wait_for("❯".encode()) + # Past the opening canonical second, so the picker owns the echo, + # and long enough to cross two refresh cycles while typing. + session.pump(1.2) + for key in "ZQWYVGBAEFIMTU": + typed[key] = session.write(key.encode()) + # Slower than the read timeout on purpose: a loop that never + # times out never reaches its refresh, and the stall this + # measures lives in the refresh. + session.pump(0.6) + session.pump(6.0) + delays = {} + for key, sent in typed.items(): + echoed = [ + offset + for offset, chunk in session.events + if key.encode() in chunk and offset >= sent - 0.01 + ] + self.assertTrue( + echoed, msg=f"{key!r} typed at {sent:.2f}s was never echoed" + ) + delays[key] = 1000 * (echoed[0] - sent) + finally: + session.close() + fixture.close() + worst = max(delays.values()) + self.assertLess( + worst, + TYPING_STALL_BUDGET_MS, + msg=f"echo delays in ms: { {k: round(v, 1) for k, v in delays.items()} }", + ) + +class ResizeTests(unittest.TestCase): + def test_a_resize_repaints_a_quiet_list(self) -> None: + """Geometry is not session content, so no fingerprint can see it. + + The repaint fingerprint hashes row fields on purpose -- an unchanged + estate must not repaint -- which left a resized window holding the old + layout until a key was pressed. On a quiet estate that is forever: rows + wrapped, or half a screen of empty space, with the prompt scrolled off + after a shrink. + """ + fixture = LoginFixture(inventory(*rows(8))) + session = Session(fixture, {"SESSION_KIT_PICKER_REFRESH_SECONDS": "2"}) + try: + session.wait_for("open ".encode()) + # Past the opening canonical second, with nothing typed and nothing + # changing in the estate. + session.pump(1.4) + before = bytes(session.seen).count(b"\x1b[H") + fcntl.ioctl( + session.descriptor, + termios.TIOCSWINSZ, + struct.pack("HHHH", 24, 50, 0, 0), + ) + os.kill(session.pid, signal.SIGWINCH) + session.pump(1.5) + after = bytes(session.seen).count(b"\x1b[H") + finally: + session.close() + fixture.close() + self.assertGreater(after, before, msg="the resize drew no new frame") + + +# One pass of the attention watcher, at 1 Hz, next to a pathological +# directory. Measured on one machine: 8.6 ms with the listing bounded, 512 ms +# with it unbounded (the shipped `sorted(dir.glob("*.json"))[:cap]` reads and +# sorts every entry before the cap throws most of them away). The budget is +# far above the first and far below the second, so a slower machine passes and +# the unbounded shape cannot. +PULSE_PASS_BUDGET_MS = 200.0 +PULSE_PATHOLOGICAL_ENTRIES = 60_000 + + +class PulsePassCostTests(unittest.TestCase): + """The watcher's own docstring says everything is bounded. Make it true. + + A session directory is not only session records: the kit's own markers + accumulate next to them (315 .nameintent, 196 .colorset, 182 .titleset + were sitting in one live ambient directory when this was written, of + 705 entries, only 2 of which were the .json this watches). The cap on how + many files are FINGERPRINTED hid that, because it kept `watched_paths` + flat while the listing underneath it grew without a bound. + """ + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix=".pulse-cost-") + self.addCleanup(self.temporary.cleanup) + self.base = Path(self.temporary.name) + self.home = self.base / "home" + (self.home / ".claude" / "sessions").mkdir(parents=True) + self.environ = { + "SESSION_KIT_STATE_DIR": os.fspath(self.base / "state"), + "SESSION_KIT_ACCOUNT_ROOT": os.fspath(self.base / "accounts"), + "CODEX_HOME": os.fspath(self.base / "codex"), + "FLEET_STATE_DIR": os.fspath(self.base / "fleet"), + } + + def fill(self, count: int, suffix: str) -> None: + sessions = self.home / ".claude" / "sessions" + for index in range(count): + (sessions / f"{index:07d}{suffix}").touch() + + def fastest_pass_ms(self) -> float: + best = [] + for _ in range(3): + started = time.monotonic() + paths = pulse.watched_paths(self.environ, self.home) + best.append((time.monotonic() - started) * 1000) + self.paths = paths + return min(best) + + def test_a_pathological_session_directory_cannot_eat_the_loop(self) -> None: + self.fill(PULSE_PATHOLOGICAL_ENTRIES, ".json") + elapsed = self.fastest_pass_ms() + self.assertLess( + elapsed, + PULSE_PASS_BUDGET_MS, + f"one pass took {elapsed:.0f} ms against a 1 s loop with " + f"{PULSE_PATHOLOGICAL_ENTRIES} entries in one directory", + ) + self.assertLessEqual( + len(self.paths), + pulse.MAX_TRACKED + 16, + "the set that gets fingerprinted grew with the directory", + ) + + def test_the_markers_a_directory_fills_with_are_bounded_too(self) -> None: + """The real shape here: entries that are not the files being watched.""" + self.fill(PULSE_PATHOLOGICAL_ENTRIES, ".nameintent") + elapsed = self.fastest_pass_ms() + self.assertLess( + elapsed, + PULSE_PASS_BUDGET_MS, + f"one pass took {elapsed:.0f} ms with " + f"{PULSE_PATHOLOGICAL_ENTRIES} non-matching entries", + ) + + def test_an_ordinary_directory_is_still_watched_whole(self) -> None: + """The cap is a ceiling, not a sample: today's estate is well under it.""" + self.fill(700, ".json") + sessions = self.home / ".claude" / "sessions" + paths = pulse.watched_paths(self.environ, self.home) + records = [ + path + for path in paths + if path.parent == sessions and path.suffix == ".json" + ] + self.assertEqual(700, len(records)) + + def test_records_buried_in_markers_are_never_dropped_from_the_watch(self) -> None: + """A marker haystack must not blind the watcher to the records. + + scandir order is name-hash order, so the two records can land anywhere + in a 20 000-entry listing; an entries-examined cap once dropped both + while the poll stayed widened on the watcher's word. Coverage of every + record, wherever it hashes, is the contract. + """ + self.fill(20_000, ".nameintent") + sessions = self.home / ".claude" / "sessions" + (sessions / "zz-late-record.json").write_text("{}", encoding="utf-8") + (sessions / "aa-early-record.json").write_text("{}", encoding="utf-8") + paths = pulse.watched_paths(self.environ, self.home) + records = sorted( + path.name + for path in paths + if path.parent == sessions and path.suffix == ".json" + ) + self.assertEqual(["aa-early-record.json", "zz-late-record.json"], records) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_picker_projects.py b/tests/test_picker_projects.py new file mode 100644 index 0000000..d4052e0 --- /dev/null +++ b/tests/test_picker_projects.py @@ -0,0 +1,155 @@ +"""The picker's projects menu: hiding a directory happens on the keystroke, +says what it did, and is reversible from the same menu. + +"Drop number" wrote an ignore row -- the directory left the list and no verb +anywhere in the picker brought it back. These tests hold the two halves of +that fix together: the honest wording, and the restore. Nothing here asks a +question: no screen in the kit does. +""" + +from __future__ import annotations + +import unittest + +from tests.test_login import LoginFixture, display_cells, inventory, row, run_pty, strip_sgr + + +def projects_rows(fixture: LoginFixture) -> list[list[str]]: + return [ + line.split("\t") + for line in fixture.projects.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.startswith("#") + ] + + +class ProjectsMenuTests(unittest.TestCase): + def fixture(self) -> LoginFixture: + return LoginFixture(inventory(row("one", number=1))) + + def test_hiding_a_project_happens_on_the_key_and_says_what_it_did(self) -> None: + fixture = self.fixture() + try: + # m: More · p: Projects · d 1: hide the first row. No question is + # asked anywhere, and the line afterwards names the way back. + code, output = run_pty(fixture, b"m\np\nd 1\n\n\nq\n") + self.assertEqual(0, code) + self.assertNotIn("[y/N]", output) + self.assertIn("Restore it from Projects.", output) + hidden = [entry for entry in projects_rows(fixture) if entry[1] == "ignore"] + self.assertEqual( + [str(fixture.primary_project)], [entry[2] for entry in hidden] + ) + finally: + fixture.close() + + def test_a_hidden_project_is_restorable_from_the_same_menu(self) -> None: + fixture = self.fixture() + try: + code, _ = run_pty(fixture, b"m\np\nd 1\n\n\nq\n") + self.assertEqual(0, code) + hidden = [entry for entry in projects_rows(fixture) if entry[1] == "ignore"] + self.assertEqual( + [str(fixture.primary_project)], [entry[2] for entry in hidden] + ) + + # The menu now offers the way back, and takes it. + code, output = run_pty(fixture, b"m\np\nu\n1\n\n\nq\n") + self.assertEqual(0, code) + self.assertIn("restore u", output) + self.assertIn("Hidden directories", output) + self.assertIn("No longer ignoring", output) + self.assertEqual( + [], + [entry for entry in projects_rows(fixture) if entry[1] == "ignore"], + ) + finally: + fixture.close() + + def test_the_menu_offers_no_restore_when_nothing_is_hidden(self) -> None: + fixture = self.fixture() + try: + code, output = run_pty(fixture, b"m\np\n\n\nq\n") + self.assertEqual(0, code) + self.assertIn("hide d number", output) + self.assertNotIn("restore u", output) + finally: + fixture.close() + + def test_wide_project_aliases_keep_the_path_column_aligned(self) -> None: + fixture = self.fixture() + try: + with fixture.projects.open("a", encoding="utf-8") as handle: + handle.write(f"東京\tclaude\t{fixture.other}\n") + code, output = run_pty(fixture, b"m\np\n\n\nq\n", columns=120) + self.assertEqual(0, code) + plain = strip_sgr(output) + lines = [ + line + for line in plain.splitlines() + if str(fixture.primary_project) in line or str(fixture.other) in line + ] + self.assertGreaterEqual(len(lines), 2, plain) + starts = { + display_cells(line[: line.index(path)]) + for line in lines[-2:] + for path in (str(fixture.primary_project), str(fixture.other)) + if path in line + } + self.assertEqual(1, len(starts), lines[-2:]) + finally: + fixture.close() + + # The branch's test_an_unusable_provider_keeps_the_form_it_was_typed_into + # asserted the add-time provider question, which the projects ruling + # retired: the provider is chosen when a session starts. Dropped in the + # merge; the two tests below carry the current contract. + def test_adding_a_project_never_asks_which_provider_opens_it(self) -> None: + """Two answers, and neither of them is a provider. + + The third question used to be binding: answering it is what made a + directory also worked on with the other provider need a second row + under a second short name. It is asked when a session starts instead, + so the row that lands here carries no provider at all. + """ + fixture = self.fixture() + extra = fixture.base / "extra-project" + extra.mkdir() + try: + code, output = run_pty( + fixture, + b"m\np\na\n" + str(extra).encode() + b"\nnewalias\n\n\nq\n", + ) + self.assertEqual(0, code) + self.assertNotIn("Opens as claude, codex, or shell", output) + self.assertIn( + "Claude, Codex, or a shell is chosen when you start a session", output + ) + self.assertIn(["newalias", "any", str(extra)], projects_rows(fixture)) + finally: + fixture.close() + + def test_a_directory_already_listed_is_refused_by_the_name_it_has(self) -> None: + """One directory, one entry, and the refusal says which entry. + + Adding it again under a second short name is exactly how a directory + ended up listed twice, once per provider. + """ + fixture = self.fixture() + try: + code, output = run_pty( + fixture, + b"m\np\na\n" + str(fixture.other).encode() + b"\nsecondname\n\n\nq\n", + ) + self.assertEqual(0, code) + self.assertIn("already on the list as other", output) + self.assertEqual( + [["main", "claude", str(fixture.primary_project)], + ["other", "codex", str(fixture.other)]], + projects_rows(fixture), + ) + finally: + fixture.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_picker_rows_grammar.py b/tests/test_picker_rows_grammar.py new file mode 100644 index 0000000..a357b57 --- /dev/null +++ b/tests/test_picker_rows_grammar.py @@ -0,0 +1,1011 @@ +"""The six rulings about what a session row says, as tests. + +Most of these fail on the commit before the change and pass after it, and that +is the point of the file. Not all of them, and the difference is worth naming +rather than glossing: a handful are REGRESSION GUARDS over behaviour that was +already right and had to stay right, `b` already worked on the account screen, +switch mode already refused to default, the rename prompt already declined to +claim `b`, and no third state existed to begin with. Those pass at both commits +by design. A reviewer counting differentials should not count them, and should +not read this file as claiming they are. + +To reproduce the differential: copy this file AND +`lib/sessionkit_inventory/session_model.py` into a tree at the older commit, +without the module the whole file fails at import and proves nothing per test. + +They are grouped by the ruling they hold, and each one states the defect it +exists to catch rather than the code it happens to touch: + +1. the model column was empty on every row, and had to follow a model changed + INSIDE a session, not the one it was launched with +2. rows carried two times and truncated at a different point on every line +3. two providers described one situation in two words +4. the account column stays +5. one grammar: `b` goes back, Enter takes the recommended choice +6. columns line up, wide characters included +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unicodedata +import unittest +from unittest import mock + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, os.fspath(REPO / "lib")) + +from sessionkit_inventory import labels # noqa: E402 +from sessionkit_inventory import render # noqa: E402 +from sessionkit_inventory import session_model # noqa: E402 +from sessionkit_inventory.model import canonical_session_order_key # noqa: E402 +from sessionkit_tui import frame as tui_frame # noqa: E402 +from sessionkit_tui import rows as tui_rows # noqa: E402 +from sessionkit_tui.model import parse_inventory # noqa: E402 + + +NOW_MS = 1_800_000_000_000 + + +def cells(text: str) -> int: + return sum( + 0 + if unicodedata.combining(character) + else 2 + if unicodedata.east_asian_width(character) in {"W", "F"} + else 1 + for character in text + ) + + +def row( + title: str, + number: int, + *, + provider: str = "claude", + **extra: object, +) -> dict: + item = { + "row": number, + "terminal_number": number, + "shpool_id": f"s-{number}", + "shpool_id_raw": f"s-{number}", + "display_shpool_id": f"s-{number}", + "availability": "ready", + "provider": provider, + "display_provider": provider, + "setup_incomplete": False, + "identity": { + "uuid": f"00000000-0000-4000-8000-00000000000{number}", + "pid": 1000 + number, + "confidence": "exact", + }, + "title": title, + "display_title": title, + "native_title": title, + "cwd": "/srv/project", + "account_alias": "primary", + "agent_status": "running", + "needs_you": False, + "subagents": [], + "active_subagent_count": 0, + "started_at_unix_ms": NOW_MS - 11_340_000, + "process_age_seconds": 11_340, + "recent_output_at_unix_ms": NOW_MS - 11_340_000, + "recent_output_age_seconds": 11_340, + "recovery": {"available": False}, + "diagnostics": [], + } + item.update(extra) + return item + + +def document(*items: dict) -> dict: + return { + "schema_version": 1, + "source": "live", + "stale": False, + "sessions": list(items), + "outside_agents": [], + "_picker": {}, + } + + +def picker_lines(*items: dict, columns: int = 130, compact: bool = False) -> list[str]: + text = render.render_picker_page( + document(*items), + page=1, + page_size=20, + style_enabled=False, + compact=compact, + columns=columns, + now_ms=NOW_MS, + ) + return [line for line in text.splitlines() if " | " in line] + + +def list_lines(*items: dict, columns: int = 130) -> list[str]: + previous = os.environ.get("COLUMNS") + os.environ["COLUMNS"] = str(columns) + try: + text = render.render_inventory( + document(*items), color_enabled=lambda: False, now_ms=NOW_MS + ) + finally: + if previous is None: + os.environ.pop("COLUMNS", None) + else: + os.environ["COLUMNS"] = previous + return [line for line in text.splitlines() if " | " in line] + + +def cursor_lines(*items: dict, columns: int = 130) -> list[str]: + parsed = parse_inventory(document(*items)) + rows = tui_rows.build_rows(parsed.sessions, now_ms=NOW_MS) + text = tui_frame.build_frame( + tui_frame.Screen(rows=rows), width=columns, height=40 + ).joined() + numbers = {item["terminal_number"] for item in items} + return [ + line + for line in text.splitlines() + if (match := __import__("re").match(r"^\s*(\d+)\s\s", line)) + and int(match.group(1)) in numbers + ] + + +# --------------------------------------------------------------- ruling 1 + + +class ModelColumnTests(unittest.TestCase): + """The model cell was `, ` on all twelve of the operator's live sessions.""" + + def test_the_row_shows_the_model_a_session_is_running(self) -> None: + for line in picker_lines( + row("Ledger Sync Audit", 1, display_model="Opus 5", model="claude-opus-5"), + row( + "Codex worker", + 2, + provider="codex", + display_model="GPT-5-Codex", + model="gpt-5-codex", + ), + ): + self.assertNotIn(f"| {labels.MISSING} | {labels.MISSING} |", line) + joined = "\n".join(picker_lines( + row("Ledger Sync Audit", 1, display_model="Opus 5", model="claude-opus-5"), + )) + self.assertIn("Opus 5", joined) + self.assertNotIn("claude-opus-5", joined) + + def test_a_model_with_no_evidence_says_so_instead_of_showing_a_dash(self) -> None: + unreadable = picker_lines( + row("Claude one", 1, model_state=labels.MODEL_STATE_UNREADABLE) + )[0] + self.assertIn("pe…", unreadable) + fresh = picker_lines( + row("Claude two", 1, model_state=labels.MODEL_STATE_NO_REPLY_YET) + )[0] + self.assertIn("pe…", fresh) + shell = picker_lines(row("A shell", 1, provider="shell"))[0] + self.assertIn(labels.MODEL_NOT_APPLICABLE, shell) + + def test_a_model_identifier_becomes_a_name_a_person_reads(self) -> None: + for identifier, expected in ( + ("claude-opus-5", "Opus 5"), + ("claude-fable-5", "Fable 5"), + ("claude-sonnet-4-5-20250929", "Sonnet 4.5"), + ("claude-3-5-haiku-20241022", "Haiku 3.5"), + ): + with self.subTest(identifier=identifier): + self.assertEqual( + expected, session_model.human_model_name("claude", identifier) + ) + for identifier, expected in ( + ("gpt-5-codex", "GPT-5-Codex"), + ("gpt-5.6-sol", "GPT-5.6-Sol"), + ("gpt-5.1-codex-max", "GPT-5.1-Codex-Max"), + ): + with self.subTest(identifier=identifier): + self.assertEqual( + expected, session_model.human_model_name("codex", identifier) + ) + + +class ModelFollowsTheSessionTests(unittest.TestCase): + """"It also needs to change when I change the model within the session.""" + + def claude_transcript(self, path: Path, *models: str) -> None: + with open(path, "a", encoding="utf-8") as handle: + for model in models: + handle.write( + json.dumps( + { + "type": "assistant", + "message": {"model": model, "content": []}, + } + ) + + "\n" + ) + + def test_a_model_changed_inside_a_claude_session_is_the_one_shown(self) -> None: + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + transcript = root / "conversation.jsonl" + cache = root / "state" + self.claude_transcript(transcript, "claude-opus-5", "claude-opus-5") + model, found = session_model.current_model( + "claude", key="one", locate=lambda: transcript, cache_dir=cache + ) + self.assertTrue(found) + self.assertEqual("claude-opus-5", model) + + # The person types /model inside the session. Nothing about the + # process changes; the next assistant record does. + self.claude_transcript(transcript, "claude-fable-5") + model, _ = session_model.current_model( + "claude", key="one", locate=lambda: transcript, cache_dir=cache + ) + self.assertEqual("claude-fable-5", model) + + def test_a_model_changed_inside_a_codex_session_is_the_one_shown(self) -> None: + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + rollout = root / "rollout.jsonl" + cache = root / "state" + with open(rollout, "w", encoding="utf-8") as handle: + handle.write( + json.dumps({"type": "session_meta", "payload": {"id": "x"}}) + "\n" + ) + handle.write( + json.dumps( + {"type": "turn_context", "payload": {"model": "gpt-5-codex"}} + ) + + "\n" + ) + model, found = session_model.current_model( + "codex", key="two", locate=lambda: rollout, cache_dir=cache + ) + self.assertTrue(found) + self.assertEqual("gpt-5-codex", model) + + with open(rollout, "a", encoding="utf-8") as handle: + handle.write( + json.dumps( + { + "type": "event_msg", + "payload": { + "type": "thread_settings_applied", + "thread_settings": {"model": "gpt-5.6-sol"}, + }, + } + ) + + "\n" + ) + model, _ = session_model.current_model( + "codex", key="two", locate=lambda: rollout, cache_dir=cache + ) + self.assertEqual("gpt-5.6-sol", model) + + def test_a_resumed_read_costs_only_the_bytes_the_provider_appended(self) -> None: + """The reason this can run on every refresh at all.""" + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + transcript = root / "conversation.jsonl" + cache = root / "state" + self.claude_transcript(transcript, *["claude-opus-5"] * 200) + located: list[Path] = [] + + def locate() -> Path: + located.append(transcript) + return transcript + + session_model.current_model( + "claude", key="three", locate=locate, cache_dir=cache + ) + self.assertEqual(1, len(located)) + # Nothing appended: the file is not read again, and the expensive + # part -- finding the transcript at all -- is not repeated. + session_model.current_model( + "claude", key="three", locate=locate, cache_dir=cache + ) + self.assertEqual(1, len(located)) + + def test_a_synthetic_assistant_record_is_not_a_model(self) -> None: + """Measured on a live transcript: Claude writes `<synthetic>` itself.""" + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + transcript = root / "conversation.jsonl" + self.claude_transcript(transcript, "claude-opus-5", "<synthetic>") + model, _ = session_model.current_model( + "claude", key="four", locate=lambda: transcript, cache_dir=None + ) + self.assertEqual("claude-opus-5", model) + + +# --------------------------------------------------------------- ruling 2 + + +class OneTimeColumnTests(unittest.TestCase): + """Rows carried `recent output Xh Ym ago` AND `process age Xh Ym`.""" + + def sample(self) -> tuple[dict, ...]: + return ( + row("Ledger Sync Audit", 1, needs_you=True, agent_status="needs your reply"), + row("Orphaned Record Audit", 2, recent_output_age_seconds=0, + recent_output_at_unix_ms=NOW_MS), + row("Codex worker", 3, provider="codex", agent_status="idle"), + ) + + def test_a_row_carries_exactly_one_time(self) -> None: + for lines in (picker_lines(*self.sample()), list_lines(*self.sample())): + self.assertEqual(3, len(lines)) + for line in lines: + self.assertEqual(1, line.count(labels.LAST_ACTIVE), line) + self.assertNotIn("process age", line) + self.assertNotIn("recent output", line) + self.assertNotIn("opened", line) + + def test_every_row_words_that_time_the_same_way(self) -> None: + for lines in (picker_lines(*self.sample()), list_lines(*self.sample())): + starts = {line.index(labels.LAST_ACTIVE) for line in lines} + self.assertEqual(1, len(starts), lines) + + # A worst-case row shape: the longest session name a row has to carry, + # the longest model string, and a wide-character (CJK) title. A fixture + # narrower than the widest real row is how the previous version of this + # test passed while the time column was missing on screen: it needed 101 + # columns to show a time and this shape needs more. + OPERATOR_WIDTHS = (80, 100, 120, 140) + + @staticmethod + def has_time(line: str) -> bool: + # The compact token is the ruled narrow-width form; it is still an + # explicit age, not an absent or clipped label. + return labels.LAST_ACTIVE in line or "3h 9m" in line + + def operator_shape(self) -> tuple[dict, ...]: + return ( + row("Ledger Sync Audit", 1, needs_you=True, + agent_status="needs your reply", display_model="Opus 5"), + row("Nightly Index Rebuild Coordination Planner", 2, provider="codex", + agent_status="idle", display_model="GPT-5.6-Sol-Preview"), + row("\u5728\u5eab\u7ba1\u7406\u30b7\u30b9\u30c6\u30e0\u70b9\u691c", 3, + display_model="Sonnet 4.5"), + row("A plain shell", 4, provider="shell"), + ) + + def test_the_time_is_on_every_row_at_the_widths_that_matter(self) -> None: + """The ruling was keep ONE time column, not drop both. + + The version of this test that shipped in `e1e3edd` accepted "no row has + a time" as a pass, and its fixture was too small to reach the width + where the column disappeared. It was green while no row on screen had + a time. This one names the widths and demands the column. + """ + for columns in self.OPERATOR_WIDTHS: + for compact in (False, True): + with self.subTest(columns=columns, compact=compact): + lines = picker_lines( + *self.operator_shape(), columns=columns, compact=compact + ) + self.assertEqual(4, len(lines)) + missing = [line for line in lines if not self.has_time(line)] + self.assertEqual([], missing, "\n".join(lines)) + + def test_the_widest_row_costs_its_own_tail_and_not_the_column(self) -> None: + """The one-character cliff: a 41-character name used to delete the time + from every row on the page at 120 columns.""" + for length in (20, 39, 40, 41, 42, 60, 90): + with self.subTest(longest_title=length): + items = list(self.operator_shape()) + items[1] = row("x" * length, 2, provider="codex", + display_model="GPT-5.6-Sol-Preview") + lines = picker_lines(*items, columns=120) + self.assertTrue( + all(self.has_time(line) for line in lines), "\n".join(lines) + ) + + def test_every_width_60_to_200_keeps_a_time_on_both_pickers(self) -> None: + """The compact tier closes the old 60–91 all-row disappearance.""" + for columns in range(60, 201): + with self.subTest(columns=columns): + for lines in ( + picker_lines(*self.operator_shape(), columns=columns), + cursor_lines(*self.operator_shape(), columns=columns), + ): + self.assertEqual(4, len(lines), "\n".join(lines)) + self.assertTrue( + all(self.has_time(line) for line in lines), + "\n".join(lines), + ) + + def test_the_narrowest_supported_width_shows_the_time(self) -> None: + def threshold(compact: bool) -> int: + for columns in range(60, 241): + lines = picker_lines( + *self.operator_shape(), columns=columns, compact=compact + ) + if lines and all(self.has_time(line) for line in lines): + return columns + raise AssertionError("no width shows the time") + + for compact in (False, True): + with self.subTest(compact=compact): + self.assertEqual(60, threshold(compact)) + + def test_the_two_surfaces_use_one_phrase(self) -> None: + self.assertEqual("last active 3h 9m ago", labels.last_active(11_340)) + self.assertEqual("last active just now", labels.last_active(4)) + self.assertEqual("last active pending", labels.last_active(None)) + + +# --------------------------------------------------------------- ruling 3 + + +class OneStateVocabularyTests(unittest.TestCase): + """Four Claude rows said `needs you`; four Codex rows in the same + situation said `idle`.""" + + def test_notification_idle_is_retired_but_transcript_idle_is_a_state(self) -> None: + self.assertEqual( + ( + labels.QUESTION, + labels.WAITING_ON_YOU, + labels.WORKING, + labels.IDLE, + labels.SETUP_INCOMPLETE, + labels.STATUS_UNAVAILABLE, + ), + labels.SESSION_STATES, + ) + + def test_both_providers_say_one_word_for_one_situation(self) -> None: + claude = {"agent_status": labels.STATUS_NEEDS_YOUR_REPLY, "needs_you": True} + codex = {"agent_status": labels.STATUS_IDLE, "needs_you": False} + state = lambda item: labels.session_state(item, stall_seconds=2700) # noqa: E731 + self.assertEqual(labels.WAITING_ON_YOU, state(claude)) + self.assertEqual(labels.WAITING_ON_YOU, state(codex)) + self.assertEqual( + labels.IDLE, + state({**codex, "transcript_idle": True}), + ) + + def test_the_rendered_rows_agree_too(self) -> None: + lines = picker_lines( + row("Claude at its prompt", 1, needs_you=True, + agent_status="needs your reply"), + row("Codex at its prompt", 2, provider="codex", agent_status="idle"), + ) + self.assertEqual(2, len(lines)) + for line in lines: + self.assertIn(labels.WAITING_ON_YOU, line) + self.assertNotIn("idle", line) + + def test_the_cursor_picker_agrees_with_the_text_picker(self) -> None: + parsed = parse_inventory( + document( + row("Claude at its prompt", 1, needs_you=True, + agent_status="needs your reply"), + row("Codex at its prompt", 2, provider="codex", agent_status="idle"), + ) + ) + self.assertEqual( + [labels.WAITING_ON_YOU, labels.WAITING_ON_YOU], + [session.state_word() for session in parsed.sessions], + ) + + def test_no_third_state_was_invented(self) -> None: + """A `finished` state was considered and declined.""" + for word in labels.STATE_WORDS.values(): + self.assertIn(word, labels.SESSION_STATES) + self.assertNotIn("finished", labels.SESSION_STATES) + + +class OneListOneTruthTests(unittest.TestCase): + """The two surfaces computed the same cell separately and disagreed on 14 + of them. They read one function now, and this walks every state a collector + can write plus the shapes that broke.""" + + def rows(self) -> tuple[dict, ...]: + base = dict(display_model="Opus 5", model="claude-opus-5") + cases = [ + row("Running", 1, agent_status="running", **base), + row("Idle", 2, agent_status="idle", **base), + row("Needs reply", 3, agent_status="needs your reply", + needs_you=True, **base), + row("Reply optional", 4, agent_status="reply optional", **base), + row("Provider exited", 5, agent_status="provider exited", **base), + row("Unknown", 6, agent_status="unknown", **base), + row("State unavailable", 7, agent_status="state unavailable", **base), + row("No status at all", 8, agent_status="", **base), + row("Setup incomplete", 9, setup_incomplete=True, **base), + row("Vendor busy", 10, agent_status="busy", **base), + row("A word from the future", 11, agent_status="brand-new-state", **base), + row("No reply yet", 12, display_model="", model="", + model_state=labels.MODEL_STATE_NO_REPLY_YET), + row("Unreadable", 13, display_model="", model="", + model_state=labels.MODEL_STATE_UNREADABLE), + row("A shell", 14, provider="shell", display_model="", model=""), + row("Quiet past the stall window", 15, agent_status="running", + recent_output_age_seconds=99_999, + recent_output_at_unix_ms=NOW_MS - 99_999_000, **base), + row("Future stamp", 16, recent_output_at_unix_ms=NOW_MS + 600_000, + recent_output_age_seconds=900, **base), + ] + return tuple(cases) + + def test_the_two_surfaces_agree_on_every_cell(self) -> None: + stall = render.stall_threshold_seconds() + items = self.rows() + shell_items = sorted(items, key=canonical_session_order_key) + shell = [ + ( + item["terminal_number"], + labels.session_state(item, stall_seconds=stall), + labels.model_cell(item), + labels.row_last_active(item, NOW_MS), + ) + for item in shell_items + ] + drawn = tui_rows.build_rows( + parse_inventory(document(*items)).sessions, now_ms=NOW_MS, stall=stall + ) + tui = [ + (drawn_row.number, drawn_row.state, drawn_row.model, drawn_row.age) + for drawn_row in drawn + if drawn_row.kind == tui_rows.SESSION + ] + self.assertEqual(shell, tui) + + def test_setup_incomplete_reaches_the_cursor_picker(self) -> None: + """It could not appear there at all: `state_word` built a synthetic dict + without the key `session_state` tests first.""" + parsed = parse_inventory(document(row("Half-built", 1, setup_incomplete=True))) + self.assertEqual( + labels.SETUP_INCOMPLETE, parsed.sessions[0].state_word() + ) + + def test_a_session_with_no_reply_yet_is_not_called_unreadable(self) -> None: + """The cursor picker guessed from the provider and reported a healthy + new conversation as a broken transcript.""" + parsed = parse_inventory( + document( + row("Fresh", 1, display_model="", model="", + model_state=labels.MODEL_STATE_NO_REPLY_YET) + ) + ) + self.assertEqual(labels.MODEL_NO_REPLY_YET, parsed.sessions[0].model_text) + + +class TotalStateMappingTests(unittest.TestCase): + """Two words, and no collector word able to leak past them.""" + + def test_every_collector_status_maps_into_the_vocabulary(self) -> None: + for status in labels.COLLECTOR_STATUSES: + with self.subTest(status=status): + self.assertIn(labels.state_word(status), labels.SESSION_STATES) + + def test_a_word_this_file_does_not_know_is_never_printed(self) -> None: + for status in ("busy", "brand-new-state", "Working On It", "", "idle "): + with self.subTest(status=status): + self.assertIn(labels.state_word(status), labels.SESSION_STATES) + + def test_no_row_can_render_a_word_outside_the_vocabulary(self) -> None: + stall = render.stall_threshold_seconds() + for status in (*labels.COLLECTOR_STATUSES, "busy", "anything at all", ""): + for needs_you in (True, False): + with self.subTest(status=status, needs_you=needs_you): + state = labels.session_state( + {"agent_status": status, "needs_you": needs_you}, + stall_seconds=stall, + ) + self.assertIn(state, labels.SESSION_STATES) + + def test_the_two_words_carry_every_ordinary_session(self) -> None: + """`pending` and `unknown` are the kit saying it + cannot read a row -- never one of the two states.""" + stall = render.stall_threshold_seconds() + ordinary = ("running", "working", "reply optional", "idle", + "needs your reply", "provider exited") + for status in ordinary: + with self.subTest(status=status): + self.assertIn( + labels.session_state( + {"agent_status": status, "needs_you": False}, + stall_seconds=stall, + ), + (labels.WAITING_ON_YOU, labels.WORKING), + ) + + +class ModelTruthTests(unittest.TestCase): + """A wrong model name is worse than the empty column they complained about.""" + + def test_an_identifier_this_kit_does_not_understand_keeps_its_raw_form(self) -> None: + for provider, identifier in ( + ("claude", "claude-opus-experimental-5"), + ("claude", "claude-sonnet-enterprise-4-9"), + ("codex", "gpt-5-quantumthing"), + ("codex", "some-vendor-model-x"), + ): + with self.subTest(identifier=identifier): + self.assertEqual( + identifier, session_model.human_model_name(provider, identifier) + ) + + def test_the_launch_argument_is_never_shown_as_the_live_model(self) -> None: + """No local record is proof of nothing, not proof that the model a + session was started with is the one it runs.""" + import session_inventory + + item = { + "provider": "claude", + "identity": {"uuid": "11111111-2222-4333-8444-555555555555", "pid": 42}, + } + table = {42: {"cmdline": ["claude", "--model", "claude-opus-5"], + "requested_model": "claude-opus-5"}} + fields = session_inventory._live_model_fields(item, table, None) + self.assertEqual("", fields["model"]) + self.assertEqual("", fields["display_model"]) + self.assertEqual("claude-opus-5", fields["launch_model"]) + self.assertEqual( + labels.MODEL_UNREADABLE, labels.model_cell({**item, **fields}) + ) + + def test_a_transcript_replaced_at_the_same_path_is_re_read(self) -> None: + """An inode can be reused after unlink+create, so it is not an identity + on its own; the cache must not answer from the deleted file. + + Filesystems are free not to reuse the inode immediately. Pin that + input here so this exercises the same-inode case on every runner. + """ + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + cache = root / "cache" + transcript = root / "t.jsonl" + real_fstat = os.fstat + generation = 1 + + def pinned_fstat(descriptor: int) -> SimpleNamespace: + info = real_fstat(descriptor) + return SimpleNamespace( + st_ino=12345, + st_dev=info.st_dev, + st_ctime_ns=generation, + st_size=info.st_size, + ) + + def write(model: str) -> None: + transcript.write_text( + json.dumps( + {"type": "assistant", "message": {"model": model, "content": []}} + ) + + "\n", + encoding="utf-8", + ) + + with mock.patch.object(session_model.os, "fstat", side_effect=pinned_fstat): + write("claude-fable-5") + self.assertEqual( + "claude-fable-5", + session_model.current_model( + "claude", key="k", locate=lambda: transcript, cache_dir=cache + )[0], + ) + os.unlink(transcript) + write("claude-sonnet-4-5-20250929") + generation = 2 + self.assertEqual( + "claude-sonnet-4-5-20250929", + session_model.current_model( + "claude", key="k", locate=lambda: transcript, cache_dir=cache + )[0], + ) + + def test_a_first_record_caught_mid_write_is_read_once_it_completes(self) -> None: + """The cache claimed the file size as scanned when nothing complete had + been read, so the resumed reader started inside the record and the + conversation said `no reply yet` forever.""" + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + cache = root / "cache" + transcript = root / "t.jsonl" + transcript.write_text( + '{"type":"assistant","message":{"model":"claude-fable-5"', + encoding="utf-8", + ) + self.assertEqual( + "", + session_model.current_model( + "claude", key="p", locate=lambda: transcript, cache_dir=cache + )[0], + ) + with open(transcript, "a", encoding="utf-8") as handle: + handle.write(',"content":[]}}\n') + self.assertEqual( + "claude-fable-5", + session_model.current_model( + "claude", key="p", locate=lambda: transcript, cache_dir=cache + )[0], + ) + + def test_a_clock_that_runs_ahead_does_not_make_every_row_look_fresh(self) -> None: + """A future stamp used to clamp to zero and read `just now`, which turns + a screen of waiting sessions into a screen of busy ones.""" + item = row("Skewed", 1, recent_output_at_unix_ms=NOW_MS + 600_000, + recent_output_age_seconds=900) + self.assertEqual(900, labels.last_active_seconds(item, NOW_MS)) + self.assertEqual("last active 15m ago", labels.row_last_active(item, NOW_MS)) + + +# --------------------------------------------------------------- ruling 4 + + +class AccountColumnTests(unittest.TestCase): + def test_the_account_column_stays_on_every_row(self) -> None: + for lines in ( + picker_lines(row("Alpha", 1), row("Bravo", 2, account_alias="wren")), + list_lines(row("Alpha", 1), row("Bravo", 2, account_alias="wren")), + ): + self.assertTrue(any("primary" in line for line in lines), lines) + self.assertTrue(any("wren" in line for line in lines), lines) + + +# --------------------------------------------------------------- ruling 5 + + +def account_choice(action: str, payload: dict, mode: str, argument: str): + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle: + json.dump(payload, handle) + path = handle.name + try: + return subprocess.run( + [ + "bash", + "-c", + f'. "{REPO}/lib/sh/shpool_login_actions.sh"; ' + f'account_choice_ui "$1" "$2" "$3" "$4"', + "bash", + action, + path, + mode, + argument, + ], + capture_output=True, + text=True, + timeout=60, + env={**os.environ, "COLUMNS": "140"}, + ) + finally: + os.unlink(path) + + +def accounts(*, recommendation: str = "") -> dict: + return { + "choices": [ + { + "alias": "primary", + "email": "primary@example.com", + "plan": "max", + "eligible": True, + "state": "ready", + "u5h": 0.25, + "u7d": 0.48, + "recommended": recommendation == "primary", + }, + { + "alias": "wren", + "email": "wren@example.com", + "plan": "max", + "eligible": True, + "state": "ready", + "u5h": 0.06, + "u7d": 0.10, + "recommended": recommendation == "wren", + }, + ], + "recommendation": recommendation, + "roster_state": "fresh", + "advice_fresh": True, + } + + +class OneGrammarTests(unittest.TestCase): + """Enter takes the most likely option, and every screen names it. + + It used to mean `back` on one screen and `use the default` on the next, + with nothing on the screen saying which. + """ + + SCREENS = { + "picker help": ("lib/sh/shpool_login_render.sh", "↵ back · b back ❯ "), + "needs you": ("lib/sh/shpool_login_render.sh", "↵ back · b back · help ?"), + "more": ("lib/sh/shpool_login_render.sh", "↵ back · b back"), + "open elsewhere": ( + "lib/sh/shpool_login_actions.sh", + "↵ move it here · b back", + ), + "provider": ( + "lib/sh/shpool_login_actions.sh", + "↵ Claude Code · b back", + ), + "model": ( + "lib/sh/shpool_login_actions.sh", + "Model number or exact name · ↵ back · b back ❯ ", + ), + "project": ("lib/sh/shpool_login_actions.sh", "use that project · b back"), + "closed sessions": ( + "lib/sh/shpool_login_recovery.sh", + "restore numbers · one with no number goes by the selector beside it · restore all a · ↵ back · b back", + ), + "projects": ("lib/sh/shpool_login_projects.sh", "↵ back · b back"), + "not on your list": ( + "lib/sh/shpool_login_projects.sh", + "add numbers · add all a · never list x · ↵ back · b back", + ), + "hidden directories": ( + "lib/sh/shpool_login_projects.sh", + "restore number · ↵ back · b back", + ), + } + + def test_every_picker_screen_names_the_back_key(self) -> None: + for screen, (relative, literal) in self.SCREENS.items(): + with self.subTest(screen=screen): + source = (REPO / relative).read_text(encoding="utf-8") + self.assertIn(literal, source) + + # Two prompts take a typed NAME, where `b` is a one-letter name and not a + # key. They say what Enter does and do not claim a key they cannot honour. + TYPED_NAME_PROMPTS = ("New name (↵ back) ❯",) + + def test_no_screen_still_offers_back_without_naming_b(self) -> None: + """The old grammar: `↵ back` with no way to type it.""" + for relative in {path for path, _ in self.SCREENS.values()}: + for number, line in enumerate( + (REPO / relative).read_text(encoding="utf-8").splitlines(), 1 + ): + if "↵ back" not in line or line.lstrip().startswith("#"): + continue + if any(prompt in line for prompt in self.TYPED_NAME_PROMPTS): + continue + with self.subTest(where=f"{relative}:{number}"): + self.assertIn("b back", line, line) + + def test_a_prompt_that_takes_a_name_does_not_claim_the_back_key(self) -> None: + source = (REPO / "lib/sh/shpool_login_actions.sh").read_text(encoding="utf-8") + self.assertIn("New name (↵ back) ❯", source) + self.assertNotIn("New name (↵ back · b back)", source) + + def test_the_top_screen_names_the_way_out(self) -> None: + source = (REPO / "lib/sh/shpool_login_render.sh").read_text(encoding="utf-8") + # Enter opens the top row here, so the door out is named for the keys + # that take it -- q and b -- and not for Enter. + self.assertIn("quit q or b", source) + self.assertIn("quit $(picker_green q) or $(picker_green b)", source) + self.assertNotIn("back %s or %s", source) + + def test_b_goes_back_from_the_account_screen(self) -> None: + for typed in ("b", "B", "back"): + with self.subTest(typed=typed): + result = account_choice("pick", accounts(), "use", typed) + self.assertEqual(2, result.returncode, result.stderr) + self.assertEqual("", result.stdout.strip()) + + def test_enter_takes_the_recommended_account(self) -> None: + result = account_choice("pick", accounts(recommendation="wren"), "use", "") + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("wren", result.stdout.strip()) + + def test_the_footer_says_what_enter_takes(self) -> None: + result = account_choice( + "render", accounts(recommendation="wren"), "use", "Account" + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("↵ use wren", result.stdout) + self.assertIn("b back", result.stdout) + + def test_a_screen_with_nothing_to_recommend_says_what_enter_does(self) -> None: + result = account_choice("render", accounts(), "use", "Account") + self.assertIn("↵ back", result.stdout) + self.assertIn("b back", result.stdout) + taken = account_choice("pick", accounts(), "use", "") + self.assertEqual(2, taken.returncode) + + def test_switching_a_running_session_never_defaults_to_a_change(self) -> None: + result = account_choice( + "pick", accounts(recommendation="wren"), "switch", "" + ) + self.assertEqual(2, result.returncode) + self.assertEqual("", result.stdout.strip()) + + +# --------------------------------------------------------------- ruling 6 + + +class AlignedColumnsTests(unittest.TestCase): + """`primary: primary@example.com | max | ready | …` ragged-edged after the + variable-width name.""" + + def pipe_columns(self, line: str) -> tuple[int, ...]: + found: list[int] = [] + width = 0 + for character in line: + if character == "|": + found.append(width) + width += cells(character) + return tuple(found) + + def test_the_account_screen_is_columns(self) -> None: + payload = accounts(recommendation="primary") + payload["choices"].append( + { + "alias": "a-very-long-alias-indeed", + "email": "someone.with.a.long.address@example.com", + "plan": "pro", + "eligible": False, + "state": "blocked: health expired", + } + ) + payload["choices"].append( + { + "alias": "東京アカウント", + "email": "tokyo@example.jp", + "plan": "max", + "eligible": True, + "state": "ready", + "u5h": 0.5, + "u7d": 0.5, + } + ) + result = account_choice("render", payload, "use", "Account") + self.assertEqual(0, result.returncode, result.stderr) + rows_shown = [ + line + for line in result.stdout.splitlines() + if line.startswith(" ") and "|" in line + ] + self.assertEqual(4, len(rows_shown), result.stdout) + columns = {self.pipe_columns(line)[:3] for line in rows_shown} + self.assertEqual(1, len(columns), rows_shown) + + def test_the_session_rows_are_columns_including_a_wide_name(self) -> None: + items = ( + row("x", 1, display_model="Opus 5"), + row("a much longer session name than that one", 2, + display_model="GPT-5.6-Sol", provider="codex"), + row("東京の作業セッション", 3, display_model="Fable 5"), + ) + for lines in (picker_lines(*items), list_lines(*items)): + columns = {self.pipe_columns(line) for line in lines} + self.assertEqual(1, len(columns), lines) + + def test_padding_counts_columns_and_not_characters(self) -> None: + self.assertEqual(4, cells("東京")) + self.assertEqual("東京 ", render._pad("東京", 6)) + self.assertEqual("ab ", render._pad("ab", 6)) + + def test_the_cursor_picker_pads_in_columns_too(self) -> None: + from sessionkit_tui import frame + + self.assertEqual("東京 ", frame._pad("東京", 6)) + parsed = parse_inventory( + document( + row("x", 1, display_model="Opus 5"), + row("東京の作業", 2, display_model="Fable 5"), + ) + ) + drawn = tui_rows.build_rows(parsed.sessions) + widths = frame.detail_widths(drawn) + self.assertGreaterEqual(widths.model, len("Fable 5")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_picker_stderr_survives.py b/tests/test_picker_stderr_survives.py new file mode 100644 index 0000000..7a1a314 --- /dev/null +++ b/tests/test_picker_stderr_survives.py @@ -0,0 +1,375 @@ +"""The picker's error channel is a terminal for its whole life, or nobody can type. + +shpool's client only puts a terminal into raw mode when stdin, stdout AND +stderr are all a terminal, and when one of them is not it declines *silently* +(libshpool/src/tty.rs, set_attach_flags). So a picker that loses fd 2 keeps +drawing and keeps taking keys, while every session it opens afterwards comes up +in line-typing mode: keystrokes echoed by the kernel as literal ^L over the +provider's first screen, nothing delivered until Enter, and no message anywhere +saying why. The only cure a person has is `stty raw -echo` typed from another +window, which is what the operator had to do at 00:46 on 2026-08-15 after +twelve minutes of it. + +Two ways in, and both are covered here: + + * `picker_events_stop` closed the event subscription with a redirection + written on a bare `exec`. Bash applies those to the SHELL, permanently, so + the close also pointed this picker's stderr at /dev/null for good -- and + `picker_events_stop` runs on the ordinary resubscribe path (an event stream + the daemon dropped) and again inside `cleanup` before the self-upgrade + execs the new release, which inherits the poisoned descriptor. + + * `bin/shpool_login` checked `-t 0` and `-t 1` at startup and never `-t 2`, + so a picker handed a redirected error channel -- by a parent, or by the + self-upgrade above -- drew a normal screen and said nothing. + +The scan at the end keeps the shape from coming back anywhere in the tree. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import pty +import re +import select +import subprocess +import tempfile +import unittest + + +REPO = Path(__file__).resolve().parent.parent +LIVE = REPO / "lib" / "sh" / "shpool_login_live.sh" +LOGIN = REPO / "bin" / "shpool_login" + + +def _drain(master: int, child: int) -> str: + captured = bytearray() + while True: + readable, _, _ = select.select([master], [], [], 20) + if not readable: + break + try: + chunk = os.read(master, 65536) + except OSError: + break + if not chunk: + break + captured.extend(chunk) + os.waitpid(child, 0) + os.close(master) + return captured.decode("utf-8", "replace") + + +def run_on_a_terminal( + script: str, + env_updates: dict | None = None, + stderr_replacement: int | None = None, +) -> str: + """Run a bash script in a window shaped like a login window. + + pty.fork gives the child the terminal as its CONTROLLING terminal, the way + sshd does. That matters: `/dev/tty` only opens for a process that has one, + and the repair in bin/shpool_login goes through /dev/tty. A pty passed as + plain descriptors would make the repair look impossible when it is not. + + Returns everything the terminal received. + """ + + environment = dict(os.environ) + environment["PYTHONDONTWRITEBYTECODE"] = "1" + # A picker started by the test runner must not inherit the runner's own + # kit settings; SESSION_KIT_NONINTERACTIVE in particular would end the + # real bin/shpool_login before it reaches anything this file is about. + for inherited in list(environment): + if inherited.startswith("SESSION_KIT_"): + del environment[inherited] + environment.update(env_updates or {}) + with tempfile.NamedTemporaryFile( + "w", suffix=".sh", delete=False, encoding="utf-8" + ) as handle: + handle.write(script) + probe = handle.name + try: + child, master = pty.fork() + if child == 0: # pragma: no cover - the child execs or dies + try: + if stderr_replacement is not None: + os.dup2(stderr_replacement, 2) + os.execve("/bin/bash", ["/bin/bash", probe], environment) + finally: + os._exit(127) + return _drain(master, child) + finally: + os.unlink(probe) + + +# Everything bin/shpool_login assigns before it sources the live module, so the +# real picker_events_stop below is the shipped one and not a copy. +PRELUDE = f"""#!/usr/bin/env bash +set -u +SCRIPT_DIR={REPO}/bin +source {REPO}/bin/session_kit_common +STATUS_CMD=$SCRIPT_DIR/shpool_status +SP_CMD=$SCRIPT_DIR/sp +TEMP_FILES=() +PICKER_EVENTS_FD= +PICKER_EVENTS_PID= +PICKER_PULSE_PID= +PICKER_EVENTS_FILE= +PICKER_EVENTS_DOWN= +PICKER_SCREEN=0 +PICKER_TTY_STATE="" +PICKER_EVENTS_STATE=off +PICKER_PULSE_STATE=off +source {LIVE} +""" + + +class PickerErrorChannelSurvives(unittest.TestCase): + """The shipped picker_events_stop, called on a real terminal.""" + + def test_stopping_the_subscription_leaves_stderr_on_the_terminal(self) -> None: + with tempfile.TemporaryDirectory() as scratch: + events = Path(scratch) / "events" + events.write_text("", encoding="utf-8") + output = run_on_a_terminal( + PRELUDE + + f""" +PICKER_EVENTS_FILE={events} +exec {{PICKER_EVENTS_FD}}< "$PICKER_EVENTS_FILE" +printf 'BEFORE stderr=%s tty=%s\\n' \\ + "$(readlink /proc/$$/fd/2)" "$([ -t 2 ] && echo yes || echo no)" +picker_events_stop +printf 'AFTER stderr=%s tty=%s\\n' \\ + "$(readlink /proc/$$/fd/2)" "$([ -t 2 ] && echo yes || echo no)" +""", + {"SESSION_KIT_STATE_DIR": scratch}, + ) + before = re.search(r"BEFORE stderr=(\S+) tty=(\S+)", output) + after = re.search(r"AFTER stderr=(\S+) tty=(\S+)", output) + self.assertIsNotNone(before, output) + self.assertIsNotNone(after, output) + self.assertEqual(before.group(2), "yes", output) + self.assertEqual( + after.group(2), + "yes", + "picker_events_stop redirected this picker's stderr away from the " + "terminal; every session it opens afterwards will come up unable " + "to receive typing. Captured:\n" + output, + ) + self.assertEqual(before.group(1), after.group(1), output) + + def test_stopping_the_subscription_still_closes_it(self) -> None: + """The suppression may move; the close may not stop happening.""" + + with tempfile.TemporaryDirectory() as scratch: + events = Path(scratch) / "events" + events.write_text("", encoding="utf-8") + output = run_on_a_terminal( + PRELUDE + + f""" +PICKER_EVENTS_FILE={events} +exec {{PICKER_EVENTS_FD}}< "$PICKER_EVENTS_FILE" +opened=$PICKER_EVENTS_FD +picker_events_stop +if [[ -e /proc/$$/fd/$opened ]]; then + printf 'SUBSCRIPTION still-open\\n' +else + printf 'SUBSCRIPTION closed\\n' +fi +printf 'SLOT [%s]\\n' "$PICKER_EVENTS_FD" +""", + {"SESSION_KIT_STATE_DIR": scratch}, + ) + self.assertIn("SUBSCRIPTION closed", output) + self.assertIn("SLOT []", output) + + def test_a_descriptor_already_gone_stays_quiet(self) -> None: + """The noise the suppression was written for is still suppressed. + + A slot that names a descriptor bash no longer holds must not print + `bad file descriptor` onto a person's screen, and must not end the + picker either. + """ + + with tempfile.TemporaryDirectory() as scratch: + output = run_on_a_terminal( + PRELUDE + + """ +PICKER_EVENTS_FD=61 +picker_events_stop +printf 'SURVIVED rc=%s\\n' "$?" +""", + {"SESSION_KIT_STATE_DIR": scratch}, + ) + self.assertIn("SURVIVED rc=0", output) + self.assertNotIn("bad file descriptor", output.lower()) + + +class LoginChecksTheThirdDescriptor(unittest.TestCase): + """bin/shpool_login refuses to draw over a broken error channel in silence.""" + + def _login_with_stderr_on_a_pipe(self, scratch: str) -> tuple[str, bytes]: + """Start the real picker with fd 2 on a pipe and fd 0/1 on a terminal. + + The helper paths are pointed at nothing, so the run stops two lines + later at "the picker's helpers are missing" -- far enough to prove + what this test is about, without a whole picker fixture. + """ + + read_end, write_end = os.pipe() + try: + on_terminal = run_on_a_terminal( + f'exec "{LOGIN}"\n', + { + "SESSION_KIT_STATE_DIR": scratch, + "SESSION_KIT_STATUS_CMD": os.path.join(scratch, "no-such-status"), + "SESSION_KIT_SP_CMD": os.path.join(scratch, "no-such-sp"), + }, + stderr_replacement=write_end, + ) + finally: + os.close(write_end) + on_pipe = os.read(read_end, 65536) + os.close(read_end) + return on_terminal, on_pipe + + def test_a_redirected_error_channel_is_named_and_put_back(self) -> None: + with tempfile.TemporaryDirectory() as scratch: + on_terminal, on_pipe = self._login_with_stderr_on_a_pipe(scratch) + self.assertIn( + "error output was not the screen", + on_terminal, + "the picker started over a redirected error channel without " + "saying so; a session opened from this window would have come up " + "unable to receive typing. Terminal saw:\n" + on_terminal, + ) + # Proof the descriptor was actually rebound rather than merely + # described: the later refusal is written to fd 2, and it lands on the + # terminal instead of in the pipe it started on. + self.assertIn("helpers are missing", on_terminal, on_terminal) + self.assertNotIn(b"helpers are missing", on_pipe) + + +class BareExecNeverRedirectsTheShell(unittest.TestCase): + """A redirection written on a bare `exec` is permanent. Keep it at zero. + + `exec {FD}<&- 2>/dev/null` reads like it quietens one close. It also moves + the shell's own stderr, forever, and that is what deafened a person's + terminal. Nothing in this tree needs the shape; where a bare `exec` really + must reassign fd 0/1/2 for the rest of a program, add it here with a + reason rather than letting the pattern back in unexamined. + """ + + # A redirection is an optional source ({name} or digits) then an operator. + # An operator with no source is fd 1 for writes and fd 0 for reads. + REDIRECT = re.compile(r"(\{[A-Za-z_][A-Za-z0-9_]*\}|\d+)?(&>>|&>|>>|<>|>&|<&|>|<)") + + # The one place the tree means it, with the reason. bin/shpool_login puts + # a redirected error channel back on the screen at startup precisely + # because a permanent reassignment is what is wanted there: the picker + # runs for hours after this line, and every session it opens has to + # inherit a terminal on fd 2 or come up unable to receive typing. + # Verified individually before being listed. `bin/shpool_login` puts a + # redirected error channel back on the screen at startup precisely because a + # permanent reassignment is what is wanted there: the picker runs for hours + # after this line, and every session it opens has to inherit a terminal on + # fd 2 or come up unable to receive typing. + # + # The three `sp_core.sh` lines arrived with the keyboard backstop, which was + # written before this guard existed, so the merge is the first time they met. + # Each was checked rather than assumed: + # + # :114 is the body of `sk_bind_handoff_tty`, whose only caller is + # `attach_id()`, which ends in `exec "$SK_SHPOOL" ...`. The binding is + # replaced along with the process, so it cannot outlive the attach. + # :140 is `sk_handoff_guard_arm`, which saves the old fd 2 first and is + # released by `sk_handoff_unbind` on every exit path, including the + # EXIT, INT, TERM and HUP traps. This pair exists BECAUSE an unscoped + # binding on the picker door -- which does not exec -- outlived its + # attach once already. + # :148 is that release itself, putting fd 2 back where the caller had it. + DELIBERATE = { + ("bin/shpool_login", "exec 2>/dev/tty"), + ("lib/sh/sp_core.sh", "exec 2>&1"), + ("lib/sh/sp_core.sh", 'exec 2>&"$SK_HANDOFF_ERR"'), + } + + def _standard_descriptors(self, remainder: str) -> list[str]: + found = [] + for source, operator in self.REDIRECT.findall(remainder): + if source.startswith("{"): + continue + if source == "": + source = "0" if operator.startswith("<") else "1" + if source in {"0", "1", "2"}: + found.append(source + operator) + if operator == "&>" or operator == "&>>": + found.append(operator) + return found + + def test_no_shell_script_moves_its_own_stdio_on_a_bare_exec(self) -> None: + listing = subprocess.run( + ["git", "ls-files"], + cwd=REPO, + check=True, + capture_output=True, + text=True, + ).stdout.split() + offenders = [] + allowed = [] + matched = set() + for name in listing: + path = REPO / name + if path.suffix not in {".sh", ".bash", ""}: + continue + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + if "exec" not in text: + continue + for number, line in enumerate(text.splitlines(), start=1): + code = line.split("#", 1)[0] + match = re.search(r"(?:^|[;&|]|\bthen\b|\bdo\b)\s*exec\s+(.*)$", code) + if match is None: + continue + remainder = match.group(1).strip() + # A command word after `exec` replaces the process; the + # redirection goes with the new program and cannot outlive it. + if remainder and not re.match(r"^[-{\d<>&]", remainder): + continue + moved = self._standard_descriptors(remainder) + if not moved: + continue + if (name, line.strip()) in self.DELIBERATE: + allowed.append(f"{name}:{number}") + matched.add((name, line.strip())) + continue + offenders.append(f"{name}:{number}: {line.strip()} -> {moved}") + self.assertEqual( + [], + offenders, + "a bare `exec` carrying a redirect on fd 0, 1 or 2 changes the " + "shell permanently. Either write the redirection on a group -- " + "`{ exec {FD}<&-; } 2>/dev/null` -- or add the line to " + "DELIBERATE with a reason:\n" + "\n".join(offenders), + ) + # The allowance is a list of exact lines; a rename or a rewrite must + # come back through review rather than silently stop being covered. + # Compared as a SET, not a count: one allowed line can legitimately + # appear more than once in a file -- `sk_bind_handoff_tty` and + # `sk_handoff_guard_arm` both bind fd 2 with the identical statement -- + # and counting made a second honest occurrence look like a missing one. + self.assertEqual( + self.DELIBERATE, + matched, + "an allowed bare-exec redirect no longer exists as written: " + f"expected {sorted(self.DELIBERATE)}, matched {sorted(matched)} " + f"at {allowed}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_picker_ux.py b/tests/test_picker_ux.py new file mode 100644 index 0000000..bf522e0 --- /dev/null +++ b/tests/test_picker_ux.py @@ -0,0 +1,600 @@ +"""Picker navigation: live filtering, jump, grouping, compact rows, and the +single help table. + +The picker is the surface a person lives in all day, so every test here asks +the same two questions of a new behaviour: does it show the truth, and can it +change anything by accident. The jump key marks rather than selects, grouping +and compact only reorder or redraw what the unfiltered list already contained, +and a filter previewed mid-typing is undone the moment the line turns out to +be a command. +""" + +from __future__ import annotations + +import json +import re +import unittest + +from tests.support import REPO +from tests.test_login import LoginFixture, inventory, row, run_pty + +LOGIN = REPO / "bin" / "shpool_login" + + +def waiting_row(shpool_id: str, *, number: int, provider: str = "codex") -> dict: + item = row(shpool_id, number=number, provider=provider, needs_you=True) + item["agent_status"] = "needs your reply" + return item + + +def rewrite_inventory(fixture: LoginFixture, document: dict) -> None: + """Point both fixture snapshots at a document built after construction. + + Project grouping is keyed on real directories, and the fixture only knows + where those are once it exists. + """ + payload = json.dumps(document) + fixture.inventory.write_text(payload, encoding="utf-8") + fixture.refreshed_inventory.write_text(payload, encoding="utf-8") + + +def row_numbers(text: str) -> list[str]: + return re.findall(r"(?m)^\s+(\d+)\s+\S.* \| (?:CLD|CDX|SHL|UNK) \|", text) + + +class PeekIsGoneTests(unittest.TestCase): + """`i<n>` was documented in four places and never had a helper to run. + + It is not a key any more, so it answers like any other key the picker does + not have, and nothing on any screen teaches it. + """ + + def test_i_is_an_unknown_key_and_changes_nothing(self) -> None: + item = waiting_row("parser", number=1) + fixture = LoginFixture(inventory(item)) + try: + code, output = run_pty(fixture, b"i1\nq\n") + self.assertEqual(0, code) + self.assertIn("There is no such key on this screen.", output) + self.assertNotIn("Peek", output) + self.assertEqual([], fixture.sp_entries()) + finally: + fixture.close() + + def test_no_screen_teaches_peek_any_more(self) -> None: + fixture = LoginFixture(inventory(row("only", number=1))) + try: + code, output = run_pty(fixture, b"?\n\nq\n", columns=110) + self.assertEqual(0, code) + self.assertNotIn("Peek", output) + self.assertNotIn("i<n>", output) + self.assertNotIn("peek", output) + finally: + fixture.close() + + +class LiveFilterTests(unittest.TestCase): + def test_a_half_typed_search_previews_its_own_result(self) -> None: + first = row("alpha", number=1) + second = row("bravo", number=2) + fixture = LoginFixture(inventory(first, second)) + try: + # "Search: alp" can only appear from the preview: nothing has been + # submitted yet, and the first frame carries no search line. + code, output = run_pty( + fixture, + b"/alp", + deferred=("Search: alp", b"ha\nq\n"), + ) + self.assertEqual(0, code) + preview = output[output.index("Search: alp") :] + self.assertIn("Codex alpha", preview) + self.assertNotIn("Codex bravo", preview) + # The line being typed is still the line being typed. + self.assertIn("❯ /alp", preview) + self.assertNotIn("Unknown choice", preview) + finally: + fixture.close() + + def test_a_preview_is_undone_when_the_line_turns_out_to_be_a_command( + self, + ) -> None: + first = row("alpha", number=1) + second = row("bravo", number=2) + fixture = LoginFixture(inventory(first, second)) + try: + # Type a search, let it preview, then rub it out and press a key + # that is not a search. The list that key acts on is the whole + # list, not the one the half-typed line was showing. + code, output = run_pty( + fixture, + b"/alp", + deferred=("Search: alp", b"\x7f\x7f\x7f\x7fg\nq\n"), + ) + self.assertEqual(0, code) + undone = output[output.rindex("Nothing needs you.") :] + self.assertIn("Codex bravo", undone) + self.assertNotIn("Search:", undone) + finally: + fixture.close() + + def test_the_preview_can_be_switched_off(self) -> None: + first = row("alpha", number=1) + second = row("bravo", number=2) + fixture = LoginFixture(inventory(first, second)) + try: + code, output = run_pty( + fixture, + b"/alpha\nq\n", + env_updates={"SESSION_KIT_PICKER_FILTER_LIVE": "0"}, + ) + self.assertEqual(0, code) + # Submitting still searches exactly as it always did. + submitted = output[output.index("Search: alpha") :] + self.assertIn("Codex alpha", submitted) + self.assertNotIn("Codex bravo", submitted) + finally: + fixture.close() + + + +class JumpTests(unittest.TestCase): + def test_jump_marks_each_waiting_session_in_turn_and_wraps(self) -> None: + fixture = LoginFixture( + inventory( + row("calm", number=1), + waiting_row("asking", number=2), + waiting_row("blocked", number=3), + ) + ) + try: + code, output = run_pty(fixture, b"g\ng\ng\nq\n") + self.assertEqual(0, code) + announcements = re.findall(r"Session (\d+) needs you", output) + self.assertEqual(["2", "3", "2"], announcements) + # The row is marked, never selected: no command ran. + self.assertEqual([], fixture.sp_entries()) + self.assertIn("▸", output) + finally: + fixture.close() + + def test_jump_says_so_when_nothing_is_waiting(self) -> None: + fixture = LoginFixture(inventory(row("calm", number=1))) + try: + code, output = run_pty(fixture, b"g\nq\n") + self.assertEqual(0, code) + self.assertIn("Nothing needs you.", output) + self.assertNotIn("▸", output) + finally: + fixture.close() + + def test_jump_repages_after_clearing_a_filter_and_draws_the_named_row(self) -> None: + sessions = [ + row(f"ready-{number:02d}", number=number) + for number in range(1, 42) + ] + # Setup-incomplete sessions are in the attention projection without + # being sorted to the head of their availability group. Number 20 is + # therefore on a middle page after the search is cleared. + sessions[19]["setup_incomplete"] = True + fixture = LoginFixture(inventory(*sessions)) + try: + code, output = run_pty( + fixture, b"/ready-01\ng\nq\n", lines=24, columns=110 + ) + self.assertEqual(0, code) + after = output[output.index("Session 20 needs you.") :] + self.assertIn("Codex ready-20", after) + marked = next(line for line in after.splitlines() if "Codex ready-20" in line) + self.assertIn("▸", marked) + self.assertIn("Search cleared to show session 20.", output) + finally: + fixture.close() + + def test_jump_keeps_a_search_when_the_target_is_already_visible(self) -> None: + fixture = LoginFixture(inventory(waiting_row("asking", number=2))) + try: + code, output = run_pty(fixture, b"/asking\ng\nq\n", columns=100) + self.assertEqual(0, code) + after = output[output.index("Session 2 needs you.") :] + self.assertIn("Search: asking", after) + self.assertNotIn("Search cleared", output) + self.assertIn("▸", after) + finally: + fixture.close() + + +class GroupingTests(unittest.TestCase): + def test_default_grouping_is_the_list_the_picker_always_drew(self) -> None: + fixture = LoginFixture( + inventory( + row("here", number=1, provider="claude"), + row("elsewhere", number=2, provider="codex", availability="attached"), + ) + ) + try: + code, output = run_pty(fixture, b"q\n") + self.assertEqual(0, code) + self.assertIn("Ready", output) + self.assertIn("Open elsewhere", output) + self.assertNotIn("Grouping by", output) + finally: + fixture.close() + + def test_grouping_by_provider_heads_each_provider(self) -> None: + fixture = LoginFixture( + inventory( + row("one", number=1, provider="claude"), + row("two", number=2, provider="codex"), + ) + ) + try: + code, output = run_pty(fixture, b"group provider\nq\n") + self.assertEqual(0, code) + grouped = output[output.index("Grouping by provider.") :] + self.assertIn("Claude", grouped) + self.assertIn("Codex", grouped) + self.assertNotIn("Open elsewhere", grouped) + self.assertEqual(["1", "2"], row_numbers(grouped)) + finally: + fixture.close() + + def test_bare_group_cycles_state_provider_project(self) -> None: + fixture = LoginFixture(inventory(row("one", number=1))) + try: + code, output = run_pty(fixture, b"group\ngroup\ngroup\nq\n") + self.assertEqual(0, code) + self.assertEqual( + ["provider", "project", "state"], + re.findall(r"Grouping by (\w+)\.", output), + ) + finally: + fixture.close() + + def test_an_unknown_grouping_changes_nothing(self) -> None: + fixture = LoginFixture(inventory(row("one", number=1))) + try: + code, output = run_pty(fixture, b"group sideways\nq\n") + self.assertEqual(0, code) + self.assertIn("Grouping is state, provider, or project", output) + self.assertIn("Ready", output) + finally: + fixture.close() + + def test_project_grouping_names_the_project_a_directory_belongs_to(self) -> None: + inside = row("known", number=1, provider="claude") + outside = row("stray", number=2, provider="codex") + fixture = LoginFixture(inventory(inside, outside)) + try: + inside["cwd"] = f"{fixture.primary_project}/lib/deep" + outside["cwd"] = "/var/tmp/scratchpad" + rewrite_inventory(fixture, inventory(inside, outside)) + code, output = run_pty(fixture, b"group project\nq\n") + self.assertEqual(0, code) + grouped = output[output.index("Grouping by project.") :] + # "main" is the alias the fixture's projects.tsv gives that root, + # and the row three directories inside it still belongs to it. + self.assertIn("main", grouped) + self.assertIn("scratchpad", grouped) + self.assertEqual(["1", "2"], row_numbers(grouped)) + finally: + fixture.close() + + def test_a_row_that_names_its_own_project_wins(self) -> None: + # The seam project identity plugs into: when the inventory row carries + # the project, nothing is derived from a path. + item = row("delegated", number=1, provider="claude") + item["project_name"] = "delegation core" + fixture = LoginFixture(inventory(item)) + try: + code, output = run_pty(fixture, b"group project\nq\n") + self.assertEqual(0, code) + self.assertIn("delegation core", output[output.index("Grouping by") :]) + finally: + fixture.close() + + +class CompactTests(unittest.TestCase): + def test_compact_removes_secondary_row_details_even_on_a_wide_screen(self) -> None: + item = row("delegated", number=1, provider="codex") + item["active_subagent_count"] = 2 + item["worktree"] = {"branch": "feature/compact-proof"} + fixture = LoginFixture(inventory(item)) + try: + code, output = run_pty(fixture, b"c\nq\n", columns=140) + self.assertEqual(0, code) + before, _, after = output.partition("Compact rows on.") + self.assertIn("2 subagents", before) + self.assertIn("worktree feature/compact-proof", before) + self.assertIn("2 subagents", after) + self.assertNotIn("worktree feature/compact-proof", after) + finally: + fixture.close() + + def test_compact_drops_headings_and_shows_more_sessions(self) -> None: + sessions = [row(f"task-{number}", number=number) for number in range(1, 31)] + fixture = LoginFixture(inventory(*sessions)) + try: + code, output = run_pty(fixture, b"c\nq\n", lines=24, columns=100) + self.assertEqual(0, code) + before, _, after = output.partition("Compact rows on.") + self.assertIn("Ready", before) + self.assertNotIn("Open elsewhere", after) + self.assertGreater(len(row_numbers(after)), len(row_numbers(before))) + finally: + fixture.close() + + def test_compact_can_be_turned_back_off(self) -> None: + fixture = LoginFixture(inventory(row("one", number=1))) + try: + code, output = run_pty(fixture, b"c\nc\nq\n") + self.assertEqual(0, code) + self.assertIn("Compact rows on.", output) + after = output[output.index("Compact rows off.") :] + self.assertIn("Ready", after) + finally: + fixture.close() + + def test_compact_can_start_on(self) -> None: + fixture = LoginFixture(inventory(row("one", number=1))) + try: + code, output = run_pty( + fixture, + b"q\n", + env_updates={"SESSION_KIT_PICKER_COMPACT": "1"}, + ) + self.assertEqual(0, code) + self.assertNotIn("Ready\n", output) + self.assertEqual(["1"], row_numbers(output)) + finally: + fixture.close() + + +class BackOnlyScreenTests(unittest.TestCase): + """Read-only screens say so instead of eating what was typed at them.""" + + def test_help_refuses_a_line_it_cannot_act_on(self) -> None: + fixture = LoginFixture(inventory(row("one", number=1))) + try: + # A row number typed on the help screen used to be swallowed: the + # screen went back and nothing opened, with nothing said. + code, output = run_pty(fixture, b"?\n1\n\nq\n") + self.assertEqual(0, code) + self.assertIn("There is nothing to choose on this screen", output) + self.assertEqual([], fixture.sp_entries()) + finally: + fixture.close() + + +class HelpTests(unittest.TestCase): + """The help screen and the menu must describe the same picker. + + They did not: `a` (Needs you) and `m` (More) were on the footer of every + screen and in no help text anywhere. One key table now feeds the help, and + this test fails if a key is dispatched without being documented. + """ + + #: Patterns the dispatcher matches that are documented under another name. + DOCUMENTED_ELSEWHERE = { + '""': "Enter", + "[0-9]*": "number", + "*": "unknown input", + "\\>": "next", + "\\<": "prev", + } + + def dispatched_keys(self) -> set[str]: + source = LOGIN.read_text(encoding="utf-8") + body = source[source.index('case "$choice" in') :] + keys: set[str] = set() + for match in re.finditer(r"(?m)^ ([^\s)][^)]*)\)$", body): + for alternative in match.group(1).split("|"): + alternative = alternative.strip() + if alternative in self.DOCUMENTED_ELSEWHERE or not alternative: + continue + # Only the lower-case spelling of each key is documented; the + # upper-case and word aliases follow it in the same arm. + if alternative != alternative.lower(): + continue + keys.add(alternative) + return keys + + def test_every_dispatched_key_is_in_the_help_table(self) -> None: + fixture = LoginFixture(inventory(row("one", number=1))) + try: + code, output = run_pty(fixture, b"?\n\nq\n", columns=110) + self.assertEqual(0, code) + help_text = output[output.index("Picker help") :] + expected = { + "q": "q", + "p": "Projects:", + "r": "r", + "m": "m", + "a": "a", + "g": "g", + "x": "x", + "c": "c", + "u": "u", + "o": "o", + "n": "n", + "\\?": "?", + "next": "next", + "prev": "prev", + "group": "group", + "group\\ *": "group", + "compact": "c", + "h": "h number", + "h[0-9]*": "h number", + "h\\ *": "h number", + "k\\ *": "k numbers", + "k[0-9]*": "k numbers", + "name\\ *": "name number", + "name[0-9]*": "name number", + "name\\ reset\\ *": "name reset number", + "name\\ reset[0-9]*": "name reset number", + "fork\\ *": "fork number", + "fork[0-9]*": "fork number", + "model\\ *": "model number", + "model[0-9]*": "model number", + "/*": "/text", + } + missing = sorted(self.dispatched_keys() - set(expected)) + self.assertEqual( + [], + missing, + "new picker keys must be added to this map and to the help table", + ) + for pattern, documented in expected.items(): + if pattern not in self.dispatched_keys(): + continue + self.assertIn( + documented, + help_text, + f"{pattern} is dispatched but {documented!r} is not in the help", + ) + finally: + fixture.close() + + def test_a_key_takes_its_number_with_or_without_the_space(self) -> None: + """k3, name3, and fork3 used to be unknown keys.""" + for typed, command in ( + (b"k9\nq\n", "picker-close"), + (b"k 9\nq\n", "picker-close"), + (b"name9\nBetter name\nq\n", "picker-name"), + (b"fork9\nq\n", "picker-fork"), + ): + with self.subTest(typed=typed): + fixture = LoginFixture( + inventory(row("open1", number=9, provider="codex")) + ) + try: + code, output = run_pty(fixture, typed) + self.assertEqual(0, code) + self.assertNotIn("There is no such key", output) + self.assertIn( + command, + [entry["args"][0] for entry in fixture.sp_entries()], + ) + finally: + fixture.close() + + def test_model_action_wires_the_displayed_row_to_the_existing_verb(self) -> None: + item = row("modeled", number=9, provider="codex") + item["model"] = "gpt-old" + fixture = LoginFixture(inventory(item)) + models = fixture.base / "models.tsv" + models.write_text("codex\tgpt-new\n", encoding="utf-8") + try: + code, output = run_pty( + fixture, + b"model9\n1\nq\n", + columns=120, + env_updates={"SESSION_KIT_MODELS_FILE": str(models)}, + ) + self.assertEqual(0, code) + self.assertIn("gpt-old", output) + self.assertIn("1 gpt-new", output) + entries = fixture.sp_entries() + self.assertEqual("picker-change-model", entries[0]["args"][0]) + self.assertEqual("gpt-new", entries[0]["args"][2]) + self.assertEqual("modeled", entries[0]["proof"]["shpool_id"]) + finally: + fixture.close() + + def test_model_action_refuses_text_outside_the_configured_list(self) -> None: + item = row("modeled", number=9, provider="codex") + fixture = LoginFixture(inventory(item)) + models = fixture.base / "models.tsv" + models.write_text("codex\tgpt-approved\n", encoding="utf-8") + try: + code, output = run_pty( + fixture, + b"model9\ngpt-made-up\nq\n", + env_updates={"SESSION_KIT_MODELS_FILE": str(models)}, + ) + self.assertEqual(0, code) + self.assertIn("not in this provider's configured list", output) + self.assertFalse( + any( + entry["args"][0] == "picker-change-model" + for entry in fixture.sp_entries() + ) + ) + finally: + fixture.close() + + def test_model_menu_offers_only_models_the_provider_verb_accepts(self) -> None: + item = row("modeled", number=9, provider="claude") + fixture = LoginFixture(inventory(item)) + models = fixture.base / "models.tsv" + models.write_text( + "*\tclaude-opus-5\n*\tgpt-5-codex\n*\topus-4.6\n", + encoding="utf-8", + ) + try: + code, output = run_pty( + fixture, + b"model9\n1\nq\n", + env_updates={"SESSION_KIT_MODELS_FILE": str(models)}, + ) + self.assertEqual(0, code) + menu = output[output.index("Models") : output.index("Model number")] + self.assertIn("claude-opus-5", menu) + self.assertNotIn("gpt-5-codex", menu) + self.assertNotIn("opus-4.6", menu) + changed = [ + entry + for entry in fixture.sp_entries() + if entry["args"][0] == "picker-change-model" + ] + self.assertEqual("claude-opus-5", changed[0]["args"][2]) + finally: + fixture.close() + + def test_zero_and_out_of_range_model_numbers_get_only_a_plain_refusal(self) -> None: + for requested in ("0", "9"): + with self.subTest(requested=requested): + item = row("modeled", number=9, provider="codex") + fixture = LoginFixture(inventory(item)) + models = fixture.base / "models.tsv" + models.write_text("codex\tgpt-approved\n", encoding="utf-8") + try: + code, output = run_pty( + fixture, + f"model9\n{requested}\nq\n".encode(), + env_updates={"SESSION_KIT_MODELS_FILE": str(models)}, + ) + self.assertEqual(0, code) + self.assertIn( + "That model is not in this provider's configured list. Nothing changed.", + output, + ) + self.assertNotIn("sed:", output) + self.assertFalse( + any( + entry["args"][0] == "picker-change-model" + for entry in fixture.sp_entries() + ) + ) + finally: + fixture.close() + + def test_help_is_reachable_from_more_and_from_needs_you(self) -> None: + for label, payload in ( + ("more", b"m\n?\n\n\nq\n"), + ("needs you", b"a\n?\n\n\nq\n"), + ): + with self.subTest(label=label): + fixture = LoginFixture(inventory(row("one", number=1))) + try: + code, output = run_pty(fixture, payload) + self.assertEqual(0, code) + self.assertIn("Picker help", output) + finally: + fixture.close() + + + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pinned_formats.py b/tests/test_pinned_formats.py new file mode 100644 index 0000000..d0d2625 --- /dev/null +++ b/tests/test_pinned_formats.py @@ -0,0 +1,396 @@ +"""The kit's dependence on vendor-internal files, kept visible. + +Both vendors say these files are internal and may change between releases. When +one does change shape, nothing announces it: the reader finds no matching +record, returns empty, and a title or a whole history stops working while every +command still exits 0. The inventory in docs/pinned-internal-formats.md is the +answer to that -- but an inventory nobody checks goes stale in exactly the same +silence. + +So: every kit file that reaches for one of these paths has to appear in the +document. A new reader added without a line in the table fails here, in the +change that adds it, rather than in a year when a format moves. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + +from tests.support import REPO + + +DOCTOR = REPO / "lib" / "sh" / "session_kit_doctor.sh" + + +def audit_source() -> str: + """The audit block doctor actually ships, lifted out of its heredoc. + + The block is a stdlib-only program that takes its whole world on argv, so + a test can point it at a fixture home and read the same rows a person sees + -- without running doctor's shell, and without touching live state. + """ + blocks = re.findall(r"<<'PY'\n(.*?)\nPY\n", DOCTOR.read_text(encoding="utf-8"), re.S) + if not blocks: + raise AssertionError("session_kit_doctor.sh no longer embeds a python block") + return max(blocks, key=len) + + +class FormatFixture: + """A home shaped exactly like a live estate, one vendor file at a time.""" + + def __init__(self, root: Path) -> None: + self.home = root / "home" + self.codex_home = root / "codex" + (self.home / ".claude" / "sessions").mkdir(parents=True) + (self.home / ".claude" / "projects" / "-srv-x").mkdir(parents=True) + (self.codex_home / "sessions" / "2026" / "08" / "13").mkdir(parents=True) + + def session_record(self, **overrides) -> "FormatFixture": + record = { + "sessionId": "22222222-3333-4444-8555-666666666666", + "pid": 4242, + "messagingSocketPath": "/run/user/1000/cc-socks/4242.sock", + "statusUpdatedAt": 1786600000000, + "status": "busy", + } + record.update(overrides) + for field in [key for key, value in record.items() if value is None]: + del record[field] + path = self.home / ".claude" / "sessions" / "4242.json" + path.write_text(json.dumps(record), encoding="utf-8") + return self + + def key_file(self, payload=None) -> "FormatFixture": + path = self.home / ".claude" / "sessions" / "4242.abc123.key" + path.write_text( + json.dumps({"peerToken": "a" * 32} if payload is None else payload), + encoding="utf-8", + ) + os.chmod(path, 0o600) + return self + + def transcript(self, lines=None, name="conversation.jsonl") -> "FormatFixture": + if lines is None: + lines = [{"type": "ai-title", "aiTitle": "x", "sessionId": "u"}] + path = self.home / ".claude" / "projects" / "-srv-x" / name + path.write_text( + "".join(json.dumps(line) + "\n" for line in lines), encoding="utf-8" + ) + return self + + def rollout(self, lines=None) -> "FormatFixture": + if lines is None: + lines = [ + {"timestamp": "t", "type": "session_meta", "payload": {"id": "u"}}, + {"timestamp": "t", "type": "event_msg", "payload": {"type": "x"}}, + ] + path = ( + self.codex_home + / "sessions" + / "2026" + / "08" + / "13" + / "rollout-2026-08-13T11-45-36-01900000.jsonl" + ) + path.write_text( + "".join(json.dumps(line) + "\n" for line in lines), encoding="utf-8" + ) + return self + + def session_index(self, lines=None) -> "FormatFixture": + if lines is None: + lines = [{"id": "01900000-0000-7000-8000-000000000001", "thread_name": "x"}] + (self.codex_home / "session_index.jsonl").write_text( + "".join(json.dumps(line) + "\n" for line in lines), encoding="utf-8" + ) + return self + + def healthy(self) -> "FormatFixture": + return ( + self.session_record() + .key_file() + .transcript() + .rollout() + .session_index() + ) + + +class DoctorFormatRowTests(unittest.TestCase): + """The verdicts a person reads when a vendor moves a format. + + Every case below is a shape that once passed silently: doctor said `ok` + while the field item 29 or item 30 depends on had already gone. + """ + + @classmethod + def setUpClass(cls) -> None: + cls.source = audit_source() + + def row(self, fixture: FormatFixture, env=None): + environment = dict(os.environ) + environment.pop("SESSION_KIT_DOCTOR_FORMAT_SECONDS", None) + environment.update(env or {}) + result = subprocess.run( + [ + "python3", + "-c", + self.source, + str(fixture.home), + str(fixture.home / ".claude"), + str(fixture.codex_home), + "test-release", + "linux-x86_64", + str(fixture.home / "install"), + ], + capture_output=True, + text=True, + timeout=120, + env=environment, + ) + self.assertEqual("", result.stderr.strip(), result.stderr) + for line in result.stdout.splitlines(): + parts = line.split("\t") + if len(parts) == 3 and parts[1] == "internal-formats": + return parts[0], parts[2] + raise AssertionError(f"doctor emitted no internal-formats row:\n{result.stdout}") + + def fixture(self) -> FormatFixture: + root = tempfile.TemporaryDirectory() + self.addCleanup(root.cleanup) + return FormatFixture(Path(root.name)) + + def test_a_whole_healthy_estate_reports_every_format_checked(self) -> None: + status, detail = self.row(self.fixture().healthy()) + self.assertEqual("ok", status, detail) + self.assertIn("all 5", detail) + + def test_a_format_with_no_live_example_is_named_not_silently_passed(self) -> None: + # Four formats present, one absent: the old row said `ok` as long as + # ANY format had been checked, so a vanished file read as health. + status, detail = self.row( + self.fixture().session_record().key_file().transcript().rollout() + ) + self.assertEqual("warn", status, detail) + self.assertIn("Codex session index", detail) + self.assertIn("4 of 5", detail) + + def test_the_session_record_losing_its_id_fails_and_says_which_field(self) -> None: + status, detail = self.row(self.fixture().healthy().session_record(sessionId=None)) + self.assertEqual("fail", status, detail) + self.assertIn("Claude session record", detail) + self.assertIn("sessionId", detail) + + def test_the_socket_path_going_away_warns_by_name(self) -> None: + # messagingSocketPath IS socket delivery. The old check read sessionId + # and nothing else, so this exact loss reported `ok`. + status, detail = self.row( + self.fixture().healthy().session_record(messagingSocketPath=None) + ) + self.assertEqual("warn", status, detail) + self.assertIn("messagingSocketPath", detail) + + def test_the_attention_tie_breaker_going_away_warns_by_name(self) -> None: + status, detail = self.row( + self.fixture().healthy().session_record(statusUpdatedAt=None) + ) + self.assertEqual("warn", status, detail) + self.assertIn("statusUpdatedAt", detail) + + def test_an_empty_rollout_record_is_not_a_healthy_rollout(self) -> None: + # `{}` is a dict, which is all the old check asked of it. + status, detail = self.row(self.fixture().healthy().rollout(lines=[{}, {}])) + self.assertEqual("fail", status, detail) + self.assertIn("Codex rollout", detail) + self.assertIn("type", detail) + + def test_a_rollout_that_lost_its_payload_fails(self) -> None: + status, detail = self.row( + self.fixture().healthy().rollout(lines=[{"type": "session_meta"}]) + ) + self.assertEqual("fail", status, detail) + self.assertIn("payload", detail) + + def test_a_rollout_is_judged_on_the_lines_the_kit_reads(self) -> None: + # Rollouts carry several kinds of line; one odd head line is not a + # moved format as long as the shape the readers dispatch on is there. + status, detail = self.row( + self.fixture().healthy().rollout( + lines=[{"note": "a line no reader looks at"}, {"type": "x", "payload": {}}] + ) + ) + self.assertEqual("ok", status, detail) + + def test_a_session_index_without_ids_fails(self) -> None: + status, detail = self.row( + self.fixture().healthy().session_index(lines=[{"thread_name": "x"}]) + ) + self.assertEqual("fail", status, detail) + self.assertIn("Codex session index", detail) + self.assertIn("id", detail) + + def test_a_session_index_that_lost_its_names_warns(self) -> None: + status, detail = self.row( + self.fixture().healthy().session_index(lines=[{"id": "u"}]) + ) + self.assertEqual("warn", status, detail) + self.assertIn("thread_name", detail) + + def test_an_untyped_transcript_record_fails(self) -> None: + status, detail = self.row( + self.fixture().healthy().transcript(lines=[{"aiTitle": "x"}]) + ) + self.assertEqual("fail", status, detail) + self.assertIn("Claude transcript", detail) + + def test_a_key_file_without_a_token_fails(self) -> None: + status, detail = self.row(self.fixture().healthy().key_file(payload={"x": 1})) + self.assertEqual("fail", status, detail) + self.assertIn("peerToken", detail) + + def test_a_transcript_still_being_written_is_not_a_moved_format(self) -> None: + # The newest *.jsonl on a healthy estate can be a file a session created + # a second ago. Doctor's loudest verdict must not come from that. + fixture = self.fixture().healthy() + fresh = fixture.home / ".claude" / "projects" / "-srv-x" / "brand-new.jsonl" + fresh.write_bytes(b"") + status, detail = self.row(fixture) + self.assertEqual("ok", status, detail) + + def test_a_half_written_line_is_not_a_moved_format(self) -> None: + fixture = self.fixture().healthy() + partial = fixture.home / ".claude" / "projects" / "-srv-x" / "partial.jsonl" + partial.write_text('{"type": "ai-tit', encoding="utf-8") + status, detail = self.row(fixture) + self.assertEqual("ok", status, detail) + + def test_the_scan_gives_up_on_a_slow_home_instead_of_hanging(self) -> None: + # A stalled or enormous home is a doctor that never returns. The scan + # carries a wall-clock budget and says so when it runs out. + status, detail = self.row( + self.fixture().healthy(), + env={"SESSION_KIT_DOCTOR_FORMAT_SECONDS": "0"}, + ) + self.assertEqual("warn", status, detail) + self.assertIn("ran out of time", detail) + + def test_a_home_with_thousands_of_rollouts_still_answers_quickly(self) -> None: + fixture = self.fixture().healthy() + day = fixture.codex_home / "sessions" / "2026" / "08" / "13" + for number in range(3000): + (day / f"rollout-2026-08-13T00-00-{number:04d}-uuid.jsonl").write_bytes(b"") + started = os.times() + status, detail = self.row(fixture) + self.assertLess(os.times().elapsed - started.elapsed, 20.0) + self.assertIn(status, {"ok", "warn"}, detail) + + +DOCUMENT = REPO / "docs" / "pinned-internal-formats.md" + +# What counts as touching a vendor internal. Each pattern is a path shape that +# only the vendors own. +INTERNAL_PATTERNS = ( + re.compile(r"\.claude[\"'/].{0,40}sessions"), + re.compile(r"\.claude[\"'/].{0,40}projects"), + re.compile(r"session_index\.jsonl"), + re.compile(r"rollout-"), +) + +# Files that match a pattern without depending on the format: they pass a path +# through, name it in prose, or check that it exists. +NOT_A_FORMAT_DEPENDENCY = { + # Reads only the kit's own state file, and only for a colour. + "config/claude/statusline.sh", + # Hands paths to the readers; parses nothing itself. + "lib/sessionkit_inventory/transcripts.py", + "lib/sessionkit_inventory/collector.py", + "lib/session_inventory.py", + # Kit-owned files that happen to live under the vendor's directory + # (.nameintent, .colorset, .titleset are the kit's own inventions). + "lib/sessionkit_inventory/projects.py", + "lib/sessionkit_inventory/closed_sessions.py", + "lib/sessionkit_inventory/accounts.py", + "lib/sessionkit_inventory/snapshot.py", + "lib/sessionkit_tui/runner.py", + "lib/sh/sp_sessions.sh", + "lib/sh/sp_provider_bounce.sh", + # Reads the transcript path the HOOK hands it -- the sanctioned interface, + # documented as already-migrated. + "config/claude/nameintent_title.sh", + # The checker itself: it reads one live example of each format precisely to + # notice when the shape changes. + "lib/sh/session_kit_doctor.sh", +} + +SEARCH_ROOTS = ("lib", "bin", "config", "tools") + + +def tracked_sources() -> list[str]: + result = subprocess.run( + ["git", "ls-files", *SEARCH_ROOTS], + cwd=REPO, + capture_output=True, + text=True, + check=True, + ) + return [ + name + for name in result.stdout.splitlines() + if name.endswith((".py", ".sh")) or "/" not in name + ] + + +class InventoryTests(unittest.TestCase): + def setUp(self) -> None: + self.text = DOCUMENT.read_text(encoding="utf-8") + + def test_every_reader_of_a_vendor_internal_is_in_the_document(self) -> None: + missing = [] + for name in tracked_sources(): + if name in NOT_A_FORMAT_DEPENDENCY: + continue + try: + source = (REPO / name).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + if not any(pattern.search(source) for pattern in INTERNAL_PATTERNS): + continue + # The document names modules, not paths: `providers_claude`, + # `names_push`, `transcript_text`, `claude_socket`, `self_name`. + stem = Path(name).stem + if stem not in self.text: + missing.append(name) + self.assertEqual( + [], + sorted(missing), + "these files read or write a vendor internal format and are not in " + f"{DOCUMENT.relative_to(REPO)}:\n " + + "\n ".join(sorted(missing)) + + "\n\nAdd the path, the exact fields, the sanctioned alternative " + "(or 'none today'), and what a person sees when it breaks.", + ) + + def test_each_entry_says_what_a_person_sees_when_it_breaks(self) -> None: + # Five formats, five failure descriptions. An entry without one is an + # inventory line, not a warning. + self.assertEqual(5, self.text.count("**When it breaks:**")) + self.assertEqual(5, self.text.count("**Sanctioned alternative")) + + def test_doctor_reports_the_pinned_formats(self) -> None: + doctor = (REPO / "lib" / "sh" / "session_kit_doctor.sh").read_text( + encoding="utf-8" + ) + self.assertIn('"internal-formats"', doctor) + # A shape that has already changed is not a warning: something a person + # depends on is broken right now. + self.assertIn('emit("fail", "internal-formats"', doctor) + self.assertIn("internal-formats", doctor.split("audit_names=(")[1][:400]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_probe_side_effects.py b/tests/test_probe_side_effects.py new file mode 100644 index 0000000..c551579 --- /dev/null +++ b/tests/test_probe_side_effects.py @@ -0,0 +1,288 @@ +"""The probe must change NOTHING, in every state it can meet. + +`__sk_close_keeps_the_conversation` asks `lifecycle closed +--only-with-conversation` which of its two outcomes would happen, and keeps the +session for anything but the good one. Asking is therefore on the hot path of a +session the operator can still type into, and the whole point of the flag is +that a refusal writes nothing at all. + +"Nothing at all" is asserted here the only way it can be believed: a sha256 of +every file under the state directory AND the durable data directory, taken in +the same process immediately before the verb runs and immediately after it +returns, byte-compared. A refusal that creates a file, grows the ledger, mints +a terminal number or touches a tombstone fails these tests no matter how +plausible its output looks. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import subprocess +from tests.test_lifecycle_shell import CORE, ProviderExitShellHarness, UUID + +REPO = Path(__file__).resolve().parents[1] + + +TREE_WRAPPER = '''#!/usr/bin/env python3 +"""Hash every watched tree around the real verb, without exec-ing away.""" +import hashlib, json, os, subprocess, sys +from pathlib import Path + +WATCHED = [Path(p) for p in os.environ["PROBE_WATCH"].split(os.pathsep) if p] + + +def snapshot(): + out = {} + for root in WATCHED: + if not root.exists(): + continue + for path in sorted(root.rglob("*")): + if path.is_dir(): + out[str(path) + "/"] = "dir" + continue + try: + blob = path.read_bytes() + except OSError as exc: + out[str(path)] = f"unreadable:{exc.errno}" + continue + out[str(path)] = hashlib.sha256(blob).hexdigest() + return out + + +if sys.argv[1:3] == ["lifecycle", "closed"]: + before = snapshot() + completed = subprocess.run( + [sys.executable, os.environ["PROBE_NEXT_CORE"], *sys.argv[1:]] + ) + after = snapshot() + with open(os.environ["PROBE_LOG"], "a", encoding="utf-8") as handle: + handle.write( + json.dumps( + { + "argv": sys.argv[1:], + "returncode": completed.returncode, + "before": before, + "after": after, + } + ) + + "\\n" + ) + raise SystemExit(completed.returncode) +os.execv( + sys.executable, + [sys.executable, os.environ["PROBE_NEXT_CORE"], *sys.argv[1:]], +) +''' + + +class ProbeSideEffectHarness(ProviderExitShellHarness): + def watch_the_probe(self) -> Path: + """Record a sha256 of every watched tree either side of the verb. + + Chains onto whatever core is installed; call it last. + """ + previous_core = self.core + log = self.base / "probe-trees.jsonl" + wrapper = self.base / "probe-tree-wrapper.py" + wrapper.write_text(TREE_WRAPPER, encoding="utf-8") + wrapper.chmod(0o700) + self.core = wrapper + self.environment_overrides = { + **self.environment_overrides, + "PROBE_NEXT_CORE": str(previous_core), + "PROBE_LOG": str(log), + # State AND the durable data directory: the closed-sessions ledger + # lives under the second one on purpose, so watching only the + # first would have missed the exact bug this flag exists for. + "PROBE_WATCH": os.pathsep.join( + (str(self.state), str(self.home / ".local" / "share")) + ), + } + return log + + def probe_records(self, log: Path) -> list[dict]: + self.assertTrue(log.is_file(), "the probe never ran") + return [ + json.loads(line) + for line in log.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + def assert_probe_changed_nothing(self, log: Path) -> dict: + records = self.probe_records(log) + self.assertEqual(1, len(records), records) + record = records[0] + self.assertIn("--only-with-conversation", record["argv"]) + before, after = record["before"], record["after"] + created = sorted(set(after) - set(before)) + removed = sorted(set(before) - set(after)) + changed = sorted( + path + for path in set(before) & set(after) + if before[path] != after[path] + ) + self.assertEqual([], created, f"the probe CREATED {created}") + self.assertEqual([], removed, f"the probe REMOVED {removed}") + self.assertEqual([], changed, f"the probe MODIFIED {changed}") + return record + + +class ProbeWritesNothingTests(ProbeSideEffectHarness): + def test_no_conversation_is_byte_identical(self) -> None: + """The state the flag exists for.""" + self.without_a_conversation() + self.reopen_answers(76) + log = self.watch_the_probe() + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assert_probe_changed_nothing(log) + self.assertIn("SOURCE_RETURNED", completed.stdout) + + def test_a_kept_session_is_byte_identical(self) -> None: + """A refusal for a second reason must be just as empty-handed.""" + self.reopen_answers(76) + log = self.watch_the_probe() + self.keep_this_session() + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assert_probe_changed_nothing(log) + self.assertIn("because you asked to keep it", completed.stdout) + + def test_an_absent_lifecycle_record_is_byte_identical(self) -> None: + """No record at all: the verb has nothing to read and must write none.""" + self.without_a_conversation() + self.reopen_answers(76) + log = self.watch_the_probe() + self.delete_the_lifecycle_record() + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assert_probe_changed_nothing(log) + + def test_a_half_written_lifecycle_record_is_byte_identical(self) -> None: + """A truncated document is what a probe meets mid-write or after a + full disk. It must refuse without repairing, replacing or extending + anything -- a close path that "fixes" state it cannot read is how a + wrong conversation gets tombstoned.""" + self.without_a_conversation() + self.reopen_answers(76) + log = self.watch_the_probe() + self.corrupt_the_lifecycle_record() + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assert_probe_changed_nothing(log) + + +class ProbeConcurrencyTests(ProbeSideEffectHarness): + def test_two_probes_at_once_still_write_nothing(self) -> None: + """Two shells asking at the same moment. + + A session that crashes while another generation of the same session id + is deciding is rare, but the answer must not depend on who won: both + refuse, and between them they leave the trees untouched. + """ + self.without_a_conversation() + self.reopen_answers(76) + log = self.watch_the_probe() + self.crashing_provider() + completed = self.launch("exit\n") + self.assertEqual(0, completed.returncode, completed.stderr) + first = self.assert_probe_changed_nothing(log) + + # Fire the verb again, concurrently, from processes that are NOT the + # managed shell -- the lifecycle proof must refuse them outright. + environment = self.environment() + environment.update( + { + "SESSION_KIT_LIFECYCLE_SESSION_ID": "main2", + "SESSION_KIT_LIFECYCLE_BOOT_ID": "11111111-2222-3333-4444-555555555555", + "SESSION_KIT_LIFECYCLE_SHELL_PID": "1", + "SESSION_KIT_LIFECYCLE_SHELL_START_TICKS": "1", + } + ) + running = [ + subprocess.Popen( + [ + "python3", + str(CORE), + "lifecycle", + "closed", + "--only-with-conversation", + ], + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + for _ in range(4) + ] + for process in running: + _, errors = process.communicate(timeout=30) + self.assertNotEqual(0, process.returncode, "a forged caller was served") + self.assertIn("outside the exact", errors) + # The trees are still exactly what the first probe left behind. + self.assertEqual(first["after"], self._tree_now()) + + def _tree_now(self) -> dict: + out: dict[str, str] = {} + for root in (self.state, self.home / ".local" / "share"): + if not root.exists(): + continue + for path in sorted(root.rglob("*")): + if path.is_dir(): + out[str(path) + "/"] = "dir" + continue + try: + out[str(path)] = hashlib.sha256(path.read_bytes()).hexdigest() + except OSError as exc: + out[str(path)] = f"unreadable:{exc.errno}" + return out + + +class FlaglessVerbUnchangedTests(ProbeSideEffectHarness): + """The other half: a flag on a shared verb must not move its old callers. + + `bye` and a clean provider exit both call `lifecycle closed` with NO flag, + and they must keep closing exactly as they did. + """ + + def test_a_clean_exit_still_records_the_conversation(self) -> None: + completed = self.launch("") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("Codex exited. Closing this session.", completed.stdout) + self.assertEqual([f"codex:{UUID}"], sorted(self.close_intents())) + rows = self.ledger_rows() + self.assertEqual(1, len(rows), rows) + self.assertEqual("codex", rows[0]["provider"]) + self.assertEqual(UUID, rows[0]["uuid"]) + + def test_the_flagless_refusal_still_lands_as_history(self) -> None: + """The flagless refusal keeps its old job: a `shell` row, on purpose. + + This is the behaviour the flag had to leave alone. A conversation-less + session that the PERSON ended -- a clean provider exit, no flag -- has + nothing to tombstone, and it still belongs on the closed list as + history rather than vanishing. Same code path, opposite decision, and + the difference is only ever the flag. + """ + self.without_a_conversation() + completed = self.launch("") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("Codex exited. Closing this session.", completed.stdout) + rows = self.ledger_rows() + self.assertEqual(1, len(rows), rows) + self.assertEqual("shell", rows[0]["provider"]) + self.assertEqual("", rows[0]["uuid"]) + self.assertEqual([], sorted(self.close_intents())) + + +if __name__ == "__main__": + import unittest + + unittest.main() diff --git a/tests/test_project_dir_name.py b/tests/test_project_dir_name.py new file mode 100644 index 0000000..09eb2e6 --- /dev/null +++ b/tests/test_project_dir_name.py @@ -0,0 +1,455 @@ +"""The short project-directory name is proved, never guessed. + +Every refusal path prints nothing: an old Claude, an unregistered directory, +an ambiguous registry, a live session, or a name collision all leave the +launch on today's munged-name behaviour. The one mutating step, renaming the +legacy directory, happens only with every proof in hand, because Claude +reads auto memory solely from the resolved name and a half-migrated project +is a session with amnesia. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import tempfile +import threading +import unittest +from unittest import mock + +from lib.sessionkit_inventory import accounts, project_dir_name + + +def _executable(path: Path) -> None: + path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + path.chmod(0o755) + + +class Fixture: + def __init__(self) -> None: + # The module under test honors SESSION_KIT_PROJECT_DIR_NAME=off as a + # kill switch. A shell that exports it -- flipped once for debugging + # and forgotten -- turns every rename in this module into a refusal + # and fails four tests for a reason that is not a defect (that is + # exactly how it was first reported, as a suspected regression, on + # 2026-08-19). Every test therefore starts from the variable unset; + # the kill-switch test sets it back deliberately through its own + # patch. + self._environment = mock.patch.dict(os.environ, {}, clear=False) + self._environment.start() + os.environ.pop("SESSION_KIT_PROJECT_DIR_NAME", None) + self.temp = tempfile.TemporaryDirectory(prefix=".projects-dirname-") + self.base = Path(self.temp.name) + self.root = self.base / "repo" + self.root.mkdir() + self.profile = self.base / "profile" + (self.profile / "projects").mkdir(parents=True) + self.projects_file = self.base / "projects.tsv" + self.projects_file.write_text(f"sl\tclaude\t{self.root}\n", encoding="utf-8") + # A launcher new enough for the variable, resolved through the same + # immutable-release layout the real installer creates. + versions = self.base / "share" / "versions" + versions.mkdir(parents=True) + _executable(versions / "2.1.234") + bin_dir = self.base / "bin" + bin_dir.mkdir() + (bin_dir / "claude").symlink_to(versions / "2.1.234") + self.claude = str(bin_dir / "claude") + + @property + def munged(self) -> Path: + munged = project_dir_name.MUNGE_RE.sub("-", os.fspath(self.root.resolve())) + return self.profile / "projects" / munged + + def close(self) -> None: + self.temp.cleanup() + self._environment.stop() + + def resolve(self) -> str | None: + return project_dir_name.resolve_project_dir_name( + self.profile, self.root, self.projects_file, self.claude + ) + + +class VersionGateTest(unittest.TestCase): + def setUp(self) -> None: + self.fixture = Fixture() + self.addCleanup(self.fixture.close) + + def test_release_layout_new_enough_passes(self) -> None: + self.assertTrue( + project_dir_name.claude_supports_project_dir_name(self.fixture.claude) + ) + + def test_older_release_refuses(self) -> None: + versions = self.fixture.base / "share" / "versions" + _executable(versions / "2.1.233") + launcher = Path(self.fixture.claude) + launcher.unlink() + launcher.symlink_to(versions / "2.1.233") + self.assertFalse( + project_dir_name.claude_supports_project_dir_name(self.fixture.claude) + ) + self.assertIsNone(self.fixture.resolve()) + + def test_unrecognised_layout_refuses(self) -> None: + launcher = Path(self.fixture.claude) + launcher.unlink() + _executable(launcher) + self.assertFalse( + project_dir_name.claude_supports_project_dir_name(self.fixture.claude) + ) + + def test_missing_command_refuses(self) -> None: + self.assertFalse( + project_dir_name.claude_supports_project_dir_name( + str(self.fixture.base / "absent") + ) + ) + + +class AliasTest(unittest.TestCase): + def setUp(self) -> None: + self.fixture = Fixture() + self.addCleanup(self.fixture.close) + + def test_registered_root_names_its_alias(self) -> None: + self.assertEqual( + project_dir_name.alias_for_root( + self.fixture.projects_file, self.fixture.root + ), + "sl", + ) + + def test_unregistered_directory_refuses(self) -> None: + elsewhere = self.fixture.base / "elsewhere" + elsewhere.mkdir() + self.assertIsNone( + project_dir_name.alias_for_root(self.fixture.projects_file, elsewhere) + ) + + def test_two_rows_for_one_root_refuse(self) -> None: + self.fixture.projects_file.write_text( + f"sl\tclaude\t{self.fixture.root}\nalso\tcodex\t{self.fixture.root}\n", + encoding="utf-8", + ) + self.assertIsNone( + project_dir_name.alias_for_root( + self.fixture.projects_file, self.fixture.root + ) + ) + + def test_reserved_device_name_refuses(self) -> None: + self.fixture.projects_file.write_text( + f"com1\tclaude\t{self.fixture.root}\n", encoding="utf-8" + ) + self.assertIsNone( + project_dir_name.alias_for_root( + self.fixture.projects_file, self.fixture.root + ) + ) + + def test_subdirectory_of_a_root_refuses(self) -> None: + inside = self.fixture.root / "deeper" + inside.mkdir() + self.assertIsNone( + project_dir_name.alias_for_root(self.fixture.projects_file, inside) + ) + + +class LivenessTest(unittest.TestCase): + def setUp(self) -> None: + self.fixture = Fixture() + self.addCleanup(self.fixture.close) + self.proc = self.fixture.base / "proc" + self.proc.mkdir() + + def _process( + self, pid: int, profile: Path, cwd: Path, comm: str | None = None + ) -> None: + entry = self.proc / str(pid) + entry.mkdir() + (entry / "environ").write_bytes( + b"PATH=/usr/bin\0CLAUDE_CONFIG_DIR=" + + os.fsencode(os.fspath(profile)) + + b"\0" + ) + (entry / "cwd").symlink_to(cwd) + if comm is not None: + (entry / "comm").write_text(comm + "\n", encoding="ascii") + + def test_profile_process_inside_root_is_live(self) -> None: + self._process(4242, self.fixture.profile, self.fixture.root) + self.assertTrue( + project_dir_name.profile_live_in_root( + self.fixture.profile, self.fixture.root.resolve(), self.proc + ) + ) + + def test_other_profile_or_other_directory_is_not(self) -> None: + elsewhere = self.fixture.base / "elsewhere" + elsewhere.mkdir() + self._process(4242, self.fixture.profile, elsewhere) + self._process(4243, self.fixture.base / "other-profile", self.fixture.root) + self.assertFalse( + project_dir_name.profile_live_in_root( + self.fixture.profile, self.fixture.root.resolve(), self.proc + ) + ) + + def test_own_shell_ancestors_never_count(self) -> None: + own = next(iter(project_dir_name._ancestor_pids())) + self._process(own, self.fixture.profile, self.fixture.root, comm="bash") + self.assertFalse( + project_dir_name.profile_live_in_root( + self.fixture.profile, self.fixture.root.resolve(), self.proc + ) + ) + + def test_a_provider_ancestor_in_the_root_is_live(self) -> None: + """A session inside a session: renaming under it splits ITS store. + + Review lane rv-pdn-1 (2026-08-17) reproduced the split by placing a + same-profile provider in the excluded ancestor chain. Only shells and + launch plumbing are excused now; a provider above us counts as live. + """ + own = next(iter(project_dir_name._ancestor_pids())) + self._process(own, self.fixture.profile, self.fixture.root, comm="claude") + self.assertTrue( + project_dir_name.profile_live_in_root( + self.fixture.profile, self.fixture.root.resolve(), self.proc + ) + ) + + def test_an_ancestor_without_a_readable_comm_is_live(self) -> None: + own = next(iter(project_dir_name._ancestor_pids())) + self._process(own, self.fixture.profile, self.fixture.root) + self.assertTrue( + project_dir_name.profile_live_in_root( + self.fixture.profile, self.fixture.root.resolve(), self.proc + ) + ) + + def test_unreadable_cwd_counts_as_live(self) -> None: + entry = self.proc / "4242" + entry.mkdir() + (entry / "environ").write_bytes( + b"CLAUDE_CONFIG_DIR=" + os.fsencode(os.fspath(self.fixture.profile)) + b"\0" + ) + self.assertTrue( + project_dir_name.profile_live_in_root( + self.fixture.profile, self.fixture.root.resolve(), self.proc + ) + ) + + +class MigrationTest(unittest.TestCase): + def setUp(self) -> None: + self.fixture = Fixture() + self.addCleanup(self.fixture.close) + quiet = mock.patch.object( + project_dir_name, "profile_live_in_root", return_value=False + ) + self.live = quiet.start() + self.addCleanup(quiet.stop) + + def test_fresh_project_exports_without_renaming(self) -> None: + self.assertEqual(self.fixture.resolve(), "sl") + self.assertFalse(self.fixture.munged.exists()) + + def test_legacy_directory_is_renamed_with_its_contents(self) -> None: + legacy = self.fixture.munged + (legacy / "memory").mkdir(parents=True) + (legacy / "memory" / "MEMORY.md").write_text("kept\n", encoding="utf-8") + self.assertEqual(self.fixture.resolve(), "sl") + renamed = self.fixture.profile / "projects" / "sl" + self.assertFalse(legacy.exists()) + self.assertEqual( + (renamed / "memory" / "MEMORY.md").read_text(encoding="utf-8"), "kept\n" + ) + + def test_live_session_defers_everything(self) -> None: + self.live.return_value = True + self.fixture.munged.mkdir(parents=True) + self.assertIsNone(self.fixture.resolve()) + self.assertTrue(self.fixture.munged.is_dir()) + + def test_both_names_existing_refuse(self) -> None: + self.fixture.munged.mkdir(parents=True) + (self.fixture.profile / "projects" / "sl").mkdir() + self.assertIsNone(self.fixture.resolve()) + self.assertTrue(self.fixture.munged.is_dir()) + + def test_kill_switch_refuses(self) -> None: + with mock.patch.dict(os.environ, {"SESSION_KIT_PROJECT_DIR_NAME": "off"}): + self.assertIsNone(self.fixture.resolve()) + + def test_a_failed_rename_refuses_instead_of_exporting(self) -> None: + self.fixture.munged.mkdir(parents=True) + with mock.patch.object(Path, "rename", side_effect=OSError): + self.assertIsNone(self.fixture.resolve()) + + def test_a_raw_launch_during_the_rename_is_undone(self) -> None: + """The scan re-runs on the far side of the rename, and a hit undoes it. + + A raw provider start takes no part in the migration lock, so it can + begin between the quiet scan and the rename (review lane rv-pdn-1). + The post-rename scan finds it, the move is undone, and the raw + session finds the world exactly as it was. + """ + (self.fixture.munged / "memory").mkdir(parents=True) + self.live.side_effect = [False, True] + self.assertIsNone(self.fixture.resolve()) + self.assertTrue((self.fixture.munged / "memory").is_dir()) + self.assertFalse((self.fixture.profile / "projects" / "sl").exists()) + + def test_a_failed_undo_leaves_the_visible_both_names_state(self) -> None: + (self.fixture.munged / "memory").mkdir(parents=True) + self.live.side_effect = [False, True] + original = Path.rename + calls = {"count": 0} + + def rename_once(target: Path, destination: Path) -> None: + calls["count"] += 1 + if calls["count"] > 1: + raise OSError("undo refused") + original(target, destination) + + with mock.patch.object(Path, "rename", rename_once): + self.assertIsNone(self.fixture.resolve()) + # The forward move happened and the undo could not: both-names is the + # state the next launch refuses on and doctor reports. + self.assertTrue((self.fixture.profile / "projects" / "sl").is_dir()) + self.assertFalse(self.fixture.munged.exists()) + + def test_an_alias_path_that_is_a_regular_file_refuses(self) -> None: + (self.fixture.profile / "projects").mkdir(parents=True, exist_ok=True) + (self.fixture.profile / "projects" / "sl").write_text( + "not a directory\n", encoding="utf-8" + ) + self.assertIsNone(self.fixture.resolve()) + self.fixture.munged.mkdir(parents=True) + self.assertIsNone(self.fixture.resolve()) + self.assertTrue(self.fixture.munged.is_dir()) + + def test_simultaneous_launches_migrate_once_and_agree(self) -> None: + """The rename loser re-reads the winner's world instead of splitting it.""" + (self.fixture.munged / "memory").mkdir(parents=True) + barrier = threading.Barrier(2) + results: list[str | None] = [] + + def launch() -> None: + barrier.wait() + results.append(self.fixture.resolve()) + + threads = [threading.Thread(target=launch) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + self.assertEqual(results, ["sl", "sl"]) + self.assertFalse(self.fixture.munged.exists()) + self.assertTrue((self.fixture.profile / "projects" / "sl" / "memory").is_dir()) + + +class DoctorTest(unittest.TestCase): + def setUp(self) -> None: + self.fixture = Fixture() + self.addCleanup(self.fixture.close) + self.accounts_root = self.fixture.base / "accounts" + self.profile = self.accounts_root / "duck" + (self.profile / "projects").mkdir(parents=True) + (self.profile / "settings.json").write_text( + json.dumps({"autoContinueAtUsageLimit": False}), encoding="utf-8" + ) + + def report(self) -> tuple[str, str]: + return project_dir_name.doctor_report( + self.fixture.projects_file, self.accounts_root + ) + + def test_clean_estate_is_ok(self) -> None: + status, detail = self.report() + self.assertEqual(status, "ok") + self.assertIn("1 named projects", detail) + + def test_pending_rename_stays_ok_and_is_counted(self) -> None: + munged = project_dir_name.MUNGE_RE.sub( + "-", os.fspath(self.fixture.root.resolve()) + ) + (self.profile / "projects" / munged).mkdir() + status, detail = self.report() + self.assertEqual(status, "ok") + self.assertIn("1 awaiting", detail) + + def test_both_names_warn(self) -> None: + munged = project_dir_name.MUNGE_RE.sub( + "-", os.fspath(self.fixture.root.resolve()) + ) + (self.profile / "projects" / munged).mkdir() + (self.profile / "projects" / "sl").mkdir() + status, detail = self.report() + self.assertEqual(status, "warn") + self.assertIn("both names", detail) + + def test_duplicate_registration_warns(self) -> None: + self.fixture.projects_file.write_text( + f"sl\tclaude\t{self.fixture.root}\nalso\tcodex\t{self.fixture.root}\n", + encoding="utf-8", + ) + status, detail = self.report() + self.assertEqual(status, "warn") + self.assertIn("registered as both", detail) + + def test_missing_auto_continue_key_warns(self) -> None: + (self.profile / "settings.json").write_text("{}", encoding="utf-8") + status, detail = self.report() + self.assertEqual(status, "warn") + self.assertIn("autoContinueAtUsageLimit", detail) + + +class EnrollmentSettingsTest(unittest.TestCase): + def test_new_profiles_start_with_auto_continue_off(self) -> None: + with tempfile.TemporaryDirectory(prefix=".projects-dirname-") as raw: + base = Path(raw) + source = base / "source" + source.mkdir() + (source / "settings.json").write_text( + json.dumps({"model": "opus"}), encoding="utf-8" + ) + profile = base / "profile" + with mock.patch.object( + accounts, "_default_profile_dir", return_value=source + ): + accounts.sync_profile_configuration("claude", profile) + written = json.loads( + (profile / "settings.json").read_text(encoding="utf-8") + ) + self.assertIs(written["autoContinueAtUsageLimit"], False) + self.assertEqual(written["model"], "opus") + + def test_a_source_without_settings_still_gets_the_switch(self) -> None: + """The default is a promise about every profile, not a copy transform. + + Review lane rv-pdn-2 (2026-08-17): a source profile with no + settings.json enrolled a profile without the file, so a limited + session could resume itself when its usage window reset. + """ + with tempfile.TemporaryDirectory(prefix=".projects-dirname-") as raw: + base = Path(raw) + source = base / "source" + source.mkdir() + profile = base / "profile" + with mock.patch.object( + accounts, "_default_profile_dir", return_value=source + ): + accounts.sync_profile_configuration("claude", profile) + written = json.loads( + (profile / "settings.json").read_text(encoding="utf-8") + ) + self.assertEqual({"autoContinueAtUsageLimit": False}, written) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..2929336 --- /dev/null +++ b/tests/test_projects.py @@ -0,0 +1,541 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import sqlite3 +import tempfile +import unittest +from unittest import mock + +from lib.sessionkit_inventory import projects + + +class ProjectDiscoveryTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="session-kit-projects.") + self.root = Path(self.temporary.name) + self.home = self.root / "home" + self.home.mkdir() + self.claude = self.home / ".claude" + self.codex = self.home / ".codex" + self.claude.mkdir() + self.codex.mkdir() + self.environment = mock.patch.dict( + os.environ, + { + "HOME": str(self.home), + "SESSION_KIT_CODEX_HOME": str(self.codex), + "TMPDIR": str(self.root.parent), + }, + clear=False, + ) + self.environment.start() + + def tearDown(self) -> None: + self.environment.stop() + self.temporary.cleanup() + + def directory(self, relative: str) -> Path: + path = self.root / relative + path.mkdir(parents=True) + return path.resolve() + + def private_json(self, path: Path, value: object) -> None: + path.write_text(json.dumps(value), encoding="utf-8") + path.chmod(0o600) + + def codex_database(self, rows: list[str]) -> None: + connection = sqlite3.connect(self.codex / "state_5.sqlite") + connection.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, cwd TEXT)") + connection.executemany( + "INSERT INTO threads VALUES (?, ?)", + [ + (f"00000000-0000-4000-8000-{number:012d}", cwd) + for number, cwd in enumerate(rows) + ], + ) + connection.commit() + connection.close() + + def test_discovers_both_providers_without_scanning_temp_projects(self) -> None: + claude_project = self.directory("work/claude") + shared = self.directory("work/shared") + codex_thread = self.directory("work/codex-thread") + temporary = self.directory("claude-tmp/scratchpad/test") + self.private_json( + self.claude / "settings.json", + {"env": {"CLAUDE_CODE_TMPDIR": str(self.root / "claude-tmp")}}, + ) + self.private_json( + self.home / ".claude.json", + { + "projects": { + str(claude_project): {}, + str(shared): {}, + str(temporary): {}, + str(self.root / "missing"): {}, + } + }, + ) + history = self.claude / "history.jsonl" + history.write_text( + "\n".join( + ( + json.dumps({"project": str(claude_project)}), + "not json", + json.dumps({"project": str(temporary)}), + ) + ) + + "\n", + encoding="utf-8", + ) + history.chmod(0o600) + (self.codex / "config.toml").write_text( + f'[projects."{shared}"]\ntrust_level = "trusted"\n', + encoding="utf-8", + ) + self.codex_database( + [str(codex_thread), str(shared), str(self.root / "missing")] + ) + + result = projects.discover_projects(self.home) + + self.assertEqual(result["warnings"], []) + self.assertEqual( + {(row["provider"], row["cwd"]) for row in result["projects"]}, + { + ("claude", str(claude_project)), + ("claude", str(shared)), + ("codex", str(shared)), + ("codex", str(codex_thread)), + }, + ) + + def test_discovery_excludes_inaccessible_paths_but_keeps_shared_readable_repos( + self, + ) -> None: + inaccessible = self.directory("private/root-copy") + shared = self.directory("shared/team-repo") + self.private_json( + self.home / ".claude.json", + {"projects": {str(inaccessible): {}, str(shared): {}}}, + ) + real_access = os.access + + def selective_access(path: os.PathLike[str], mode: int) -> bool: + if Path(path) == inaccessible: + return False + return real_access(path, mode) + + with mock.patch.object(projects.os, "access", side_effect=selective_access): + result = projects.discover_projects(self.home) + + self.assertEqual( + result["projects"], + [{"provider": "claude", "cwd": str(shared)}], + ) + + def test_codex_home_falls_back_to_provider_override(self) -> None: + project = self.directory("work/provider-home") + with mock.patch.dict(os.environ, {"CODEX_HOME": str(self.codex)}, clear=False): + os.environ.pop("SESSION_KIT_CODEX_HOME", None) + (self.codex / "config.toml").write_text( + f'[projects."{project}"]\ntrust_level = "trusted"\n', + encoding="utf-8", + ) + result = projects.discover_projects(self.home) + self.assertIn( + ("codex", str(project)), + {(row["provider"], row["cwd"]) for row in result["projects"]}, + ) + + def test_import_preserves_existing_rows_and_builds_unique_aliases(self) -> None: + first = self.directory("work/one/app") + second = self.directory("work/two/app") + shared = self.directory("work/shared") + self.private_json( + self.home / ".claude.json", + {"projects": {str(first): {}, str(second): {}, str(shared): {}}}, + ) + (self.codex / "config.toml").write_text( + f'[projects."{shared}"]\ntrust_level = "trusted"\n', encoding="utf-8" + ) + projects_file = self.root / "config/projects.tsv" + projects_file.parent.mkdir() + projects_file.write_text( + "# keep this comment\nmanual\tshell\t" + str(self.root) + "\n", + encoding="utf-8", + ) + projects_file.chmod(0o600) + state = self.root / "state" + + result = projects.import_projects(projects_file, state) + + rows = projects._parse_projects(projects_file.read_bytes()) + self.assertEqual(rows[0]["alias"], "manual") + self.assertEqual( + {(row["alias"], row["provider"], row["cwd"]) for row in result["added"]}, + { + ("one-app", "claude", str(first)), + ("two-app", "claude", str(second)), + ("shared", "claude", str(shared)), + }, + ) + self.assertTrue(Path(result["backup"]).is_file()) + self.assertIn("# keep this comment", projects_file.read_text(encoding="utf-8")) + self.assertEqual(projects_file.stat().st_mode & 0o777, 0o600) + + repeated = projects.import_projects(projects_file, state) + self.assertEqual(repeated["added"], []) + self.assertIsNone(repeated["backup"]) + + def test_import_never_adds_a_second_row_for_a_configured_directory(self) -> None: + configured = self.directory("work/app") + self.private_json( + self.home / ".claude.json", {"projects": {str(configured): {}}} + ) + (self.codex / "config.toml").write_text( + f'[projects."{configured}"]\ntrust_level = "trusted"\n', encoding="utf-8" + ) + projects_file = self.root / "projects.tsv" + projects_file.write_text(f"sl\tclaude\t{configured}\n", encoding="utf-8") + projects_file.chmod(0o600) + state = self.root / "state" + + result = projects.import_projects(projects_file, state) + + self.assertEqual(result["added"], []) + self.assertEqual( + projects._parse_projects(projects_file.read_bytes()), + [{"alias": "sl", "provider": "claude", "cwd": str(configured)}], + ) + + def test_selection_grammar_is_shared_by_the_installer_and_the_picker(self) -> None: + rows = [{"cwd": f"/w/{index}"} for index in range(1, 6)] + pick = lambda answer: [ # noqa: E731 + row["cwd"] for row in projects.select_rows(rows, answer) + ] + every = ["/w/1", "/w/2", "/w/3", "/w/4", "/w/5"] + self.assertEqual(pick("a"), every) + # The picker takes the whole word; so does everything else now. + self.assertEqual(pick("all"), every) + self.assertEqual(pick("ALL"), every) + self.assertEqual(pick("2"), ["/w/2"]) + self.assertEqual(pick(" 4 , 2-3 "), ["/w/2", "/w/3", "/w/4"]) + self.assertEqual(pick("4 2"), ["/w/2", "/w/4"]) + self.assertEqual(pick("3,3"), ["/w/3"]) + self.assertEqual(pick("2-2"), ["/w/2"]) + self.assertEqual(pick(""), []) + for bad in ( + "0", + "6", + "2-1", + "1-", + "x", + "1,,2", + "one", + "-3", + "2,", + "1-99999", # a range no picker could show, refused before it is built + "\u0663", # an Arabic-Indic digit is not a number a picker printed + "\u00b2", # a superscript passes str.isdigit() and breaks int() + ): + with self.assertRaises(projects.ProjectError, msg=bad): + projects.select_rows(rows, bad) + + def test_candidates_report_what_import_would_add_without_writing(self) -> None: + fresh = self.directory("work/fresh") + taken = self.directory("work/taken") + self.private_json( + self.home / ".claude.json", + {"projects": {str(fresh): {}, str(taken): {}}}, + ) + projects_file = self.root / "projects.tsv" + projects_file.write_text(f"sl\tclaude\t{taken}\n", encoding="utf-8") + projects_file.chmod(0o600) + before = projects_file.read_bytes() + + value = projects.project_candidates(projects_file) + + self.assertEqual( + value["candidates"], + [{"alias": "fresh", "provider": "claude", "cwd": str(fresh)}], + ) + self.assertEqual( + value["configured"], + [{"alias": "sl", "provider": "claude", "cwd": str(taken)}], + ) + self.assertEqual(value["ignored"], []) + self.assertEqual(before, projects_file.read_bytes()) + + def test_import_only_takes_the_directories_that_were_chosen(self) -> None: + wanted = self.directory("work/wanted") + skipped = self.directory("work/skipped") + self.private_json( + self.home / ".claude.json", + {"projects": {str(wanted): {}, str(skipped): {}}}, + ) + projects_file = self.root / "projects.tsv" + projects_file.write_text("", encoding="utf-8") + projects_file.chmod(0o600) + state = self.root / "state" + + result = projects.import_projects(projects_file, state, only=[str(wanted)]) + + self.assertEqual( + result["added"], + [{"alias": "wanted", "provider": "claude", "cwd": str(wanted)}], + ) + # The directory that was not chosen stays a candidate rather than + # being silently ignored for good. + self.assertEqual( + [ + row["cwd"] + for row in projects.project_candidates(projects_file)["candidates"] + ], + [str(skipped)], + ) + + def test_adding_a_directory_reverses_its_ignore_under_any_alias(self) -> None: + target = self.directory("work/kit") + projects_file = self.root / "projects.tsv" + projects_file.write_text("", encoding="utf-8") + projects_file.chmod(0o600) + state = self.root / "state" + projects.ignore_project(projects_file, state, str(target)) + + # Reusing the ignore row's own alias must not collide with it. + result = projects.add_project(projects_file, state, "kit", "codex", str(target)) + + self.assertEqual( + result["added"], + [{"alias": "kit", "provider": "codex", "cwd": str(target)}], + ) + self.assertEqual( + projects._parse_projects(projects_file.read_bytes()), + [{"alias": "kit", "provider": "codex", "cwd": str(target)}], + ) + + def test_add_derives_an_unused_alias_when_none_is_given(self) -> None: + target = self.directory("work/newsite/app") + projects_file = self.root / "projects.tsv" + projects_file.write_text("", encoding="utf-8") + projects_file.chmod(0o600) + + result = projects.add_project( + projects_file, self.root / "state", None, "claude", str(target) + ) + + # "app" is a generic leaf, so the suggestion widens to the parent. + self.assertEqual( + result["added"], + [{"alias": "newsite-app", "provider": "claude", "cwd": str(target)}], + ) + + def test_ignore_withdraws_a_shortcut_and_survives_rediscovery(self) -> None: + ignored = self.directory("work/parent") + kept = self.directory("work/parent/app") + self.private_json( + self.home / ".claude.json", + {"projects": {str(ignored): {}, str(kept): {}}}, + ) + projects_file = self.root / "projects.tsv" + projects_file.write_text( + f"# keep this comment\nsl\tclaude\t{kept}\nparent\tclaude\t{ignored}\n", + encoding="utf-8", + ) + projects_file.chmod(0o600) + state = self.root / "state" + + result = projects.ignore_project(projects_file, state, str(ignored)) + + self.assertEqual( + result["added"], + [{"alias": "parent", "provider": "ignore", "cwd": str(ignored)}], + ) + self.assertEqual([row["alias"] for row in result["removed"]], ["parent"]) + self.assertEqual( + projects._parse_projects(projects_file.read_bytes()), + [ + {"alias": "sl", "provider": "claude", "cwd": str(kept)}, + {"alias": "parent", "provider": "ignore", "cwd": str(ignored)}, + ], + ) + self.assertIn("# keep this comment", projects_file.read_text(encoding="utf-8")) + self.assertEqual(projects_file.stat().st_mode & 0o777, 0o600) + + # Discovery still reports the directory, and import must leave it alone. + imported = projects.import_projects(projects_file, state) + self.assertEqual(imported["added"], []) + + repeated = projects.ignore_project(projects_file, state, str(ignored)) + self.assertEqual(repeated["added"], []) + self.assertEqual(repeated["removed"], []) + self.assertIsNone(repeated["backup"]) + + def test_unignore_puts_a_dropped_directory_back_in_reach(self) -> None: + """Dropping was a one-way door with no verb anywhere to reverse it.""" + target = self.directory("work/dropped") + self.private_json( + self.home / ".claude.json", + {"projects": {str(target): {}}}, + ) + projects_file = self.root / "projects.tsv" + projects_file.write_text("# keep this comment\n", encoding="utf-8") + projects_file.chmod(0o600) + state = self.root / "state" + projects.ignore_project(projects_file, state, str(target)) + self.assertEqual( + [str(target)], + projects.project_candidates(projects_file)["ignored"], + ) + + restored = projects.unignore_project(projects_file, state, str(target)) + + self.assertEqual([], restored["added"]) + self.assertEqual( + [{"alias": "dropped", "provider": "ignore", "cwd": str(target)}], + restored["removed"], + ) + after = projects.project_candidates(projects_file) + self.assertEqual([], after["ignored"]) + # No decision at all, so the directory is offered again. + self.assertEqual( + [str(target)], + [row["cwd"] for row in after["candidates"]], + ) + self.assertIn("# keep this comment", projects_file.read_text(encoding="utf-8")) + self.assertEqual(projects_file.stat().st_mode & 0o777, 0o600) + + def test_unignore_of_a_directory_that_was_never_dropped_changes_nothing( + self, + ) -> None: + target = self.directory("work/listed") + projects_file = self.root / "projects.tsv" + projects_file.write_text(f"listed\tclaude\t{target}\n", encoding="utf-8") + projects_file.chmod(0o600) + + result = projects.unignore_project( + projects_file, self.root / "state", str(target) + ) + + self.assertEqual([], result["added"]) + self.assertEqual([], result["removed"]) + self.assertEqual( + [{"alias": "listed", "provider": "claude", "cwd": str(target)}], + projects._parse_projects(projects_file.read_bytes()), + ) + + def test_ignore_names_a_directory_that_had_no_shortcut(self) -> None: + target = self.directory("work/unlisted") + projects_file = self.root / "projects.tsv" + projects_file.write_text("", encoding="utf-8") + projects_file.chmod(0o600) + + result = projects.ignore_project( + projects_file, self.root / "state", str(target) + ) + + self.assertEqual( + result["added"], + [{"alias": "unlisted", "provider": "ignore", "cwd": str(target)}], + ) + + def test_unignore_withdraws_the_decision_and_leaves_the_file_alone(self) -> None: + target = self.directory("work/hidden") + kept = self.directory("work/kept") + projects_file = self.root / "projects.tsv" + projects_file.write_text( + f"# keep this comment\nsl\tclaude\t{kept}\n", encoding="utf-8" + ) + projects_file.chmod(0o600) + state = self.root / "state" + projects.ignore_project(projects_file, state, str(target)) + + result = projects.unignore_project(projects_file, state, str(target)) + + self.assertEqual(result["added"], []) + self.assertEqual([row["cwd"] for row in result["removed"]], [str(target)]) + self.assertEqual( + projects._parse_projects(projects_file.read_bytes()), + [{"alias": "sl", "provider": "claude", "cwd": str(kept)}], + ) + self.assertIn("# keep this comment", projects_file.read_text(encoding="utf-8")) + self.assertEqual(projects_file.stat().st_mode & 0o777, 0o600) + + repeated = projects.unignore_project(projects_file, state, str(target)) + self.assertEqual(repeated["removed"], []) + self.assertIsNone(repeated["backup"]) + + def test_unignore_reaches_a_directory_that_no_longer_exists(self) -> None: + target = self.directory("work/gone") + projects_file = self.root / "projects.tsv" + projects_file.write_text("", encoding="utf-8") + projects_file.chmod(0o600) + state = self.root / "state" + projects.ignore_project(projects_file, state, str(target)) + target.rmdir() + + # The row is a decision, not a shortcut: it outlives its directory and + # must still be withdrawable, or the picker's restore key would refuse + # exactly the rows a person most wants gone from the file. + result = projects.unignore_project(projects_file, state, str(target)) + + self.assertEqual([row["cwd"] for row in result["removed"]], [str(target)]) + self.assertEqual(projects._parse_projects(projects_file.read_bytes()), []) + + def test_unignore_refuses_a_path_that_is_not_absolute(self) -> None: + projects_file = self.root / "projects.tsv" + projects_file.write_text("", encoding="utf-8") + projects_file.chmod(0o600) + with self.assertRaises(projects.ProjectError): + projects.unignore_project(projects_file, self.root / "state", "work/gone") + + def test_add_validates_alias_and_never_replaces_an_existing_alias(self) -> None: + cwd = self.directory("work/project") + other = self.directory("work/other") + projects_file = self.root / "projects.tsv" + state = self.root / "state" + + added = projects.add_project( + projects_file, state, "my-project", "codex", str(cwd) + ) + self.assertEqual(added["added"][0]["cwd"], str(cwd)) + repeated = projects.add_project( + projects_file, state, "my-project", "codex", str(cwd) + ) + self.assertEqual(repeated["added"], []) + with self.assertRaisesRegex(projects.ProjectError, "already exists"): + projects.add_project( + projects_file, state, "my-project", "claude", str(other) + ) + with self.assertRaisesRegex(projects.ProjectError, "lowercase"): + projects.add_project( + projects_file, state, "Bad Alias", "claude", str(other) + ) + + def test_unsafe_provider_file_warns_and_other_provider_still_works(self) -> None: + codex_project = self.directory("work/codex") + target = self.root / "real-claude.json" + self.private_json(target, {"projects": {str(codex_project): {}}}) + os.symlink(target, self.home / ".claude.json") + (self.codex / "config.toml").write_text( + f'[projects."{codex_project}"]\ntrust_level = "trusted"\n', encoding="utf-8" + ) + + result = projects.discover_projects(self.home) + + self.assertEqual( + result["projects"], [{"provider": "codex", "cwd": str(codex_project)}] + ) + self.assertTrue( + any( + "Claude Code project configuration" in warning + for warning in result["warnings"] + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_projects_context.py b/tests/test_projects_context.py new file mode 100644 index 0000000..d9b906d --- /dev/null +++ b/tests/test_projects_context.py @@ -0,0 +1,679 @@ +"""Launch plans, startup approval, resume context, and the machine surface. + +``sp new <project>`` is supposed to reproduce a setup, and entering a project +is supposed to show what is already going on there. These tests pin both, and +they pin the rule that keeps the first from becoming a way to run a command +out of a repository nobody read. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import stat +import subprocess +import sys +import tempfile +import unittest + +from lib.sessionkit_projects import cli, context, identity, launch + +REPO = Path(__file__).resolve().parents[1] +MANIFEST = """ +name = "demo-api" +description = "Demo API service" +provider = "codex" +account = "work" +model = "gpt-5-1-codex" +startup = "sp msg main demo-up" + +[[team]] +role = "reviewer" +provider = "claude" +model = "claude-opus-5" +expertise = "review" +scope = "Review the diff." +""" + + +class ProjectFixture(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="session-kit-context.") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + self.state = self.root / "state" + self.state.mkdir() + self.repo = self.root / "repo" + (self.repo / "sub").mkdir(parents=True) + self.projects_file = self.root / "projects.tsv" + self.projects_file.write_text(f"demo\tcodex\t{self.repo}\n", encoding="utf-8") + + def manifest(self, text: str = MANIFEST) -> None: + (self.repo / identity.MANIFEST_NAME).write_text(text, encoding="utf-8") + + def project(self, alias: str = "demo") -> identity.Project: + project = identity.Resolver(self.projects_file, environ={}).resolve_alias(alias) + assert project is not None + return project + + +class LaunchPlanTests(ProjectFixture): + def test_a_trusted_manifest_supplies_provider_account_and_model(self) -> None: + self.manifest() + plan = launch.launch_plan(self.project(), state_dir=self.state) + self.assertEqual(plan["provider"], "codex") + self.assertEqual(plan["account"], "work") + self.assertEqual(plan["model"], "gpt-5-1-codex") + self.assertEqual(plan["decisions"]["model"], "manifest") + # The only note is the unapproved startup command, covered below. + self.assertEqual([note for note in plan["notes"] if "startup" not in note], []) + + def test_an_explicit_flag_beats_the_manifest(self) -> None: + self.manifest() + plan = launch.launch_plan( + self.project(), + requested_provider="claude", + requested_model="claude-opus-5", + state_dir=self.state, + ) + self.assertEqual(plan["provider"], "claude") + self.assertEqual(plan["model"], "claude-opus-5") + self.assertEqual(plan["decisions"]["provider"], "flag") + # Not overridden, so the manifest still supplies it. + self.assertEqual(plan["account"], "work") + self.assertEqual(plan["decisions"]["account"], "manifest") + + def test_without_a_manifest_the_shortcut_row_still_decides_the_provider( + self, + ) -> None: + plan = launch.launch_plan(self.project(), state_dir=self.state) + self.assertEqual(plan["provider"], "codex") + self.assertEqual(plan["decisions"]["provider"], "shortcut") + self.assertIsNone(plan["model"]) + self.assertEqual(plan["team"], []) + + def test_an_untrusted_manifest_is_listed_but_never_applied(self) -> None: + self.manifest() + self.projects_file.write_text("", encoding="utf-8") + resolver = identity.Resolver(self.projects_file, environ={}) + project = resolver.resolve(self.repo) + assert project is not None + plan = launch.launch_plan(project, state_dir=self.state) + self.assertFalse(plan["trusted"]) + self.assertIsNone(plan["model"]) + self.assertIsNone(plan["account"]) + self.assertEqual(plan["provider"], "shell") + self.assertEqual(plan["decisions"]["provider"], "default") + # The roles are still shown: a person should see what the repository + # proposes even while the host declines to act on it. + self.assertEqual([role["role"] for role in plan["team"]], ["reviewer"]) + self.assertFalse(plan["team_launchable"]) + self.assertTrue( + any("not on the host's project list" in note for note in plan["notes"]) + ) + + def test_a_shell_session_takes_neither_account_nor_model(self) -> None: + self.manifest(MANIFEST.replace('provider = "codex"', 'provider = "shell"')) + plan = launch.launch_plan(self.project(), state_dir=self.state) + self.assertEqual(plan["provider"], "shell") + self.assertIsNone(plan["account"]) + self.assertIsNone(plan["model"]) + self.assertEqual( + [note for note in plan["notes"] if "startup" not in note], + [ + "a shell session takes no account; the account was not applied", + "a shell session takes no model; the model was not applied", + ], + ) + + def test_an_unknown_requested_provider_is_refused(self) -> None: + with self.assertRaises(ValueError): + launch.launch_plan(self.project(), requested_provider="gemini") + + def test_a_worktree_plan_names_the_repository_it_groups_under(self) -> None: + worktree = self.root / "wt-feature" + (worktree / "sub").mkdir(parents=True) + (self.repo / ".git" / "worktrees" / "feature").mkdir(parents=True) + (worktree / ".git").write_text( + f"gitdir: {self.repo}/.git/worktrees/feature\n", encoding="utf-8" + ) + (worktree / identity.MANIFEST_NAME).write_text(MANIFEST, encoding="utf-8") + project = identity.Resolver(self.projects_file, environ={}).resolve(worktree) + assert project is not None + plan = launch.launch_plan(project, state_dir=self.state) + self.assertTrue(plan["is_worktree"]) + self.assertEqual(plan["group_root"], os.fspath(self.repo)) + self.assertTrue(plan["trusted"]) + self.assertEqual(plan["model"], "gpt-5-1-codex") + + +class StartupApprovalTests(ProjectFixture): + def test_an_unapproved_startup_command_never_runs(self) -> None: + self.manifest() + plan = launch.launch_plan(self.project(), state_dir=self.state) + self.assertEqual(plan["startup"], "sp msg main demo-up") + self.assertEqual(plan["startup_state"], launch.UNAPPROVED) + self.assertFalse(plan["startup_applied"]) + + def test_a_non_interactive_launch_says_why_it_skipped_the_command(self) -> None: + self.manifest() + plan = launch.launch_plan( + self.project(), state_dir=self.state, interactive=False + ) + self.assertTrue(any("not interactive" in note for note in plan["notes"])) + + def test_approval_is_by_exact_command_and_survives(self) -> None: + self.manifest() + project = self.project() + launch.approve_startup(project.root, "sp msg main demo-up", self.state) + plan = launch.launch_plan(self.project(), state_dir=self.state) + self.assertEqual(plan["startup_state"], launch.APPROVED) + self.assertTrue(plan["startup_applied"]) + self.assertEqual(plan["decisions"]["startup"], "manifest") + + def test_editing_the_command_withdraws_the_approval(self) -> None: + self.manifest() + project = self.project() + launch.approve_startup(project.root, "sp msg main demo-up", self.state) + self.manifest(MANIFEST.replace("sp msg main demo-up", "curl example.invalid")) + plan = launch.launch_plan(self.project(), state_dir=self.state) + self.assertEqual(plan["startup_state"], launch.CHANGED) + self.assertFalse(plan["startup_applied"]) + + def test_the_approval_record_is_owner_only(self) -> None: + self.manifest() + launch.approve_startup(self.project().root, "sp msg main demo-up", self.state) + path = self.state / "projects" / launch.APPROVAL_FILE + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + + def test_a_group_or_world_writable_record_is_not_believed(self) -> None: + self.manifest() + project = self.project() + launch.approve_startup(project.root, "sp msg main demo-up", self.state) + path = self.state / "projects" / launch.APPROVAL_FILE + path.chmod(0o666) + self.assertEqual( + launch.startup_state(project.root, "sp msg main demo-up", self.state), + launch.UNAPPROVED, + ) + + def test_approving_a_second_project_keeps_the_first(self) -> None: + other = self.root / "other" + other.mkdir() + launch.approve_startup("/a/one", "one", self.state) + launch.approve_startup(os.fspath(other), "two", self.state) + self.assertEqual( + launch.startup_state("/a/one", "one", self.state), launch.APPROVED + ) + self.assertEqual( + launch.startup_state(os.fspath(other), "two", self.state), launch.APPROVED + ) + + def test_a_manifest_with_no_startup_has_no_startup_state(self) -> None: + self.manifest(MANIFEST.replace('startup = "sp msg main demo-up"\n', "")) + plan = launch.launch_plan(self.project(), state_dir=self.state) + self.assertIsNone(plan["startup"]) + self.assertIsNone(plan["startup_state"]) + self.assertNotIn("startup", plan["decisions"]) + + +def session(cwd: str, **overrides: object) -> dict[str, object]: + row = { + "shpool_id": "s1", + "display_shpool_id": "s1", + "provider": "claude", + "cwd": cwd, + "display_title": "a session", + "agent_status": "idle", + "needs_you": False, + "started_at_unix_ms": 1, + } + row.update(overrides) + return row + + +class ResumeContextTests(ProjectFixture): + def test_context_gathers_sessions_under_the_project(self) -> None: + self.manifest() + project = self.project() + result = context.project_context( + project, + sessions=[ + session(os.fspath(self.repo)), + session(os.fspath(self.repo / "sub"), shpool_id="s2"), + session(os.fspath(self.root / "elsewhere"), shpool_id="s3"), + ], + ) + self.assertEqual(result["counts"]["sessions"], 2) + self.assertEqual(result["counts"]["team_roles"], 1) + self.assertEqual([row["shpool_id"] for row in result["sessions"]], ["s1", "s2"]) + + def test_a_context_view_carries_only_the_fields_it_declares(self) -> None: + self.manifest() + result = context.project_context( + self.project(), + sessions=[session(os.fspath(self.repo), secret="do not copy this")], + ) + self.assertNotIn("secret", result["sessions"][0]) + self.assertEqual(result["sessions"][0]["project_root"], os.fspath(self.repo)) + + def test_an_unreadable_store_is_named_rather_than_read_as_empty(self) -> None: + self.manifest() + result = context.project_context( + self.project(), unavailable=["the session snapshot is unreadable"] + ) + self.assertEqual(result["counts"]["sessions"], 0) + self.assertEqual(result["unavailable"], ["the session snapshot is unreadable"]) + + def test_a_worktree_counts_as_the_project_it_was_cut_from(self) -> None: + worktree = self.root / "wt-feature" + worktree.mkdir() + (self.repo / ".git" / "worktrees" / "feature").mkdir(parents=True) + (worktree / ".git").write_text( + f"gitdir: {self.repo}/.git/worktrees/feature\n", encoding="utf-8" + ) + self.manifest() + (worktree / identity.MANIFEST_NAME).write_text(MANIFEST, encoding="utf-8") + self.projects_file.write_text( + f"demo\tcodex\t{self.repo}\ndemo-wt\tcodex\t{worktree}\n", encoding="utf-8" + ) + resolver = identity.Resolver(self.projects_file, environ={}) + project = resolver.resolve_alias("demo") + assert project is not None + result = context.project_context( + project, + sessions=[session(os.fspath(worktree), shpool_id="s9")], + group=resolver.projects(), + ) + self.assertEqual(result["counts"]["worktrees"], 1) + self.assertEqual(result["counts"]["sessions"], 1) + self.assertEqual(result["sessions"][0]["project_root"], os.fspath(worktree)) + + +class SessionGroupingTests(ProjectFixture): + def test_sessions_bucket_by_project_and_keep_the_unplaceable_ones(self) -> None: + self.manifest() + loose = self.root / "loose" + loose.mkdir() + resolver = identity.Resolver(self.projects_file, environ={}) + rows = [ + session(os.fspath(self.repo)), + session(os.fspath(self.repo / "sub"), shpool_id="s2"), + session(os.fspath(loose), shpool_id="s3"), + session("", shpool_id="s4"), + ] + assignments = resolver.assign( + str(row["cwd"]) for row in rows if isinstance(row["cwd"], str) + ) + grouped = context.group_sessions_by_project(rows, assignments) + self.assertEqual(len(grouped["groups"]), 1) + self.assertEqual(grouped["groups"][0]["name"], "demo-api") + self.assertEqual(len(grouped["groups"][0]["sessions"]), 2) + self.assertEqual( + [row["shpool_id"] for row in grouped["ungrouped"]], ["s3", "s4"] + ) + + +class WorktreeLabelTests(ProjectFixture): + """A materialised worktree is placed by the registry when its path cannot. + + `sp new --worktree` creates a directory that is on no shortcut list and may + carry no manifest, so path resolution alone would drop its sessions into + the unplaceable pile. The worktree registry annotates the row with the main + repository it was cut from, the same path this module groups by. + """ + + def worktree_session(self, cwd: str, repo: str, **overrides: object) -> dict: + return session( + cwd, worktree={"branch": "feature/one", "repo": repo}, **overrides + ) + + def test_the_worktree_label_survives_the_field_whitelist(self) -> None: + self.manifest() + result = context.project_context( + self.project(), + sessions=[ + self.worktree_session(os.fspath(self.repo), os.fspath(self.repo)) + ], + ) + self.assertEqual( + result["sessions"][0]["worktree"], + {"branch": "feature/one", "repo": os.fspath(self.repo)}, + ) + + def test_a_worktree_session_off_the_project_path_still_counts(self) -> None: + self.manifest() + outside = self.root / "wt-elsewhere" + outside.mkdir() + result = context.project_context( + self.project(), + sessions=[self.worktree_session(os.fspath(outside), os.fspath(self.repo))], + ) + self.assertEqual(result["counts"]["sessions"], 1) + self.assertEqual(result["sessions"][0]["project_root"], os.fspath(self.repo)) + + def test_a_worktree_of_another_repository_is_not_borrowed(self) -> None: + self.manifest() + outside = self.root / "wt-elsewhere" + outside.mkdir() + result = context.project_context( + self.project(), + sessions=[self.worktree_session(os.fspath(outside), "/some/other/repo")], + ) + self.assertEqual(result["counts"]["sessions"], 0) + + def test_grouping_places_a_worktree_session_under_its_project(self) -> None: + self.manifest() + outside = self.root / "wt-elsewhere" + outside.mkdir() + resolver = identity.Resolver(self.projects_file, environ={}) + rows = [ + session(os.fspath(self.repo)), + self.worktree_session( + os.fspath(outside), os.fspath(self.repo), shpool_id="s2" + ), + ] + assignments = resolver.assign(str(row["cwd"]) for row in rows) + grouped = context.group_sessions_by_project(rows, assignments) + self.assertEqual(len(grouped["groups"]), 1) + self.assertEqual(grouped["ungrouped"], []) + self.assertEqual(len(grouped["groups"][0]["sessions"]), 2) + # The worktree's own root is not on the row, so it is attributed to the + # project rather than to a root this cannot name. + self.assertEqual(grouped["groups"][0]["roots"], [os.fspath(self.repo)]) + self.assertEqual( + grouped["groups"][0]["sessions"][1]["project_root"], os.fspath(self.repo) + ) + + def test_a_project_worked_only_in_worktrees_still_groups(self) -> None: + # The delegated case: every worker is in a worktree and no session runs + # in the project directory itself. Placing these needs the host's + # project list, not the projects that happened to resolve from the + # session list, which here is none of them. + self.manifest() + resolver = identity.Resolver(self.projects_file, environ={}) + rows = [] + for index in range(3): + worktree = self.root / f"wt-{index}" + worktree.mkdir() + rows.append( + self.worktree_session( + os.fspath(worktree), os.fspath(self.repo), shpool_id=f"s{index}" + ) + ) + assignments = resolver.assign(str(row["cwd"]) for row in rows) + self.assertEqual(set(assignments.values()), {None}) + + without_list = context.group_sessions_by_project(rows, assignments) + self.assertEqual(len(without_list["ungrouped"]), 3) + + grouped = context.group_sessions_by_project( + rows, assignments, projects=resolver.projects() + ) + self.assertEqual(grouped["ungrouped"], []) + self.assertEqual(len(grouped["groups"]), 1) + self.assertEqual(len(grouped["groups"][0]["sessions"]), 3) + self.assertEqual(grouped["groups"][0]["name"], "demo-api") + + def test_a_malformed_worktree_label_is_ignored_not_trusted(self) -> None: + self.manifest() + outside = self.root / "wt-elsewhere" + outside.mkdir() + for label in ({"repo": "relative/path"}, {"repo": ""}, {}, "not-a-table", None): + with self.subTest(label=label): + result = context.project_context( + self.project(), + sessions=[session(os.fspath(outside), worktree=label)], + ) + self.assertEqual(result["counts"]["sessions"], 0) + + +class CommandLineTests(ProjectFixture): + def run_cli(self, *argv: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + os.fspath(REPO / "lib" / "sessionkit_projects" / "cli.py"), + "--projects-file", + os.fspath(self.projects_file), + "--state-dir", + os.fspath(self.state), + *argv, + ], + capture_output=True, + text=True, + check=False, + ) + + def test_resolve_prints_the_project_as_json(self) -> None: + self.manifest() + completed = self.run_cli("resolve", os.fspath(self.repo / "sub")) + self.assertEqual(completed.returncode, cli.EXIT_OK, completed.stderr) + value = json.loads(completed.stdout) + self.assertEqual(value["project"]["root"], os.fspath(self.repo)) + self.assertEqual(value["project"]["name"], "demo-api") + + def test_resolve_reports_a_directory_in_no_project(self) -> None: + completed = self.run_cli("resolve", os.fspath(self.root)) + self.assertEqual(completed.returncode, cli.EXIT_NO_PROJECT) + self.assertIn("no project", completed.stderr) + + def test_launch_plan_by_alias_carries_the_manifest(self) -> None: + self.manifest() + completed = self.run_cli("launch-plan", "demo") + self.assertEqual(completed.returncode, cli.EXIT_OK, completed.stderr) + plan = json.loads(completed.stdout) + self.assertEqual(plan["provider"], "codex") + self.assertEqual(plan["model"], "gpt-5-1-codex") + self.assertEqual(plan["startup_state"], launch.UNAPPROVED) + + def test_approve_startup_records_and_then_the_plan_applies_it(self) -> None: + self.manifest() + completed = self.run_cli("approve-startup", "demo") + self.assertEqual(completed.returncode, cli.EXIT_OK, completed.stderr) + plan = json.loads(self.run_cli("launch-plan", "demo").stdout) + self.assertTrue(plan["startup_applied"]) + + def test_approve_startup_refuses_a_digest_that_no_longer_matches(self) -> None: + self.manifest() + completed = self.run_cli("approve-startup", "demo", "--expect", "0" * 64) + self.assertEqual(completed.returncode, cli.EXIT_USAGE) + self.assertIn("changed since it was shown", completed.stderr) + + def test_approve_startup_refuses_an_untrusted_project(self) -> None: + self.manifest() + self.projects_file.write_text("", encoding="utf-8") + completed = self.run_cli("approve-startup", os.fspath(self.repo)) + self.assertEqual(completed.returncode, cli.EXIT_USAGE) + self.assertIn("projects add", completed.stderr) + + def test_check_validates_a_manifest_and_names_what_is_wrong(self) -> None: + self.manifest() + completed = self.run_cli("check", os.fspath(self.repo)) + self.assertEqual(completed.returncode, cli.EXIT_OK, completed.stderr) + self.assertTrue(json.loads(completed.stdout)["valid"]) + self.manifest('name = "demo"\nbogus = "x"\n') + completed = self.run_cli("check", os.fspath(self.repo)) + self.assertEqual(completed.returncode, cli.EXIT_MANIFEST) + self.assertIn("bogus", completed.stderr) + + def test_list_reports_projects_and_their_groups(self) -> None: + self.manifest() + completed = self.run_cli("list") + self.assertEqual(completed.returncode, cli.EXIT_OK, completed.stderr) + value = json.loads(completed.stdout) + self.assertEqual([row["name"] for row in value["projects"]], ["demo-api"]) + self.assertEqual(len(value["groups"]), 1) + + def test_context_reads_sessions_from_a_snapshot_file(self) -> None: + self.manifest() + snapshot = self.root / "snapshot.json" + snapshot.write_text( + json.dumps({"sessions": [session(os.fspath(self.repo / "sub"))]}), + encoding="utf-8", + ) + completed = self.run_cli("context", "demo", "--snapshot", os.fspath(snapshot)) + self.assertEqual(completed.returncode, cli.EXIT_OK, completed.stderr) + value = json.loads(completed.stdout) + self.assertEqual(value["counts"]["sessions"], 1) + self.assertEqual(value["project"]["name"], "demo-api") + + def test_context_names_an_unreadable_snapshot_instead_of_reporting_none( + self, + ) -> None: + self.manifest() + completed = self.run_cli( + "context", + "demo", + "--snapshot", + os.fspath(self.root / "absent.json"), + ) + self.assertEqual(completed.returncode, cli.EXIT_OK, completed.stderr) + value = json.loads(completed.stdout) + self.assertEqual(value["counts"]["sessions"], 0) + self.assertTrue(any("unreadable" in note for note in value["unavailable"])) + + def test_group_sessions_places_a_worktree_session_from_the_host_list(self) -> None: + self.manifest() + worktree = self.root / "wt-one" + worktree.mkdir() + snapshot = self.root / "snapshot.json" + snapshot.write_text( + json.dumps( + { + "sessions": [ + session( + os.fspath(worktree), + worktree={"branch": "f/one", "repo": os.fspath(self.repo)}, + ) + ] + } + ), + encoding="utf-8", + ) + completed = self.run_cli("group-sessions", "--snapshot", os.fspath(snapshot)) + self.assertEqual(completed.returncode, cli.EXIT_OK, completed.stderr) + value = json.loads(completed.stdout) + self.assertEqual(value["ungrouped"], []) + self.assertEqual(len(value["groups"]), 1) + self.assertEqual(value["groups"][0]["name"], "demo-api") + + def test_group_sessions_buckets_a_snapshot(self) -> None: + self.manifest() + snapshot = self.root / "snapshot.json" + snapshot.write_text( + json.dumps( + { + "sessions": [ + session(os.fspath(self.repo)), + session(os.fspath(self.root), shpool_id="s2"), + ] + } + ), + encoding="utf-8", + ) + completed = self.run_cli("group-sessions", "--snapshot", os.fspath(snapshot)) + self.assertEqual(completed.returncode, cli.EXIT_OK, completed.stderr) + value = json.loads(completed.stdout) + self.assertEqual(len(value["groups"]), 1) + self.assertEqual(len(value["ungrouped"]), 1) + + def test_a_bad_verb_is_a_usage_error(self) -> None: + self.assertEqual(self.run_cli("nonsense").returncode, cli.EXIT_USAGE) + + +class ShellSeamTests(ProjectFixture): + """`sp new <project>` reads its plan through one shell helper. + + These drive `sk_project_plan` exactly as `start_new` does, so the contract + between the resolver and the shell is pinned on both sides. + """ + + def plan_line(self, *arguments: str) -> tuple[int, str]: + script = ( + "set -uo pipefail\n" + f"source {os.fspath(REPO / 'bin' / 'session_kit_common')}\n" + 'sk_project_plan "$@"\n' + ) + completed = subprocess.run( + ["bash", "-c", script, "sk_project_plan", *arguments], + capture_output=True, + text=True, + check=False, + env={ + **os.environ, + "SESSION_KIT_PROJECTS_FILE": os.fspath(self.projects_file), + "SESSION_KIT_STATE_DIR": os.fspath(self.state), + "SESSION_KIT_RELEASE_DIR": os.fspath(REPO), + # The seam never starts a daemon; this keeps the sourced file + # from hunting for a real shpool on the developer's machine. + "SESSION_KIT_SHPOOL_CMD": "/bin/true", + }, + ) + return completed.returncode, completed.stdout.rstrip("\n") + + def test_the_manifest_decides_over_the_shortcut_row(self) -> None: + self.manifest() + code, line = self.plan_line("demo", "", "", "") + self.assertEqual(code, 0) + self.assertEqual( + line.split("\t"), + [os.fspath(self.repo), "codex", "work", "gpt-5-1-codex", launch.UNAPPROVED], + ) + + def test_flags_decide_over_the_manifest(self) -> None: + self.manifest() + code, line = self.plan_line("demo", "claude", "", "claude-opus-5") + self.assertEqual(code, 0) + fields = line.split("\t") + self.assertEqual(fields[1], "claude") + self.assertEqual(fields[3], "claude-opus-5") + self.assertEqual(fields[2], "work") + + def test_an_untrusted_manifest_applies_nothing_through_the_seam(self) -> None: + self.manifest() + self.projects_file.write_text("", encoding="utf-8") + code, line = self.plan_line(os.fspath(self.repo / "sub"), "", "", "") + self.assertEqual(code, 0) + self.assertEqual(line.split("\t"), [os.fspath(self.repo), "shell", "", "", ""]) + + def test_a_missing_resolver_is_named_rather_than_blamed_on_the_alias(self) -> None: + # Otherwise `sp new demo` reports a perfectly good alias as unknown + # when what is actually missing is a file. + script = ( + "set -uo pipefail\n" + f"source {os.fspath(REPO / 'bin' / 'session_kit_common')}\n" + 'sk_project_plan "$@"\n' + ) + completed = subprocess.run( + ["bash", "-c", script, "sk_project_plan", "demo", "", "", ""], + capture_output=True, + text=True, + check=False, + env={ + **os.environ, + "SESSION_KIT_PROJECTS_FILE": os.fspath(self.projects_file), + "SESSION_KIT_STATE_DIR": os.fspath(self.state), + "SESSION_KIT_RELEASE_DIR": os.fspath(REPO), + "SESSION_KIT_PROJECTS_CORE": os.fspath(self.root / "absent.py"), + "SESSION_KIT_SHPOOL_CMD": "/bin/true", + }, + ) + self.assertNotEqual(completed.returncode, 0) + self.assertIn("project resolver is missing", completed.stderr) + self.assertEqual(completed.stdout, "") + + def test_an_unknown_project_fails_so_the_caller_stops(self) -> None: + code, line = self.plan_line("absent", "", "", "") + self.assertNotEqual(code, 0) + self.assertEqual(line, "") + + def test_a_project_without_a_manifest_still_resolves_its_row(self) -> None: + code, line = self.plan_line("demo", "", "", "") + self.assertEqual(code, 0) + self.assertEqual(line.split("\t")[:2], [os.fspath(self.repo), "codex"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_projects_identity.py b/tests/test_projects_identity.py new file mode 100644 index 0000000..25a7fd5 --- /dev/null +++ b/tests/test_projects_identity.py @@ -0,0 +1,420 @@ +"""One project identity across the two senses the kit grew separately. + +These tests pin the membership rule: a directory belongs to the deepest +project root above it, and the trust rule that keeps a cloned repository +from choosing what this host launches. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import tempfile +import unittest + +from lib.sessionkit_projects import identity + + +class ResolverFixture(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="session-kit-identity.") + self.addCleanup(self.temporary.cleanup) + # A macOS temporary directory is itself a symlink; resolving here keeps + # the fixture's own paths comparable with the canonical ones under test. + self.root = Path(self.temporary.name).resolve() + self.config = self.root / "config" + self.config.mkdir() + self.projects_file = self.config / "projects.tsv" + + def directory(self, relative: str) -> Path: + path = self.root / relative + path.mkdir(parents=True, exist_ok=True) + return path + + def manifest(self, relative: str, text: str) -> Path: + path = self.directory(relative) / identity.MANIFEST_NAME + path.write_text(text, encoding="utf-8") + return path + + def shortcuts(self, *rows: tuple[str, str, str]) -> None: + self.projects_file.write_text( + "".join(f"{alias}\t{kind}\t{cwd}\n" for alias, kind, cwd in rows), + encoding="utf-8", + ) + + def resolver(self) -> identity.Resolver: + return identity.Resolver(self.projects_file, environ={}) + + +class ResolutionTests(ResolverFixture): + def test_a_subdirectory_resolves_to_the_manifest_above_it(self) -> None: + self.manifest("repo", 'name = "demo"\n') + deep = self.directory("repo/a/b/c") + project = self.resolver().resolve(deep) + assert project is not None + self.assertEqual(project.root, os.fspath(self.root / "repo")) + self.assertEqual(project.name, "demo") + self.assertEqual(project.source, identity.SOURCE_MANIFEST) + + def test_the_deepest_manifest_wins_over_one_further_up(self) -> None: + self.manifest("outer", 'name = "outer"\n') + self.manifest("outer/inner", 'name = "inner"\n') + project = self.resolver().resolve(self.directory("outer/inner/work")) + assert project is not None + self.assertEqual(project.name, "inner") + self.assertEqual(project.root, os.fspath(self.root / "outer" / "inner")) + + def test_without_a_manifest_the_deepest_shortcut_row_wins(self) -> None: + outer, inner = self.directory("outer"), self.directory("outer/inner") + self.shortcuts( + ("outer", "claude", os.fspath(outer)), + ("inner", "codex", os.fspath(inner)), + ) + project = self.resolver().resolve(self.directory("outer/inner/work")) + assert project is not None + self.assertEqual(project.root, os.fspath(inner)) + self.assertEqual(project.alias, "inner") + self.assertEqual(project.source, identity.SOURCE_SHORTCUT) + self.assertEqual(project.shortcut_provider, "codex") + + def test_a_manifest_wins_over_a_shortcut_further_up(self) -> None: + self.shortcuts(("outer", "claude", os.fspath(self.directory("outer")))) + self.manifest("outer/inner", 'name = "inner"\n') + project = self.resolver().resolve(self.directory("outer/inner")) + assert project is not None + self.assertEqual(project.name, "inner") + + def test_a_directory_in_no_project_resolves_to_nothing(self) -> None: + self.assertIsNone(self.resolver().resolve(self.directory("loose"))) + + def test_a_sibling_with_a_shared_prefix_is_not_inside_the_project(self) -> None: + self.shortcuts(("app", "claude", os.fspath(self.directory("srv/app")))) + self.assertIsNone(self.resolver().resolve(self.directory("srv/application"))) + + def test_an_ignore_row_is_never_a_project(self) -> None: + ignored = self.directory("scratch") + self.shortcuts(("scratch", "ignore", os.fspath(ignored))) + resolver = self.resolver() + self.assertIsNone(resolver.resolve(ignored)) + self.assertIsNone(resolver.resolve_alias("scratch")) + self.assertTrue(resolver.is_ignored(os.fspath(ignored))) + self.assertEqual(resolver.projects(), []) + + def test_an_ignored_directory_still_resolves_through_its_manifest(self) -> None: + # Ignoring a directory keeps it out of the shortcut list. It does not + # make the sessions running there unplaceable. + ignored = self.directory("repo") + self.manifest("repo", 'name = "demo"\n') + self.shortcuts(("repo", "ignore", os.fspath(ignored))) + project = self.resolver().resolve(ignored) + assert project is not None + self.assertEqual(project.name, "demo") + self.assertFalse(project.trusted) + + def test_an_alias_resolves_with_its_manifest(self) -> None: + repo = self.directory("repo") + self.manifest("repo", 'name = "demo"\nmodel = "claude-opus-5"\n') + self.shortcuts(("demo", "claude", os.fspath(repo))) + project = self.resolver().resolve_alias("demo") + assert project is not None + self.assertEqual(project.alias, "demo") + assert project.manifest is not None + self.assertEqual(project.manifest["model"], "claude-opus-5") + self.assertTrue(project.trusted) + + def test_an_unknown_alias_resolves_to_nothing(self) -> None: + self.assertIsNone(self.resolver().resolve_alias("absent")) + + def test_a_manifest_root_may_point_at_a_subdirectory_of_itself(self) -> None: + self.directory("repo/src") + self.manifest("repo", 'name = "demo"\nroot = "src"\n') + project = self.resolver().resolve(self.directory("repo/src/deep")) + assert project is not None + self.assertEqual(project.root, os.fspath(self.root / "repo" / "src")) + + def test_the_display_name_falls_back_from_manifest_to_alias_to_folder(self) -> None: + self.manifest("named", 'name = "chosen"\n') + aliased = self.directory("aliased") + plain = self.directory("plain") + self.manifest("plain", "# no name\n") + self.shortcuts( + ("shortcut-name", "claude", os.fspath(aliased)), + ("plain-alias", "claude", os.fspath(plain)), + ) + resolver = self.resolver() + named = resolver.resolve(self.root / "named") + by_alias = resolver.resolve(aliased) + by_manifest_without_name = resolver.resolve(plain) + assert named is not None and by_alias is not None + assert by_manifest_without_name is not None + self.assertEqual(named.name, "chosen") + self.assertEqual(by_alias.name, "shortcut-name") + self.assertEqual(by_manifest_without_name.name, "plain-alias") + + def test_resolution_stops_rather_than_walking_forever(self) -> None: + deep = self.directory("/".join(f"d{index}" for index in range(80))) + self.manifest("", 'name = "top"\n') + # The manifest is above the walk bound, so the walk gives up instead of + # climbing out of the fixture and into the developer's real home. + self.assertIsNone(self.resolver().resolve(deep)) + + +class AliasVersusPathTests(ResolverFixture): + """An alias launches its own directory; a path resolves the project above it. + + These two entry points can name different roots when a shortcut points + *inside* a project that has a manifest further up. Both behaviours are + deliberate and neither applies a setting the other would not, so the rule + is pinned here rather than left to be discovered. + """ + + def setUp(self) -> None: + super().setUp() + self.repo = self.directory("repo") + self.tools = self.directory("repo/tools") + self.manifest("repo", 'name = "demo"\nmodel = "claude-opus-5"\n') + + def test_a_path_inside_the_project_resolves_to_the_project(self) -> None: + self.shortcuts(("tools", "shell", os.fspath(self.tools))) + project = self.resolver().resolve(self.tools) + assert project is not None + self.assertEqual(project.root, os.fspath(self.repo)) + self.assertEqual(project.name, "demo") + + def test_an_alias_keeps_its_own_directory_as_the_launch_target(self) -> None: + # Changing this would silently move where `sp new tools` lands. + self.shortcuts(("tools", "shell", os.fspath(self.tools))) + project = self.resolver().resolve_alias("tools") + assert project is not None + self.assertEqual(project.root, os.fspath(self.tools)) + + def test_a_manifest_further_up_governs_neither_alias_launch(self) -> None: + # Whether the parent is on the list or not, an alias pointing deeper + # launches with the shortcut's own settings only. The alias directory + # is itself on the list, so the project is trusted, there is simply + # no manifest of its own to apply, and the one above it is not + # reached. Adding /repo/tools is not a statement about who may write + # /repo's manifest. + for rows in ( + (("tools", "shell", os.fspath(self.tools)),), + ( + ("demo", "claude", os.fspath(self.repo)), + ("tools", "shell", os.fspath(self.tools)), + ), + ): + with self.subTest(rows=len(rows)): + self.shortcuts(*rows) + project = self.resolver().resolve_alias("tools") + assert project is not None + self.assertIsNone(project.manifest) + self.assertIsNone(project.manifest_path) + self.assertEqual(project.source, identity.SOURCE_SHORTCUT) + + def test_an_alias_at_the_manifest_directory_gets_everything(self) -> None: + self.shortcuts(("demo", "claude", os.fspath(self.repo))) + project = self.resolver().resolve_alias("demo") + assert project is not None + self.assertTrue(project.trusted) + assert project.manifest is not None + self.assertEqual(project.manifest["model"], "claude-opus-5") + + +class TrustTests(ResolverFixture): + def test_a_manifest_without_a_shortcut_row_is_read_but_not_trusted(self) -> None: + self.manifest("repo", 'name = "demo"\nmodel = "claude-opus-5"\n') + project = self.resolver().resolve(self.root / "repo") + assert project is not None + self.assertFalse(project.trusted) + assert project.manifest is not None + self.assertEqual(project.manifest["model"], "claude-opus-5") + self.assertTrue( + any("not on the host's project list" in note for note in project.warnings) + ) + + def test_adding_the_project_is_what_makes_its_manifest_apply(self) -> None: + repo = self.directory("repo") + self.manifest("repo", 'name = "demo"\n') + self.shortcuts(("demo", "claude", os.fspath(repo))) + project = self.resolver().resolve(repo) + assert project is not None + self.assertTrue(project.trusted) + self.assertEqual(project.warnings, ()) + + def test_a_shortcut_deeper_than_the_manifest_root_does_not_confer_trust( + self, + ) -> None: + # Adding /repo/tools says nothing about who may write /repo's manifest. + self.manifest("repo", 'name = "demo"\n') + self.shortcuts(("tools", "shell", os.fspath(self.directory("repo/tools")))) + project = self.resolver().resolve(self.root / "repo" / "tools") + assert project is not None + self.assertEqual(project.root, os.fspath(self.root / "repo")) + self.assertFalse(project.trusted) + + +class MalformedManifestTests(ResolverFixture): + def test_a_malformed_manifest_reports_and_applies_nothing(self) -> None: + repo = self.directory("repo") + self.manifest("repo", 'name = "demo"\nnonsense = = =\n') + self.shortcuts(("demo", "claude", os.fspath(repo))) + project = self.resolver().resolve(repo) + assert project is not None + # The project still exists, so its sessions stay placeable. + self.assertEqual(project.root, os.fspath(repo)) + self.assertIsNone(project.manifest) + assert project.manifest_error is not None + self.assertIn("line 2", project.manifest_error) + self.assertEqual(project.name, "demo") + + def test_a_symlinked_manifest_is_reported_not_followed(self) -> None: + repo = self.directory("repo") + elsewhere = self.directory("elsewhere") / "real.toml" + elsewhere.write_text('name = "attacker"\n', encoding="utf-8") + (repo / identity.MANIFEST_NAME).symlink_to(elsewhere) + project = self.resolver().resolve(repo) + assert project is not None + self.assertIsNone(project.manifest) + assert project.manifest_error is not None + self.assertIn("symlink", project.manifest_error) + self.assertNotEqual(project.name, "attacker") + + def test_an_unreadable_shortcut_line_is_skipped_with_a_warning(self) -> None: + good = self.directory("good") + self.projects_file.write_text( + "# a comment\n" + "\n" + "broken-line-without-tabs\n" + f"bad-kind\tgemini\t{good}\n" + "relative\tclaude\tnot/absolute\n" + f"good\tclaude\t{good}\n", + encoding="utf-8", + ) + resolver = self.resolver() + self.assertEqual([row["alias"] for row in resolver.shortcuts()], ["good"]) + self.assertEqual(len(resolver.warnings), 3) + + def test_a_missing_shortcut_file_is_not_an_error(self) -> None: + resolver = identity.Resolver(self.config / "absent.tsv", environ={}) + self.assertEqual(resolver.shortcuts(), []) + self.assertEqual(resolver.warnings, ()) + + +class WorktreeTests(ResolverFixture): + def link_worktree(self, worktree: Path, main: Path, name: str) -> None: + (main / ".git" / "worktrees" / name).mkdir(parents=True, exist_ok=True) + (worktree / ".git").write_text( + f"gitdir: {main}/.git/worktrees/{name}\n", encoding="utf-8" + ) + + def test_a_linked_worktree_groups_under_the_repository_it_came_from(self) -> None: + main = self.directory("repo") + worktree = self.directory("wt-feature") + self.link_worktree(worktree, main, "feature") + self.manifest("repo", 'name = "demo"\n') + self.manifest("wt-feature", 'name = "demo"\n') + project = self.resolver().resolve(worktree) + assert project is not None + self.assertEqual(project.root, os.fspath(worktree)) + self.assertEqual(project.group_root, os.fspath(main)) + self.assertTrue(project.is_worktree) + + def test_a_worktree_inherits_the_trust_of_its_repository(self) -> None: + main = self.directory("repo") + worktree = self.directory("wt-feature") + self.link_worktree(worktree, main, "feature") + self.manifest("wt-feature", 'name = "demo"\nmodel = "claude-opus-5"\n') + self.shortcuts(("demo", "claude", os.fspath(main))) + project = self.resolver().resolve(worktree) + assert project is not None + self.assertTrue(project.trusted) + + def test_a_normal_repository_is_its_own_group(self) -> None: + main = self.directory("repo") + (main / ".git").mkdir() + self.manifest("repo", 'name = "demo"\n') + project = self.resolver().resolve(main) + assert project is not None + self.assertFalse(project.is_worktree) + self.assertEqual(project.group, os.fspath(main)) + + def test_a_git_file_that_is_not_a_worktree_pointer_is_ignored(self) -> None: + repo = self.directory("repo") + for text in ("gitdir: /elsewhere/.git\n", "not a pointer\n", ""): + with self.subTest(text=text): + (repo / ".git").write_text(text, encoding="utf-8") + self.assertIsNone(identity.main_repository(os.fspath(repo))) + + def test_grouping_collapses_worktrees_under_their_repository(self) -> None: + main = self.directory("repo") + first = self.directory("wt-one") + second = self.directory("wt-two") + self.link_worktree(first, main, "one") + self.link_worktree(second, main, "two") + for relative in ("repo", "wt-one", "wt-two"): + self.manifest(relative, 'name = "demo"\n') + self.shortcuts( + ("demo", "claude", os.fspath(main)), + ("demo-one", "claude", os.fspath(first)), + ("demo-two", "claude", os.fspath(second)), + ) + groups = identity.group_projects(self.resolver().projects()) + self.assertEqual(len(groups), 1) + self.assertEqual(groups[0]["group_root"], os.fspath(main)) + self.assertEqual(len(groups[0]["members"]), 3) + + +class ListingTests(ResolverFixture): + def test_the_project_list_is_one_entry_per_shortcut_directory(self) -> None: + first, second = self.directory("one"), self.directory("two") + self.manifest("one", 'name = "alpha"\n') + self.shortcuts( + ("one", "claude", os.fspath(first)), + ("two", "codex", os.fspath(second)), + ("dup", "claude", os.fspath(first)), + ) + projects = self.resolver().projects() + self.assertEqual([project.name for project in projects], ["alpha", "two"]) + self.assertEqual(projects[0].source, identity.SOURCE_MANIFEST) + self.assertEqual(projects[1].source, identity.SOURCE_SHORTCUT) + + def test_assign_resolves_each_directory_once(self) -> None: + repo = self.directory("repo") + self.manifest("repo", 'name = "demo"\n') + deep = self.directory("repo/a") + loose = self.directory("loose") + assigned = self.resolver().assign( + [os.fspath(repo), os.fspath(deep), os.fspath(repo), os.fspath(loose)] + ) + self.assertEqual(len(assigned), 3) + self.assertIsNone(assigned[os.fspath(loose)]) + assert assigned[os.fspath(deep)] is not None + self.assertEqual(assigned[os.fspath(deep)].root, os.fspath(repo)) + + +class PathTests(unittest.TestCase): + def test_canonical_refuses_what_is_not_an_absolute_path(self) -> None: + self.assertIsNone(identity.canonical("\x00")) + + def test_membership_compares_whole_path_components(self) -> None: + self.assertTrue(identity._within("/srv/app/a", "/srv/app")) + self.assertTrue(identity._within("/srv/app", "/srv/app")) + self.assertFalse(identity._within("/srv/application", "/srv/app")) + self.assertTrue(identity._within("/srv", "/")) + + def test_the_default_shortcut_file_follows_the_shell_rules(self) -> None: + self.assertEqual( + identity.default_projects_file({"SESSION_KIT_PROJECTS_FILE": "/tmp/p.tsv"}), + Path("/tmp/p.tsv"), + ) + self.assertEqual( + identity.default_projects_file({"XDG_CONFIG_HOME": "/c", "HOME": "/h"}), + Path("/c/session-kit/projects.tsv"), + ) + self.assertEqual( + identity.default_projects_file({"HOME": "/h"}), + Path("/h/.config/session-kit/projects.tsv"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_projects_manifest.py b/tests/test_projects_manifest.py new file mode 100644 index 0000000..d89c4a4 --- /dev/null +++ b/tests/test_projects_manifest.py @@ -0,0 +1,272 @@ +"""The committable ``session-kit.toml`` reader. + +The manifest decides what a launch does, so these tests care about two things +above all: that a malformed or hostile file is refused by name rather than +half-applied, and that the hand-written subset parser reads a manifest to the +same values ``tomllib`` does, because a manifest that means one thing on +Python 3.13 and another on 3.10 is worse than no manifest at all. +""" + +from __future__ import annotations + +from pathlib import Path +import tempfile +import unittest + +from lib.sessionkit_projects import manifest + +try: + import tomllib +except ImportError: # Python 3.10 is supported and has no tomllib. + tomllib = None # type: ignore[assignment] + + +FULL = """ +# A complete manifest. +name = "demo-api" +description = "Demo API service" +root = "." +provider = "codex" +account = "work" +model = "gpt-5.1-codex" +startup = "sp msg main 'demo up'" + +[[team]] +role = "reviewer" +provider = "claude" +model = "claude-opus-5" +expertise = "review" +scope = "Review the diff." + +[[team]] +role = "builder" +provider = "codex" +model = "gpt-5.1-codex" +expertise = "implementation" +scope = "Implement the plan." +branch = "feature/demo" +""" + + +class SubsetParserTests(unittest.TestCase): + def test_a_full_manifest_parses_to_its_exact_values(self) -> None: + values = manifest.loads(FULL) + self.assertEqual(values["name"], "demo-api") + self.assertEqual(values["provider"], "codex") + self.assertEqual(values["account"], "work") + self.assertEqual(values["model"], "gpt-5.1-codex") + self.assertEqual(values["startup"], "sp msg main 'demo up'") + self.assertEqual(values["root"], ".") + self.assertEqual( + [role["role"] for role in values["team"]], ["reviewer", "builder"] + ) + self.assertEqual(values["team"][1]["branch"], "feature/demo") + self.assertIsNone(values["team"][0]["branch"]) + + def test_comments_blank_lines_and_trailing_comments_are_ignored(self) -> None: + values = manifest.loads( + '# leading\n\nname = "demo" # trailing\n\n# between\nprovider = "claude"\n' + ) + self.assertEqual(values["name"], "demo") + self.assertEqual(values["provider"], "claude") + + def test_literal_and_escaped_strings_read_the_same_as_toml_says(self) -> None: + parsed = manifest.parse_subset( + "literal = 'a \\\\ backslash'\n" + 'escaped = "line\\nbreak\\ttab \\u00e9 \\"quoted\\""\n' + ) + self.assertEqual(parsed["literal"], "a \\\\ backslash") + self.assertEqual(parsed["escaped"], 'line\nbreak\ttab \u00e9 "quoted"') + + def test_booleans_integers_and_arrays_parse(self) -> None: + # No manifest field uses these yet; the parser supports them so a + # future field does not need a second parser written under pressure. + parsed = manifest.parse_subset("a = true\nb = -12\nc = [1, 2]\nd = []\n") + self.assertEqual(parsed, {"a": True, "b": -12, "c": [1, 2], "d": []}) + + def test_an_unknown_top_level_key_is_refused_by_name(self) -> None: + with self.assertRaises(manifest.ManifestError) as caught: + manifest.loads('name = "demo"\nprovidr = "claude"\n') + self.assertIn("providr", str(caught.exception)) + + def test_an_unknown_section_is_refused_with_its_line_number(self) -> None: + with self.assertRaises(manifest.ManifestError) as caught: + manifest.loads('name = "demo"\n[settings]\nmodel = "x"\n') + self.assertIn("line 2", str(caught.exception)) + self.assertIn("[settings]", str(caught.exception)) + + def test_an_unknown_array_section_is_refused(self) -> None: + with self.assertRaises(manifest.ManifestError) as caught: + manifest.loads("[[workers]]\nrole = 'a'\n") + self.assertIn("[[workers]]", str(caught.exception)) + + def test_a_duplicate_key_is_refused_rather_than_last_one_winning(self) -> None: + with self.assertRaises(manifest.ManifestError) as caught: + manifest.loads('model = "a"\nmodel = "b"\n') + self.assertIn("set twice", str(caught.exception)) + + def test_a_key_after_a_team_header_belongs_to_that_team_as_toml_says(self) -> None: + # Matching TOML here is what keeps the subset a subset. The documented + # layout puts project settings above the first [[team]] for this reason. + values = manifest.loads("[[team]]\nrole = 'a'\n\nmodel = 'claude-opus-5'\n") + self.assertIsNone(values["model"]) + self.assertEqual(values["team"][0]["model"], "claude-opus-5") + + def test_unsupported_toml_constructs_are_refused_not_guessed(self) -> None: + for text, expected in ( + ('name = """multi"""\n', "multi-line"), + ("name = { a = 1 }\n", "inline tables"), + ('name = "unterminated\n', "closing quote"), + ("name = 2026-08-11\n", "only strings"), + ('name = "a" garbage\n', "unexpected text"), + ('"quoted" = "a"\n', "quoted keys"), + ("name\n", "expected key = value"), + ('name = "\\q"\n', "unsupported string escape"), + ('name = "\\u00"\n', "four hex digits"), + ("a = [[1]]\n", "nested arrays"), + ("a = [1 2]\n", "separated by commas"), + ): + with self.subTest(text=text): + with self.assertRaises(manifest.ManifestError) as caught: + manifest.parse_subset(text) + self.assertIn(expected, str(caught.exception)) + + @unittest.skipIf(tomllib is None, "tomllib is unavailable on this Python") + def test_the_subset_parser_agrees_with_real_toml(self) -> None: + """The subset is a subset: where both read a file, they must agree.""" + for text in ( + FULL, + 'name = "demo"\nstartup = "a\\nb"\n', + "# only comments\n", + "[[team]]\nrole = 'solo'\n", + ): + with self.subTest(text=text[:40]): + mine = manifest.parse_subset(text) + theirs = tomllib.loads(text) + self.assertEqual(mine, theirs) + + +class ValidationTests(unittest.TestCase): + def test_an_absolute_root_is_refused(self) -> None: + with self.assertRaises(manifest.ManifestError) as caught: + manifest.loads('root = "/etc"\n') + self.assertIn("not absolute", str(caught.exception)) + + def test_a_root_that_climbs_out_of_the_manifest_is_refused(self) -> None: + with self.assertRaises(manifest.ManifestError) as caught: + manifest.loads('root = "../elsewhere"\n') + self.assertIn("climb above", str(caught.exception)) + + def test_a_relative_root_is_normalised(self) -> None: + self.assertEqual(manifest.loads('root = "./src//app/"\n')["root"], "src/app") + self.assertEqual(manifest.loads("")["root"], ".") + + def test_field_shapes_are_enforced(self) -> None: + for text, expected in ( + ('name = "Demo Api"\n', "name is not in the accepted form"), + ('provider = "gemini"\n', "provider must be claude, codex, or shell"), + ('account = "WORK"\n', "account is not in the accepted form"), + ('model = "a model"\n', "model is not in the accepted form"), + ("[[team]]\nrole = 'a'\nprovider = 'shell'\n", "must be claude or codex"), + ("[[team]]\nmodel = 'a'\n", "needs a role name"), + ("[[team]]\nrole = 'a'\nnope = 'b'\n", "unknown key"), + ): + with self.subTest(text=text): + with self.assertRaises(manifest.ManifestError) as caught: + manifest.loads(text) + self.assertIn(expected, str(caught.exception)) + + def test_two_team_roles_may_not_share_a_name(self) -> None: + with self.assertRaises(manifest.ManifestError) as caught: + manifest.loads("[[team]]\nrole = 'a'\n\n[[team]]\nrole = 'a'\n") + self.assertIn("share one role name", str(caught.exception)) + + def test_control_characters_are_refused_in_text(self) -> None: + # A startup command is a line this host would run. An escape sequence + # can repaint the terminal that is asking whether to approve it, and a + # newline turns one reviewed command into two. + for text in ( + 'startup = "clear\\u001b[2J"\n', + 'startup = "one\\ntwo"\n', + 'startup = "one\\ttwo"\n', + 'description = "a\\u0007b"\n', + ): + with self.subTest(text=text): + with self.assertRaises(manifest.ManifestError) as caught: + manifest.loads(text) + self.assertIn("control characters", str(caught.exception)) + + def test_an_empty_manifest_is_valid_and_asks_for_nothing(self) -> None: + values = manifest.loads("") + self.assertIsNone(values["name"]) + self.assertIsNone(values["provider"]) + self.assertEqual(values["team"], []) + + def test_a_blank_string_reads_as_unset_rather_than_empty(self) -> None: + self.assertIsNone(manifest.loads('model = " "\n')["model"]) + + +class ReadTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="session-kit-manifest.") + self.root = Path(self.temporary.name) + self.addCleanup(self.temporary.cleanup) + + def write(self, text: str, name: str = manifest.MANIFEST_NAME) -> Path: + path = self.root / name + path.write_text(text, encoding="utf-8") + return path + + def test_a_file_reads_to_validated_values(self) -> None: + values = manifest.read(self.write(FULL)) + self.assertEqual(values["name"], "demo-api") + self.assertEqual(len(values["team"]), 2) + + def test_a_symlinked_manifest_is_refused(self) -> None: + real = self.write('name = "real"\n', name="real.toml") + link = self.root / manifest.MANIFEST_NAME + link.symlink_to(real) + with self.assertRaises(manifest.ManifestError) as caught: + manifest.read(link) + self.assertIn("symlink", str(caught.exception)) + + def test_an_oversized_manifest_is_refused_before_it_is_read(self) -> None: + path = self.write("# " + "x" * (manifest.MAX_MANIFEST_BYTES + 1)) + with self.assertRaises(manifest.ManifestError) as caught: + manifest.read(path) + self.assertIn("larger than", str(caught.exception)) + + def test_a_manifest_with_too_many_lines_is_refused(self) -> None: + path = self.write("\n" * (manifest.MAX_MANIFEST_LINES + 1)) + with self.assertRaises(manifest.ManifestError) as caught: + manifest.read(path) + self.assertIn("longer than", str(caught.exception)) + + def test_more_team_roles_than_the_bound_are_refused(self) -> None: + text = "".join( + f"[[team]]\nrole = 'r{index}'\n\n" + for index in range(manifest.MAX_TEAM_ROLES + 1) + ) + with self.assertRaises(manifest.ManifestError) as caught: + manifest.loads(text) + self.assertIn("more than", str(caught.exception)) + + def test_invalid_utf8_and_null_bytes_are_refused(self) -> None: + path = self.root / manifest.MANIFEST_NAME + path.write_bytes(b'name = "\xff\xfe"\n') + with self.assertRaises(manifest.ManifestError) as caught: + manifest.read(path) + self.assertIn("valid UTF-8", str(caught.exception)) + path.write_text('name = "a\x00b"\n', encoding="utf-8") + with self.assertRaises(manifest.ManifestError) as caught: + manifest.read(path) + self.assertIn("null byte", str(caught.exception)) + + def test_a_missing_manifest_reports_the_path(self) -> None: + with self.assertRaises(manifest.ManifestError) as caught: + manifest.read(self.root / "absent.toml") + self.assertIn("absent.toml", str(caught.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_projects_one_entry.py b/tests/test_projects_one_entry.py new file mode 100644 index 0000000..e630d6e --- /dev/null +++ b/tests/test_projects_one_entry.py @@ -0,0 +1,195 @@ +"""One directory is one project, and no project is locked to a provider. + +The shortcut table used to answer two questions with one row: *where* a +project is, and *what opens it*. The second answer was binding, so a directory +worked on with Claude and with Codex needed two rows under two short names, +and adding a project asked a third question whose answer was already asked +again at every launch. + +These tests hold the ruling: adding takes a directory (and a short name if you +want your own), a directory already listed is refused by name rather than +duplicated, the provider column is a default that a launch overrides, and a +project that keeps no default is never opened by guess. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +from lib.sessionkit_inventory import projects +from lib.sessionkit_projects import identity, launch + +TOOL = Path(__file__).resolve().parents[1] / "lib" / "sessionkit_inventory" / "projects.py" + + +class OneEntryPerDirectoryTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="session-kit-one-entry.") + self.root = Path(self.temporary.name) + self.state = self.root / "state" + self.state.mkdir() + self.file = self.root / "projects.tsv" + self.work = self.root / "work" + self.work.mkdir() + self.other = self.root / "other" + self.other.mkdir() + + def tearDown(self) -> None: + self.temporary.cleanup() + + def rows(self) -> list[list[str]]: + if not self.file.exists(): + return [] + return [ + line.split("\t") + for line in self.file.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.startswith("#") + ] + + def tool(self, *words: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + os.fspath(TOOL), + "--projects-file", + os.fspath(self.file), + "--state-dir", + os.fspath(self.state), + *words, + ], + capture_output=True, + text=True, + check=False, + ) + + # ---- adding ----------------------------------------------------------- + + def test_a_project_is_added_without_naming_a_provider(self) -> None: + projects.add_project(self.file, self.state, "work", cwd=os.fspath(self.work)) + self.assertEqual([["work", "any", os.fspath(self.work)]], self.rows()) + + def test_the_command_line_takes_a_short_name_and_a_directory(self) -> None: + completed = self.tool("add", "work", os.fspath(self.work)) + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertEqual([["work", "any", os.fspath(self.work)]], self.rows()) + self.assertIn("chosen when you start a session", completed.stdout) + + def test_a_directory_alone_is_enough_to_add_it(self) -> None: + completed = self.tool("add", os.fspath(self.work)) + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertEqual([["work", "any", os.fspath(self.work)]], self.rows()) + + def test_the_old_three_word_form_still_records_its_provider(self) -> None: + completed = self.tool("add", "work", "claude", os.fspath(self.work)) + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertEqual([["work", "claude", os.fspath(self.work)]], self.rows()) + + def test_a_second_entry_for_one_directory_is_refused_by_name(self) -> None: + projects.add_project(self.file, self.state, "work", cwd=os.fspath(self.work)) + with self.assertRaises(projects.ProjectError) as refusal: + projects.add_project( + self.file, self.state, "work-codex", "codex", os.fspath(self.work) + ) + self.assertIn("already on the list as work", str(refusal.exception)) + self.assertEqual([["work", "any", os.fspath(self.work)]], self.rows()) + + # ---- the default ------------------------------------------------------ + + def test_a_default_provider_is_set_and_cleared_without_re_adding(self) -> None: + projects.add_project(self.file, self.state, "work", cwd=os.fspath(self.work)) + projects.set_default_provider(self.file, self.state, "work", "codex") + self.assertEqual([["work", "codex", os.fspath(self.work)]], self.rows()) + projects.set_default_provider(self.file, self.state, "work", "any") + self.assertEqual([["work", "any", os.fspath(self.work)]], self.rows()) + + def test_a_default_never_prevents_the_other_provider(self) -> None: + projects.add_project( + self.file, self.state, "work", "claude", os.fspath(self.work) + ) + resolver = identity.Resolver(self.file) + project = resolver.resolve_alias("work") + self.assertIsNotNone(project) + plan = launch.launch_plan(project, requested_provider="codex") + self.assertEqual("codex", plan["provider"]) + self.assertEqual("flag", plan["decisions"]["provider"]) + + def test_a_project_with_no_default_is_never_opened_by_guess(self) -> None: + projects.add_project(self.file, self.state, "work", cwd=os.fspath(self.work)) + resolver = identity.Resolver(self.file) + project = resolver.resolve_alias("work") + self.assertIsNotNone(project, "an `any` row must still resolve by alias") + plan = launch.launch_plan(project) + self.assertIsNone(plan["provider"]) + self.assertEqual("unset", plan["decisions"]["provider"]) + self.assertTrue( + any("no default provider" in note for note in plan["notes"]), + plan["notes"], + ) + + # ---- the directories already listed twice ----------------------------- + + def write(self, text: str) -> None: + self.file.write_text(text, encoding="utf-8") + self.file.chmod(0o600) + + def test_a_directory_listed_twice_reads_as_one_project_with_no_default( + self, + ) -> None: + self.write( + f"work\tclaude\t{self.work}\n" + f"work-codex\tcodex\t{self.work}\n" + f"other\tshell\t{self.other}\n" + ) + candidates = projects.project_candidates(self.file) + listed = [(row["alias"], row["provider"]) for row in candidates["configured"]] + self.assertEqual([("work", "any"), ("other", "shell")], listed) + self.assertEqual( + [("work-codex", "work")], + [(row["alias"], row["kept_alias"]) for row in candidates["duplicates"]], + ) + + def test_normalize_folds_the_duplicate_and_keeps_every_other_line(self) -> None: + self.write( + "# hand written\n" + f"work\tclaude\t{self.work}\n" + f"work-codex\tcodex\t{self.work}\n" + f"other\tshell\t{self.other}\n" + ) + completed = self.tool("normalize") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("kept work and retired work-codex", completed.stdout) + self.assertIn("# hand written", self.file.read_text(encoding="utf-8")) + self.assertEqual( + sorted([["other", "shell", os.fspath(self.other)], + ["work", "any", os.fspath(self.work)]]), + sorted(self.rows()), + ) + + def test_normalizing_a_file_with_no_duplicate_changes_nothing(self) -> None: + self.write(f"work\tclaude\t{self.work}\nother\tshell\t{self.other}\n") + before = self.file.read_bytes() + completed = self.tool("normalize") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn("exactly one entry", completed.stdout) + self.assertEqual(before, self.file.read_bytes()) + + def test_an_ignore_row_is_a_different_class_and_survives_untouched(self) -> None: + self.write( + f"work\tclaude\t{self.work}\n" + f"work-codex\tcodex\t{self.work}\n" + f"hidden\tignore\t{self.other}\n" + ) + completed = self.tool("normalize") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertIn(["hidden", "ignore", os.fspath(self.other)], self.rows()) + candidates = projects.project_candidates(self.file) + self.assertEqual([os.fspath(self.other)], candidates["ignored"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_projects_startup_wiring.py b/tests/test_projects_startup_wiring.py new file mode 100644 index 0000000..f7c13b9 --- /dev/null +++ b/tests/test_projects_startup_wiring.py @@ -0,0 +1,209 @@ +"""A startup command a person can review and approve. + +`sp new` reads a project's launch plan and refuses to run a startup command +nobody approved. The verbs that review and approve one existed, in +lib/sessionkit_projects/cli.py, but `session-kit projects` dispatched only to +the project-list tool, so no command a person could type reached them. An +unapproved command could therefore never become approved, and the notice `sp +new` printed pointed at nothing. + +Both halves are tested here: the verbs reach the manifest tool, and the notice +names the commands that do the work. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +from tests.support import REPO, run + +SESSION_KIT = REPO / "bin" / "session-kit" +SP = REPO / "bin" / "sp" + +MANIFEST = """ +name = "demo-api" +provider = "codex" +startup = "sp list" +""" + + +class ProjectsDispatchTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix=".projects-wiring-", dir=REPO) + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + self.install = self.root / "lib" / "session-kit" + self.install.mkdir(parents=True) + # An installed release is a link to a tree; this checkout is that tree. + (self.install / "current").symlink_to(REPO) + self.config = self.root / "config" + self.config.mkdir() + self.state = self.root / "state" + self.state.mkdir(mode=0o700) + self.repo = self.root / "repo" + self.repo.mkdir() + (self.repo / "session-kit.toml").write_text(MANIFEST, encoding="utf-8") + (self.config / "projects.tsv").write_text( + f"demo\tcodex\t{self.repo}\n", encoding="utf-8" + ) + + def session_kit(self, *argv: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [os.fspath(SESSION_KIT), *argv], + env={ + **os.environ, + "HOME": os.fspath(self.root), + "SESSION_KIT_ROOT": os.fspath(self.install), + "SESSION_KIT_CONFIG_ROOT": os.fspath(self.config), + "SESSION_KIT_STATE_DIR": os.fspath(self.state), + }, + capture_output=True, + text=True, + check=False, + ) + + def test_launch_plan_is_reachable_and_says_what_would_start(self) -> None: + shown = self.session_kit("projects", "launch-plan", "demo") + self.assertEqual(0, shown.returncode, shown.stderr) + plan = json.loads(shown.stdout) + self.assertEqual(os.fspath(self.repo), plan["root"]) + self.assertEqual("codex", plan["provider"]) + self.assertEqual("unapproved", plan["startup_state"]) + + def test_approving_a_startup_command_makes_the_plan_apply_it(self) -> None: + approved = self.session_kit("projects", "approve-startup", "demo") + self.assertEqual(0, approved.returncode, approved.stderr) + self.assertTrue(json.loads(approved.stdout)["approved"]) + plan = json.loads(self.session_kit("projects", "launch-plan", "demo").stdout) + self.assertTrue(plan["startup_applied"]) + self.assertEqual("approved", plan["startup_state"]) + + def test_the_project_list_verbs_still_reach_the_list_tool(self) -> None: + listed = self.session_kit("projects", "list") + self.assertEqual(0, listed.returncode, listed.stderr) + self.assertIn("demo", listed.stdout) + + def test_a_manifest_can_be_checked_before_it_is_trusted(self) -> None: + checked = self.session_kit("projects", "check", os.fspath(self.repo)) + self.assertEqual(0, checked.returncode, checked.stderr) + + def test_the_help_names_the_verbs_that_exist(self) -> None: + helped = self.session_kit("help") + self.assertIn("session-kit projects launch-plan", helped.stdout) + self.assertIn("session-kit projects approve-startup", helped.stdout) + + +MODEL_ONLY_MANIFEST = """ +name = "demo-api" +provider = "codex" +model = "gpt-5-1-codex" +""" + + +class ProjectPlanFieldsTests(unittest.TestCase): + """A project that names a model but no account still launches. + + The plan arrives as one tab-separated line, and a tab is IFS whitespace: a + run of them reads as ONE separator, so the empty account field vanished and + every field after it shifted left. The model landed in the account + variable and `sp new <project>` refused itself with "--account is available + only for Claude or Codex with an enrolled alias", a flag nobody typed. + """ + + def setUp(self) -> None: + from tests.test_commands import CommandFixture + + self.fixture = CommandFixture() + self.repo = self.fixture.base / "model-only-project" + self.repo.mkdir() + (self.repo / "session-kit.toml").write_text( + MODEL_ONLY_MANIFEST, encoding="utf-8" + ) + self.fixture.projects.write_text( + f"demo\tcodex\t{self.repo}\n", encoding="utf-8" + ) + + def tearDown(self) -> None: + self.fixture.close() + + def env(self) -> dict[str, str]: + return { + **self.fixture.env(), + "SESSION_KIT_PROJECTS_CORE": os.fspath( + REPO / "lib" / "sessionkit_projects" / "cli.py" + ), + "STUB_DYNAMIC_PROVIDER": "codex", + "STUB_DYNAMIC_CWD": os.fspath(self.repo), + "STUB_DYNAMIC_UUID": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + "SESSION_KIT_BACKGROUND": "1", + } + + def test_a_model_without_an_account_is_not_read_as_an_account(self) -> None: + started = run([SP, "new", "codex", "demo"], env=self.env(), check=False) + self.assertEqual(0, started.returncode, started.stdout + started.stderr) + self.assertNotIn("--account", started.stderr) + self.assertNotIn("enrolled alias", started.stderr) + + def test_the_model_the_project_names_reaches_the_launch_record(self) -> None: + run([SP, "new", "codex", "demo"], env=self.env(), check=False) + launches = sorted(self.fixture.start.glob("*.launch")) + self.assertEqual(1, len(launches), launches) + fields = launches[0].read_text(encoding="utf-8").rstrip("\n").split("\t") + self.assertEqual("codex", fields[0]) + self.assertEqual(os.fspath(self.repo), fields[1]) + self.assertEqual("gpt-5-1-codex", fields[2]) + # No account was named, so no account record was written. + self.assertEqual([], sorted(self.fixture.start.glob("*.account"))) + + +class StartupNoticeTests(unittest.TestCase): + """The notice `sp new` prints names commands that work.""" + + def setUp(self) -> None: + from tests.test_commands import CommandFixture + + self.fixture = CommandFixture() + self.repo = self.fixture.base / "manifest-project" + self.repo.mkdir() + (self.repo / "session-kit.toml").write_text(MANIFEST, encoding="utf-8") + self.fixture.projects.write_text( + f"demo\tcodex\t{self.repo}\n", encoding="utf-8" + ) + + def tearDown(self) -> None: + self.fixture.close() + + def test_an_unapproved_startup_command_points_at_the_real_verbs(self) -> None: + env = { + **self.fixture.env(), + "SESSION_KIT_PROJECTS_CORE": os.fspath( + REPO / "lib" / "sessionkit_projects" / "cli.py" + ), + "STUB_DYNAMIC_PROVIDER": "codex", + "STUB_DYNAMIC_CWD": os.fspath(self.repo), + "STUB_DYNAMIC_UUID": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + "SESSION_KIT_BACKGROUND": "1", + } + started = run([SP, "new", "codex", "demo"], env=env, check=False) + self.assertIn( + "This project's startup command is not approved here, so it was not run.", + started.stdout, + ) + self.assertIn( + "Review it with: session-kit projects launch-plan demo", started.stdout + ) + self.assertIn( + "Approve it with: session-kit projects approve-startup demo", + started.stdout, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_provider_title_push.py b/tests/test_provider_title_push.py index 0b11f8c..f5e4833 100644 --- a/tests/test_provider_title_push.py +++ b/tests/test_provider_title_push.py @@ -11,6 +11,7 @@ from pathlib import Path import sys import tempfile +import time import unittest from unittest import mock @@ -104,6 +105,166 @@ def test_claude_push_writes_nameintent_and_exact_record_only(self) -> None: self.assertEqual( unrelated_payload, unrelated.read_text(encoding="utf-8") ) + config = json.loads( + (home / ".config/session-kit/inventory.json").read_text( + encoding="utf-8" + ) + ) + self.assertNotIn( + f"claude:{exact}", config.get("pending_native_titles", {}) + ) + + def test_claude_push_updates_account_record_and_clears_derived_source(self) -> None: + with tempfile.TemporaryDirectory(prefix=".title-push-", dir=REPO) as raw: + home = self._home(Path(raw)) + exact = uuid_for(13) + account = ( + home + / ".local/share/session-kit/accounts/claude/duck/sessions" + ) + account.mkdir(parents=True, mode=0o700) + record = account / "789.json" + record.write_text( + json.dumps( + { + "pid": 789, + "sessionId": exact, + "name": "v2-5e", + "nameSource": "derived", + "nameSince": 100, + } + ), + encoding="utf-8", + ) + + result = self._push("claude", exact, "Session Kit Closeout", home) + + self.assertEqual([], result["provider_title_warnings"]) + self.assertIn("claude-session-record", result["provider_title_pushes"]) + updated = json.loads(record.read_text(encoding="utf-8")) + self.assertEqual("Session Kit Closeout", updated["name"]) + self.assertNotIn("nameSource", updated) + self.assertEqual( + "Session Kit Closeout\n", + (account / f"{exact}.nameintent").read_text(encoding="utf-8"), + ) + config = json.loads( + (home / ".config/session-kit/inventory.json").read_text( + encoding="utf-8" + ) + ) + self.assertEqual( + { + "title": "v2-5e", + "nameSince": 100, + "nameSource": "derived", + }, + config["pending_native_titles"][f"claude:{exact}"], + ) + + def test_claude_record_without_name_since_gets_no_pending_tier(self) -> None: + with tempfile.TemporaryDirectory(prefix=".title-push-", dir=REPO) as raw: + home = self._home(Path(raw)) + exact = uuid_for(16) + record = home / ".claude" / "sessions" / "791.json" + record.write_text( + json.dumps( + {"pid": 791, "sessionId": exact, "name": "Legacy Name"} + ), + encoding="utf-8", + ) + + result = self._push("claude", exact, "New Kit Name", home) + + self.assertEqual([], result["provider_title_warnings"]) + config = json.loads( + (home / ".config/session-kit/inventory.json").read_text( + encoding="utf-8" + ) + ) + self.assertNotIn("pending_native_titles", config) + + def test_claude_push_regression_guard_refuses_symlinked_intent_and_record(self) -> None: + with tempfile.TemporaryDirectory(prefix=".title-push-", dir=REPO) as raw: + home = self._home(Path(raw)) + sessions = home / ".claude" / "sessions" + exact = uuid_for(14) + external_intent = Path(raw) / "external-intent" + external_intent.write_text("keep\n", encoding="utf-8") + (sessions / f"{exact}.nameintent").symlink_to(external_intent) + external_record = Path(raw) / "external-record.json" + external_payload = json.dumps( + {"pid": 790, "sessionId": exact, "name": "Keep"} + ) + external_record.write_text(external_payload, encoding="utf-8") + (sessions / "790.json").symlink_to(external_record) + + result = self._push("claude", exact, "Must Not Land", home) + + self.assertIn("refusing symlinked name intent", result["stderr"]) + self.assertNotIn("claude-session-record", result["provider_title_pushes"]) + self.assertEqual("keep\n", external_intent.read_text(encoding="utf-8")) + self.assertEqual( + external_payload, external_record.read_text(encoding="utf-8") + ) + + def test_claude_push_refuses_symlinked_account_but_pushes_healthy_roots(self) -> None: + with tempfile.TemporaryDirectory(prefix=".title-push-", dir=REPO) as raw: + base = Path(raw) + home = self._home(base) + exact = uuid_for(15) + ambient = home / ".claude" / "sessions" + healthy = ( + home + / ".local/share/session-kit/accounts/claude/healthy/sessions" + ) + healthy.mkdir(parents=True, mode=0o700) + external = base / "external-profile" / "sessions" + external.mkdir(parents=True, mode=0o700) + linked = home / ".local/share/session-kit/accounts/claude/linked" + linked.parent.mkdir(parents=True, exist_ok=True) + linked.symlink_to(external.parent, target_is_directory=True) + + records = [] + for sessions, pid, title in ( + (ambient, 801, "Ambient Old"), + (healthy, 802, "Healthy Old"), + (external, 803, "External Keep"), + ): + record = sessions / f"{pid}.json" + record.write_text( + json.dumps( + { + "pid": pid, + "sessionId": exact, + "name": title, + "nameSince": 100, + } + ), + encoding="utf-8", + ) + records.append(record) + + result = self._push("claude", exact, "Bounded Push", home) + + self.assertIn(str(linked / "sessions"), result["stderr"]) + self.assertIn("account profile is a symlink", result["stderr"]) + self.assertEqual( + "Bounded Push", json.loads(records[0].read_text())["name"] + ) + self.assertEqual( + "Bounded Push", json.loads(records[1].read_text())["name"] + ) + self.assertEqual( + "External Keep", json.loads(records[2].read_text())["name"] + ) + self.assertEqual( + "Bounded Push\n", (ambient / f"{exact}.nameintent").read_text() + ) + self.assertEqual( + "Bounded Push\n", (healthy / f"{exact}.nameintent").read_text() + ) + self.assertFalse((external / f"{exact}.nameintent").exists()) def test_codex_push_appends_and_preserves_existing_index(self) -> None: with tempfile.TemporaryDirectory(prefix=".title-push-", dir=REPO) as raw: @@ -199,6 +360,8 @@ def test_alias_set_propagates_and_delete_leaves_provider_alone(self) -> None: environment = { "SESSION_KIT_CONFIG": os.fspath(config_file), "HOME": os.fspath(home), + "CODEX_HOME": os.fspath(home / ".codex"), + "SESSION_KIT_CODEX_HOME": os.fspath(home / ".codex"), } with mock.patch.dict(os.environ, environment, clear=False): stdout = io.StringIO() @@ -337,6 +500,218 @@ def test_reader_returns_last_bounded_ai_title_for_exact_session(self) -> None: "", inventory_core.read_claude_ai_title("not-a-uuid", home) ) + def test_a_title_in_an_account_profile_is_found(self) -> None: + """The read side has to search the profile the session ran on. + + A session launched on an enrolled account runs with CLAUDE_CONFIG_DIR + set, so its transcript is written under that profile and never under + the default root. Reading only the default root returned "no title", + every caller read that as "the provider never set one", and the name + the kit could already see was never pushed anywhere the window shows. + """ + with tempfile.TemporaryDirectory() as raw: + home = Path(raw) + profile = home / ".local/share/session-kit/accounts/claude/primary" + project = profile / "projects" / "-srv-app" + project.mkdir(parents=True, mode=0o700) + # The default root exists and holds nothing: the live shape. + (home / ".claude" / "projects").mkdir(parents=True, mode=0o700) + exact = uuid_for(91) + (project / f"{exact}.jsonl").write_text( + json.dumps( + { + "type": "ai-title", + "aiTitle": "Count the great lakes", + "sessionId": exact, + } + ) + + "\n" + + json.dumps( + {"type": "agent-color", "agentColor": "blue", "sessionId": exact} + ) + + "\n", + encoding="utf-8", + ) + + signals = inventory_core.read_claude_transcript_signals(exact, home) + + self.assertEqual("Count the great lakes", signals["ai_title"]) + self.assertEqual("blue", signals["agent_color"]) + + def test_transcript_signals_return_last_color_and_reject_junk(self) -> None: + with tempfile.TemporaryDirectory() as raw: + home = Path(raw) + project = home / ".claude" / "projects" / "-srv-app" + project.mkdir(parents=True, mode=0o700) + exact = uuid_for(76) + transcript = project / f"{exact}.jsonl" + lines = [ + json.dumps( + { + "type": "agent-name", + "agentName": "Leap year audit", + "sessionId": exact, + } + ), + json.dumps( + {"type": "agent-color", "agentColor": "pink", "sessionId": exact} + ), + json.dumps( + { + "type": "agent-color", + "agentColor": "not-a-color", + "sessionId": exact, + } + ), + json.dumps( + { + "type": "ai-title", + "aiTitle": "Count leap years", + "sessionId": exact, + } + ), + json.dumps( + {"type": "agent-color", "agentColor": "green", "sessionId": exact} + ), + ] + transcript.write_text("\n".join(lines) + "\n", encoding="utf-8") + signals = inventory_core.read_claude_transcript_signals(exact, home) + # Last valid record of each kind wins; junk colors never surface. + self.assertEqual( + { + "ai_title": "Count leap years", + "agent_name": "Leap year audit", + "agent_color": "green", + "pending_ask_user_question": False, + "pending_tool_use": False, + "pending_tool_use_at_unix_ms": None, + }, + signals, + ) + + def test_pending_hydration_fills_only_absent_native_records(self) -> None: + with tempfile.TemporaryDirectory() as raw: + home = Path(raw) + exact = uuid_for(77) + project = home / ".claude" / "projects" / "-srv-app" + sessions = home / ".claude" / "sessions" + project.mkdir(parents=True, mode=0o700) + sessions.mkdir(parents=True, mode=0o700) + transcript = project / f"{exact}.jsonl" + transcript.write_text( + json.dumps( + { + "type": "ai-title", + "aiTitle": "Visible Claude metadata", + "sessionId": exact, + } + ) + + "\n", + encoding="utf-8", + ) + live = { + "source": "live", + "stale": False, + "sessions": [ + { + "provider": "claude", + "identity": {"uuid": exact}, + "native_title": "Visible Claude metadata", + "provider_title_state": "pending", + } + ], + } + with ( + mock.patch.object(inventory_core, "snapshot", return_value=live), + mock.patch.object( + inventory_core, "guard_live_inventory", return_value=True + ), + mock.patch.object(inventory_core, "canonical_colors", return_value={}), + ): + result = inventory_core.claude_pending_native_hydrations( + {"state_dir": str(home / "state")}, + environ={"HOME": os.fspath(home)}, + ) + self.assertEqual(exact, result[0]["uuid"]) + signals = inventory_core.read_claude_transcript_signals(exact, home) + self.assertEqual("Visible Claude metadata", signals["agent_name"]) + self.assertIn(signals["agent_color"], inventory_core.SESSION_COLORS) + + # A second pass is self-terminating and preserves both records. + with ( + mock.patch.object(inventory_core, "snapshot", return_value=live), + mock.patch.object( + inventory_core, "guard_live_inventory", return_value=True + ), + mock.patch.object(inventory_core, "canonical_colors", return_value={}), + ): + repeated = inventory_core.claude_pending_native_hydrations( + {"state_dir": str(home / "state")}, + environ={"HOME": os.fspath(home)}, + ) + self.assertEqual([], repeated) + + def test_pending_hydration_never_replaces_native_explicit_name(self) -> None: + with tempfile.TemporaryDirectory() as raw: + home = Path(raw) + exact = uuid_for(78) + project = home / ".claude" / "projects" / "-srv-app" + (home / ".claude" / "sessions").mkdir(parents=True, mode=0o700) + project.mkdir(parents=True, mode=0o700) + transcript = project / f"{exact}.jsonl" + transcript.write_text( + "\n".join( + ( + json.dumps( + { + "type": "ai-title", + "aiTitle": "Generated title", + "sessionId": exact, + } + ), + json.dumps( + { + "type": "agent-color", + "agentColor": "pink", + "sessionId": exact, + } + ), + ) + ) + + "\n", + encoding="utf-8", + ) + live = { + "source": "live", + "stale": False, + "sessions": [ + { + "provider": "claude", + "identity": {"uuid": exact}, + "native_title": "The operator's explicit name", + "provider_title_state": "ready", + } + ], + } + with ( + mock.patch.object(inventory_core, "snapshot", return_value=live), + mock.patch.object( + inventory_core, "guard_live_inventory", return_value=True + ), + mock.patch.object(inventory_core, "canonical_colors", return_value={}), + ): + result = inventory_core.claude_pending_native_hydrations( + {"state_dir": str(home / "state")}, + environ={"HOME": os.fspath(home)}, + ) + self.assertEqual([], result) + self.assertEqual( + "", + inventory_core.read_claude_transcript_signals(exact, home)[ + "agent_name" + ], + ) + def test_parser_passes_title_evidence_and_derived_names_yield(self) -> None: exact = uuid_for(74) parsed = inventory_core._parse_claude_payload( @@ -349,6 +724,7 @@ def test_parser_passes_title_evidence_and_derived_names_yield(self) -> None: "name": "v2-b3", "nameSource": "derived", "aiTitle": "Find leap years this year", + "agentName": "Find leap years this year", "status": "idle", } ] @@ -358,6 +734,9 @@ def test_parser_passes_title_evidence_and_derived_names_yield(self) -> None: "Find leap years this year", parsed[0]["ai_title"] ) self.assertEqual("derived", parsed[0]["name_source"]) + self.assertEqual( + "Find leap years this year", parsed[0]["agent_name"] + ) class DerivePromptTitleTests(unittest.TestCase): @@ -383,6 +762,16 @@ def test_rejects_slash_commands_and_short_prompts(self) -> None: self.assertIsNone(inventory_core.derive_prompt_title("")) self.assertIsNone(inventory_core.derive_prompt_title(None)) + def test_rejects_session_kit_machine_transport_prompts(self) -> None: + for prompt in ( + '<cross-session-message from="uds:/run/user/1000/example.sock">work</cross-session-message>', + "[session-kit operator message abc123] work", + "Session Kit initialized this managed worker. Wait for assignment.", + "You are a Session Kit delivery runner. Deliver one message.", + "RUNTIME FOR THIS WAKE: You are a fixed Session Kit delivery bot.", + ): + self.assertIsNone(inventory_core.derive_prompt_title(prompt)) + def test_caps_length_at_64(self) -> None: title = inventory_core.derive_prompt_title( "investigate " + "extraordinarily " * 8 + "long prompt" @@ -392,76 +781,379 @@ def test_caps_length_at_64(self) -> None: class AutoTitleFromHookTests(unittest.TestCase): - UUID = "00000000-0000-4000-8000-000000000077" + def test_codex_title_push_uses_one_override_precedence_for_all_writes(self) -> None: + import sqlite3 - def _prebaked_home(self, base: Path) -> tuple[Path, Path]: - home = base / "home" - (home / ".claude" / "sessions").mkdir(parents=True, mode=0o700) - project = home / ".claude" / "projects" / "-srv-project" - project.mkdir(parents=True, mode=0o700) - transcript = project / f"{self.UUID}.jsonl" - records = [ - {"type": "agent-color", "agentColor": "green", "sessionId": self.UUID}, - { - "type": "user", - "isMeta": True, - "message": { - "role": "user", - "content": [ - { - "type": "text", - "text": "Continue from where you left off.", - } - ], - }, - }, - { - "type": "assistant", - "message": { - "role": "assistant", - "content": [ - {"type": "text", "text": "No response requested."} - ], + uuid = "00000000-0000-4000-8000-000000000033" + with tempfile.TemporaryDirectory() as base: + home = Path(base) / "home" + provider_home = Path(base) / "provider" + kit_home = Path(base) / "kit" + home.mkdir() + for root in (provider_home, kit_home): + root.mkdir() + connection = sqlite3.connect(root / "state_5.sqlite") + connection.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT)") + connection.execute("INSERT INTO threads VALUES (?, NULL)", (uuid,)) + connection.commit() + connection.close() + result = inventory_core.propagate_provider_title( + "codex", + uuid, + "Override Title", + environ={ + "HOME": str(home), + "CODEX_HOME": str(provider_home), + "SESSION_KIT_CODEX_HOME": str(kit_home), }, - }, - ] - transcript.write_text( - "".join(json.dumps(record) + "\n" for record in records), - encoding="utf-8", - ) - return home, transcript + ) + self.assertIn("codex-thread-title", result["provider_title_pushes"]) + for root, expected in ((kit_home, "Override Title"), (provider_home, None)): + connection = sqlite3.connect(root / "state_5.sqlite") + actual = connection.execute( + "SELECT title FROM threads WHERE id = ?", (uuid,) + ).fetchone()[0] + connection.close() + self.assertEqual(expected, actual) - def _payload(self, transcript: Path, prompt: str) -> str: - return json.dumps( - { - "hook_event_name": "UserPromptSubmit", - "session_id": self.UUID, - "transcript_path": str(transcript), - "prompt": prompt, - } - ) + def test_codex_title_push_live_renames_app_server_threads(self) -> None: + import base64 + import hashlib + import socket as socketmod + import sqlite3 + import struct + import threading - def test_titles_prebaked_conversation_once(self) -> None: + uuid = "00000000-0000-4000-8000-000000000044" with tempfile.TemporaryDirectory() as base: - home, transcript = self._prebaked_home(Path(base)) - env = {"HOME": str(home)} - result = inventory_core.auto_title_from_hook( - self._payload( - transcript, - "leap years are there in 2026 and what are the dates?", - ), - environ=env, + home = Path(base) / "home" + codex = Path(base) / "codex" + home.mkdir() + codex.mkdir() + connection = sqlite3.connect(codex / "state_5.sqlite") + connection.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT)" ) - self.assertEqual("Leap years are there in 2026", result["title"]) - self.assertIn("claude-transcript-ai-title", result["pushes"]) - self.assertIn("claude-transcript-name", result["pushes"]) - self.assertIn("claude-nameintent", result["pushes"]) - records = [ - json.loads(line) - for line in transcript.read_text().splitlines() - ] - self.assertIn("ai-title", [r.get("type") for r in records]) - self.assertIn("agent-name", [r.get("type") for r in records]) + connection.execute("INSERT INTO threads VALUES (?, NULL)", (uuid,)) + connection.commit() + connection.close() + app_dir = ( + home / ".local" / "state" / "session-kit" / "app-server" / "s1" + ) + app_dir.mkdir(parents=True) + socket_path = app_dir / "app.sock" + server = socketmod.socket(socketmod.AF_UNIX, socketmod.SOCK_STREAM) + server.bind(os.fspath(socket_path)) + server.listen(1) + server.settimeout(5) + seen: list[dict] = [] + + def recv_client_frame(link: socketmod.socket) -> dict: + def read_exact(count: int) -> bytes: + data = b"" + while len(data) < count: + chunk = link.recv(count - len(data)) + if not chunk: + raise OSError("client closed") + data += chunk + return data + + while True: + first, second = read_exact(2) + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", read_exact(2))[0] + mask = read_exact(4) if second & 0x80 else b"" + payload = read_exact(length) + if mask: + payload = bytes( + value ^ mask[index % 4] + for index, value in enumerate(payload) + ) + if first & 0x0F != 1: + continue + return json.loads(payload) + + def serve() -> None: + link, _ = server.accept() + link.settimeout(5) + request = b"" + while not request.endswith(b"\r\n\r\n"): + request += link.recv(1) + key = next( + line.split(b":", 1)[1].strip() + for line in request.split(b"\r\n") + if line.lower().startswith(b"sec-websocket-key:") + ) + accept = base64.b64encode( + hashlib.sha1( + key + b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + ).digest() + ) + link.sendall( + b"HTTP/1.1 101 Switching Protocols\r\n" + b"Upgrade: websocket\r\nConnection: Upgrade\r\n" + b"Sec-WebSocket-Accept: " + accept + b"\r\n\r\n" + ) + while True: + message = recv_client_frame(link) + seen.append(message) + if message.get("id") is not None: + reply = json.dumps( + {"id": message["id"], "result": {}}, + separators=(",", ":"), + ).encode() + link.sendall( + bytes([0x81, len(reply)]) + reply + ) + if message.get("method") == "thread/name/set": + break + link.close() + + worker = threading.Thread(target=serve, daemon=True) + worker.start() + try: + result = inventory_core.propagate_provider_title( + "codex", + uuid, + "Live Rename", + environ={ + "HOME": str(home), + "SESSION_KIT_CODEX_HOME": str(codex), + }, + ) + finally: + worker.join(timeout=5) + server.close() + self.assertIn( + "codex-live-rename", result["provider_title_pushes"] + ) + renamed = next( + message + for message in seen + if message.get("method") == "thread/name/set" + ) + self.assertEqual( + {"threadId": uuid, "name": "Live Rename"}, + renamed.get("params"), + ) + + def test_codex_live_rename_kill_switch_and_absent_sockets_fail_open(self) -> None: + import sqlite3 + + uuid = "00000000-0000-4000-8000-000000000045" + with tempfile.TemporaryDirectory() as base: + home = Path(base) / "home" + codex = Path(base) / "codex" + home.mkdir() + codex.mkdir() + connection = sqlite3.connect(codex / "state_5.sqlite") + connection.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT)" + ) + connection.execute("INSERT INTO threads VALUES (?, NULL)", (uuid,)) + connection.commit() + connection.close() + for extra in ( + {}, + {"SESSION_KIT_CODEX_LIVE_RENAME": "0"}, + ): + result = inventory_core.propagate_provider_title( + "codex", + uuid, + "Quiet Title", + environ={ + "HOME": str(home), + "SESSION_KIT_CODEX_HOME": str(codex), + **extra, + }, + ) + self.assertIn( + "codex-thread-title", result["provider_title_pushes"] + ) + self.assertNotIn( + "codex-live-rename", result["provider_title_pushes"] + ) + self.assertEqual([], result["provider_title_warnings"]) + + def test_app_server_logs_are_private_precreated_and_symlink_refused(self) -> None: + shell = (REPO / "bashrc" / "shpool.bashrc").read_text(encoding="utf-8") + app_definition = shell.index('__sk_app_log="$__sk_app_dir/app-server.log"') + broker_definition = shell.index('__sk_broker_log="$__sk_app_dir/broker.log"') + first_redirect = shell.index('>>"$__sk_app_log"') + self.assertLess(app_definition, first_redirect) + self.assertLess(broker_definition, first_redirect) + self.assertIn("O_NOFOLLOW", shell[app_definition:first_redirect]) + self.assertIn("os.fchmod(descriptor, 0o600)", shell[app_definition:first_redirect]) + self.assertIn("[[ -L $__sk_app_log || -L $__sk_broker_log ]]", shell) + + def test_app_server_directory_chain_is_private_real_and_owner_controlled(self) -> None: + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + state = base / "state" + state.mkdir(mode=0o700) + output = io.StringIO() + with contextlib.redirect_stdout(output): + code = inventory_core._platform_command( + argparse.Namespace( + platform_action="app-server-dir", + state_root=str(state), + session_id="main", + ) + ) + self.assertEqual(0, code) + created = Path(output.getvalue().strip()) + self.assertEqual(state / "session-kit" / "app-server" / "main", created) + for path in (state / "session-kit", state / "session-kit" / "app-server", created): + self.assertFalse(path.is_symlink()) + self.assertEqual(0o700, path.stat().st_mode & 0o777) + + unsafe = base / "unsafe-state" + unsafe.mkdir(mode=0o700) + external = base / "external" + external.mkdir(mode=0o700) + (unsafe / "session-kit").symlink_to(external, target_is_directory=True) + with self.assertRaises(inventory_core.CollectionError): + inventory_core._platform_command( + argparse.Namespace( + platform_action="app-server-dir", + state_root=str(unsafe), + session_id="main", + ) + ) + weak = base / "weak-state" + weak.mkdir(mode=0o755) + weak.chmod(0o755) + with self.assertRaises(inventory_core.CollectionError): + inventory_core._platform_command( + argparse.Namespace( + platform_action="app-server-dir", + state_root=str(weak), + session_id="main", + ) + ) + + def test_pending_self_name_yields_to_native_claude_rename(self) -> None: + uuid = "00000000-0000-4000-8000-000000000034" + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + state = base / "state" + state.mkdir(mode=0o700) + home = base / "home" + transcript_root = home / ".claude" / "projects" / "-srv-project" + transcript_root.mkdir(parents=True) + transcript = transcript_root / f"{uuid}.jsonl" + transcript.write_text( + json.dumps( + { + "type": "agent-name", + "agentName": "Manual Native Name", + "sessionId": uuid, + } + ) + + "\n", + encoding="utf-8", + ) + retry = state / "provider-title-retries.json" + retry.write_text( + json.dumps( + { + "schema_version": 1, + "entries": { + f"claude:{uuid}": { + "provider": "claude", + "uuid": uuid, + "title": "Old Kit Name", + "attempts": 0, + } + }, + } + ), + encoding="utf-8", + ) + retry.chmod(0o600) + before = transcript.read_bytes() + with mock.patch.object( + inventory_core, + "snapshot", + side_effect=inventory_core.CollectionError("no live inventory"), + ): + result = inventory_core.claude_pending_native_hydrations( + {"state_dir": state}, {"HOME": str(home)} + ) + self.assertEqual("superseded", result[0]["status"]) + self.assertEqual(before, transcript.read_bytes()) + self.assertEqual({}, json.loads(retry.read_text())["entries"]) + UUID = "00000000-0000-4000-8000-000000000077" + + def _prebaked_home(self, base: Path) -> tuple[Path, Path]: + home = base / "home" + (home / ".claude" / "sessions").mkdir(parents=True, mode=0o700) + project = home / ".claude" / "projects" / "-srv-project" + project.mkdir(parents=True, mode=0o700) + transcript = project / f"{self.UUID}.jsonl" + records = [ + {"type": "agent-color", "agentColor": "green", "sessionId": self.UUID}, + { + "type": "user", + "isMeta": True, + "message": { + "role": "user", + "content": [ + { + "type": "text", + "text": "Continue from where you left off.", + } + ], + }, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + {"type": "text", "text": "No response requested."} + ], + }, + }, + ] + transcript.write_text( + "".join(json.dumps(record) + "\n" for record in records), + encoding="utf-8", + ) + return home, transcript + + def _payload(self, transcript: Path, prompt: str) -> str: + return json.dumps( + { + "hook_event_name": "UserPromptSubmit", + "session_id": self.UUID, + "transcript_path": str(transcript), + "prompt": prompt, + } + ) + + def test_titles_prebaked_conversation_once(self) -> None: + with tempfile.TemporaryDirectory() as base: + home, transcript = self._prebaked_home(Path(base)) + env = {"HOME": str(home)} + result = inventory_core.auto_title_from_hook( + self._payload( + transcript, + "leap years are there in 2026 and what are the dates?", + ), + environ=env, + ) + self.assertEqual("Leap years are there in 2026", result["title"]) + self.assertIn("claude-transcript-ai-title", result["pushes"]) + self.assertIn("claude-transcript-name", result["pushes"]) + self.assertIn("claude-nameintent", result["pushes"]) + records = [ + json.loads(line) + for line in transcript.read_text().splitlines() + ] + self.assertIn("ai-title", [r.get("type") for r in records]) + self.assertIn("agent-name", [r.get("type") for r in records]) again = inventory_core.auto_title_from_hook( self._payload(transcript, "another prompt entirely here"), environ=env, @@ -733,6 +1425,24 @@ def test_bounce_uses_real_title_and_mirrors_it_to_the_index(self) -> None: ], ) + def test_read_only_bounce_lookup_never_appends_to_the_session_index(self) -> None: + uuid = "00000000-0000-4000-8000-000000000049" + with tempfile.TemporaryDirectory() as base: + codex = self._bounce_codex_root( + Path(base), + uuid, + "Read Only Launch Lookup", + "an unrelated first prompt", + ) + + self.assertEqual( + "Read Only Launch Lookup", + inventory_core.codex_bounce_prepare( + uuid, codex, mirror_index=False + ), + ) + self.assertFalse((codex / "session_index.jsonl").exists()) + def test_bounce_honors_an_echo_shaped_index_entry_as_deliberate( self, ) -> None: @@ -777,12 +1487,12 @@ def test_bounce_prefers_an_explicit_index_rename(self) -> None: ) index = codex / "session_index.jsonl" index.write_text( - json.dumps({"id": uuid, "thread_name": "Renamed By Dan"}) + json.dumps({"id": uuid, "thread_name": "Renamed By The Operator"}) + "\n", encoding="utf-8", ) self.assertEqual( - "Renamed By Dan", + "Renamed By The Operator", inventory_core.codex_bounce_prepare(uuid, codex), ) self.assertEqual( @@ -904,6 +1614,69 @@ def test_first_prompt_outranks_payload_prompt(self) -> None: self.assertEqual("Fix the nginx 502 errors please", result["title"]) +class ClaudeBouncePrepareTests(unittest.TestCase): + def _home( + self, + base: Path, + uuid: str, + intent: str | None, + prompt_offset: float, + ) -> Path: + import datetime as dt + + home = base / "home" + sessions = home / ".claude" / "sessions" + project = home / ".claude" / "projects" / "-srv-project" + sessions.mkdir(parents=True) + project.mkdir(parents=True) + intent_path = sessions / f"{uuid}.nameintent" + if intent is not None: + intent_path.write_text(intent + "\n", encoding="utf-8") + intent_mtime = ( + intent_path.stat().st_mtime if intent is not None else 1_700_000_000 + ) + stamp = dt.datetime.fromtimestamp( + intent_mtime + prompt_offset, dt.timezone.utc + ).isoformat().replace("+00:00", "Z") + records = [ + {"type": "user", "isMeta": True, "message": {"role": "user", "content": "meta"}, "timestamp": stamp}, + {"type": "user", "message": {"role": "user", "content": ["tool result"]}, "timestamp": stamp}, + {"type": "user", "message": {"role": "user", "content": "who are the last three headliners"}, "timestamp": stamp}, + ] + (project / f"{uuid}.jsonl").write_text( + "\n".join(json.dumps(record) for record in records) + "\n", + encoding="utf-8", + ) + return home + + def test_bounces_when_the_name_arrived_after_the_last_prompt(self) -> None: + uuid = "00000000-0000-4000-8000-000000000061" + with tempfile.TemporaryDirectory() as raw: + home = self._home(Path(raw), uuid, "Headliner Question", -120) + self.assertEqual( + ("Headliner Question", False), + inventory_core.claude_bounce_prepare(uuid, home=home), + ) + + def test_clears_when_a_real_prompt_followed_the_intent(self) -> None: + uuid = "00000000-0000-4000-8000-000000000062" + with tempfile.TemporaryDirectory() as raw: + home = self._home(Path(raw), uuid, "Headliner Question", 120) + self.assertEqual( + ("", True), + inventory_core.claude_bounce_prepare(uuid, home=home), + ) + + def test_defers_without_an_intent_and_ignores_meta_and_tool_records(self) -> None: + uuid = "00000000-0000-4000-8000-000000000063" + with tempfile.TemporaryDirectory() as raw: + home = self._home(Path(raw), uuid, None, 0) + self.assertEqual( + ("", False), + inventory_core.claude_bounce_prepare(uuid, home=home), + ) + + class CodexPendingAutoTitleTests(unittest.TestCase): def _codex_root(self, base: Path, rows: list[tuple]) -> Path: import sqlite3 @@ -933,6 +1706,64 @@ def _run(self, base: Path, codex: Path) -> list[dict[str, str]]: environ={"HOME": str(base)} ) + def test_auto_title_offers_live_rename_from_the_caller_sandbox(self) -> None: + import time + + uuid = "00000000-0000-4000-8000-000000000058" + now = int(time.time()) + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + codex = self._codex_root( + base, + [(uuid, "trace the cdn purge failure", "trace the cdn purge failure", now - 60)], + ) + with mock.patch.object( + inventory_core, "_push_codex_live_rename", return_value=([], []) + ) as live: + titled = self._run(base, codex) + self.assertEqual(1, len(titled)) + live.assert_called_once() + call = live.call_args + self.assertEqual( + ( + Path(base) / ".local" / "state" / "session-kit", + uuid, + titled[0]["title"], + ), + call.args, + ) + self.assertGreater(call.kwargs["timeout_seconds"], 0) + self.assertTrue(call.kwargs["still_automatic"]()) + + def test_auto_title_live_rename_honors_kill_switch(self) -> None: + import time + + uuid = "00000000-0000-4000-8000-000000000059" + now = int(time.time()) + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + codex = self._codex_root( + base, + [(uuid, "trace the cdn purge failure", "trace the cdn purge failure", now - 60)], + ) + with mock.patch.dict( + os.environ, + {"SESSION_KIT_CODEX_HOME": str(codex)}, + clear=False, + ): + os.environ.pop("SESSION_KIT_CODEX_DB", None) + with mock.patch.object( + inventory_core, "_push_codex_live_rename", return_value=([], []) + ) as live: + titled = inventory_core.codex_pending_auto_titles( + environ={ + "HOME": str(base), + "SESSION_KIT_CODEX_LIVE_RENAME": "0", + } + ) + self.assertEqual(1, len(titled)) + live.assert_not_called() + def test_titles_a_recent_echo_thread_into_both_stores(self) -> None: import sqlite3 import time @@ -978,12 +1809,20 @@ def test_titles_a_recent_echo_thread_into_both_stores(self) -> None: # Self-terminating: the index entry excludes it next pass. self.assertEqual([], self._run(base, codex)) - def test_never_touches_named_stale_or_indexed_threads(self) -> None: + def test_never_touches_named_or_indexed_threads(self) -> None: + """Age is not evidence of anything; a name is. + + The titler used to skip threads older than a week, which left them + nameless forever (ledger B6). Age no longer decides: what protects a + thread is a real name in the database or a deliberate entry in the + session index. An old thread whose title is still the prompt it + started with is exactly the thread nobody has named. + """ import time now = int(time.time()) named = "00000000-0000-4000-8000-000000000052" - stale = "00000000-0000-4000-8000-000000000053" + old_echo = "00000000-0000-4000-8000-000000000053" indexed = "00000000-0000-4000-8000-000000000054" with tempfile.TemporaryDirectory() as raw: base = Path(raw) @@ -991,7 +1830,7 @@ def test_never_touches_named_stale_or_indexed_threads(self) -> None: base, [ (named, "Real Name", "unrelated prompt", now - 60), - (stale, "old echo", "old echo", now - 8 * 86400), + (old_echo, "old echo", "old echo", now - 8 * 86400), (indexed, "echo text", "echo text", now - 60), ], ) @@ -1000,27 +1839,106 @@ def test_never_touches_named_stale_or_indexed_threads(self) -> None: + "\n", encoding="utf-8", ) + titled = self._run(base, codex) + self.assertEqual([old_echo], [item["uuid"] for item in titled]) + entries = [ + json.loads(line) + for line in (codex / "session_index.jsonl") + .read_text(encoding="utf-8") + .splitlines() + ] + self.assertEqual( + "Kept Name", + [e["thread_name"] for e in entries if e["id"] == indexed][-1], + ) + self.assertEqual([], [e for e in entries if e["id"] == named]) + + def test_heals_database_title_from_curated_index_entry(self) -> None: + import sqlite3 + import time + + uuid = "00000000-0000-4000-8000-000000000056" + now = int(time.time()) + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + codex = self._codex_root( + base, + [(uuid, "what is the capital of peru", "what is the capital of peru", now - 60)], + ) + (codex / "session_index.jsonl").write_text( + json.dumps({"id": uuid, "thread_name": "prompt echo stub"}) + + "\n" + + json.dumps({"id": uuid, "thread_name": "Peru Capital Check"}) + + "\n", + encoding="utf-8", + ) + # Not reported as a fresh title: the heal converges stores for a + # thread that already carries its deliberate name in the index. self.assertEqual([], self._run(base, codex)) + connection = sqlite3.connect(codex / "state_5.sqlite") + self.assertEqual( + ("Peru Capital Check",), + connection.execute( + "SELECT title FROM threads WHERE id = ?", (uuid,) + ).fetchone(), + ) + connection.close() + # The index itself is untouched by the heal (no new entries). + self.assertEqual( + 2, + len( + (codex / "session_index.jsonl") + .read_text(encoding="utf-8") + .splitlines() + ), + ) - def test_kill_switch_disables_the_titler(self) -> None: + def test_heal_never_replaces_a_real_database_name(self) -> None: + import sqlite3 import time - uuid = "00000000-0000-4000-8000-000000000055" + uuid = "00000000-0000-4000-8000-000000000057" + now = int(time.time()) with tempfile.TemporaryDirectory() as raw: base = Path(raw) codex = self._codex_root( - base, [(uuid, "echo", "echo", int(time.time()) - 60)] + base, + [(uuid, "Deliberate Db Name", "some first prompt", now - 60)], ) - with mock.patch.dict( - os.environ, - {"SESSION_KIT_CODEX_HOME": str(codex)}, - clear=False, - ): - for kill in ( - {"SESSION_KIT_AUTO_NAME": "0"}, - {"SESSION_KIT_CODEX_AUTOTITLE": "0"}, - ): - self.assertEqual( + (codex / "session_index.jsonl").write_text( + json.dumps({"id": uuid, "thread_name": "Different Index Name"}) + + "\n", + encoding="utf-8", + ) + self.assertEqual([], self._run(base, codex)) + connection = sqlite3.connect(codex / "state_5.sqlite") + self.assertEqual( + ("Deliberate Db Name",), + connection.execute( + "SELECT title FROM threads WHERE id = ?", (uuid,) + ).fetchone(), + ) + connection.close() + + def test_kill_switch_disables_the_titler(self) -> None: + import time + + uuid = "00000000-0000-4000-8000-000000000055" + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + codex = self._codex_root( + base, [(uuid, "echo", "echo", int(time.time()) - 60)] + ) + with mock.patch.dict( + os.environ, + {"SESSION_KIT_CODEX_HOME": str(codex)}, + clear=False, + ): + for kill in ( + {"SESSION_KIT_AUTO_NAME": "0"}, + {"SESSION_KIT_CODEX_AUTOTITLE": "0"}, + ): + self.assertEqual( [], inventory_core.codex_pending_auto_titles( environ={"HOME": str(base), **kill} @@ -1028,5 +1946,1179 @@ def test_kill_switch_disables_the_titler(self) -> None: ) +class NameOwnershipTests(unittest.TestCase): + """Who owns a name, and what an automatic pass may do about it.""" + + UUID = "00000000-0000-4000-8000-000000000201" + + def _sandbox(self, base: Path) -> tuple[Path, dict[str, str]]: + home = base / "home" + home.mkdir(mode=0o700) + return home, {"HOME": str(home)} + + def _document(self, home: Path) -> dict: + path = home / ".config" / "session-kit" / "inventory.json" + if not path.is_file(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + def _human_rename( + self, home: Path, provider: str, uuid: str, title: str | None + ) -> None: + """Rename the way `sp name` and the picker do: through the alias tier.""" + state = home / "state" + state.mkdir(mode=0o700, exist_ok=True) + path = home / ".config" / "session-kit" / "inventory.json" + inventory_core._private_alias_parent(path) + with mock.patch.dict( + os.environ, {"SESSION_KIT_CONFIG": str(path)}, clear=False + ): + inventory_core.mutate_canonical_alias( + {"state_dir": state}, provider, uuid, title + ) + + def test_an_automatic_claim_is_taken_once_and_then_reported_as_owned(self) -> None: + with tempfile.TemporaryDirectory() as raw: + home, env = self._sandbox(Path(raw)) + self.assertEqual("", inventory_core.name_owner("codex", self.UUID, environ=env)) + self.assertEqual( + "", inventory_core.claim_automatic_name("codex", self.UUID, environ=env) + ) + self.assertEqual( + "automatic", + inventory_core.name_owner("codex", self.UUID, environ=env), + ) + self.assertIn( + "already owns", + inventory_core.claim_automatic_name("codex", self.UUID, environ=env), + ) + record = self._document(home)["name_ownership"][f"codex:{self.UUID}"] + self.assertEqual("automatic", record["owner"]) + self.assertTrue(record["at"]) + + def test_only_an_untouched_automatic_claim_can_be_released(self) -> None: + with tempfile.TemporaryDirectory() as raw: + home, env = self._sandbox(Path(raw)) + self.assertEqual( + "", inventory_core.claim_automatic_name("codex", self.UUID, environ=env) + ) + self.assertTrue( + inventory_core.release_automatic_name_claim( + "codex", self.UUID, environ=env + ) + ) + self.assertEqual("", inventory_core.name_owner("codex", self.UUID, environ=env)) + + self._human_rename(home, "codex", self.UUID, "the operator named this") + self.assertFalse( + inventory_core.release_automatic_name_claim( + "codex", self.UUID, environ=env + ) + ) + self.assertEqual( + "human", inventory_core.name_owner("codex", self.UUID, environ=env) + ) + + def test_a_human_rename_takes_ownership_and_never_gives_it_back(self) -> None: + with tempfile.TemporaryDirectory() as raw: + home, env = self._sandbox(Path(raw)) + self.assertEqual( + "", inventory_core.claim_automatic_name("claude", self.UUID, environ=env) + ) + self._human_rename(home, "claude", self.UUID, "the operator named this") + self.assertEqual( + "human", inventory_core.name_owner("claude", self.UUID, environ=env) + ) + self.assertEqual( + frozenset({f"claude:{self.UUID}"}), + inventory_core.human_named_keys(env), + ) + stamped = self._document(home)["name_ownership"][f"claude:{self.UUID}"] + self.assertIn( + "already owns", + inventory_core.claim_automatic_name("claude", self.UUID, environ=env), + ) + # `sp name reset` drops the alias. The override outlives it, so no + # later automatic pass can resurrect a name over the reset. + self._human_rename(home, "claude", self.UUID, None) + self.assertEqual( + {}, self._document(home)["aliases"] + ) + self.assertEqual( + "human", inventory_core.name_owner("claude", self.UUID, environ=env) + ) + self.assertIn( + "already owns", + inventory_core.claim_automatic_name("claude", self.UUID, environ=env), + ) + # A second rename does not re-stamp the moment the override began. + self._human_rename(home, "claude", self.UUID, "the operator named it again") + self.assertEqual( + stamped, + self._document(home)["name_ownership"][f"claude:{self.UUID}"], + ) + + + def test_a_legacy_document_is_read_from_the_evidence_it_kept(self) -> None: + with tempfile.TemporaryDirectory() as raw: + home, env = self._sandbox(Path(raw)) + path = home / ".config" / "session-kit" / "inventory.json" + inventory_core._private_alias_parent(path) + renamed = uuid_for(202) + self_named = uuid_for(203) + path.write_text( + json.dumps( + { + "schema_version": 1, + "aliases": { + f"claude:{renamed}": "A person wrote this", + f"codex:{self_named}": "Session Kit Updates", + }, + "automatic_titles": { + f"codex:{self_named}": "Session Kit Updates" + }, + } + ), + encoding="utf-8", + ) + path.chmod(0o600) + # No ownership record exists yet: an alias is a human rename unless + # the automatic tier holds the same text, which only a self-name + # ever writes. + self.assertEqual( + "human", inventory_core.name_owner("claude", renamed, environ=env) + ) + self.assertEqual( + "automatic", inventory_core.name_owner("codex", self_named, environ=env) + ) + self.assertEqual( + frozenset({f"claude:{renamed}"}), + inventory_core.human_named_keys(env), + ) + + def test_an_unreadable_document_owns_nothing(self) -> None: + self.assertEqual("", inventory_core.name_owner("codex", self.UUID, environ={})) + self.assertEqual(frozenset(), inventory_core.human_named_keys({})) + self.assertIn( + "no home", + inventory_core.claim_automatic_name("codex", self.UUID, environ={}), + ) + self.assertEqual( + "", inventory_core.name_owner("codex", "not-a-uuid", environ={"HOME": "/"}) + ) + + +class ClaudeFirstPromptOwnershipTests(unittest.TestCase): + """The Claude hook claims a thread's name at its first prompt, once.""" + + UUID = "00000000-0000-4000-8000-000000000211" + + def _prebaked_home(self, base: Path) -> tuple[Path, Path]: + home = base / "home" + (home / ".claude" / "sessions").mkdir(parents=True, mode=0o700) + project = home / ".claude" / "projects" / "-srv-project" + project.mkdir(parents=True, mode=0o700) + transcript = project / f"{self.UUID}.jsonl" + records = [ + { + "type": "user", + "isMeta": True, + "message": { + "role": "user", + "content": [ + {"type": "text", "text": "Continue from where you left off."} + ], + }, + } + ] + transcript.write_text( + "".join(json.dumps(record) + "\n" for record in records), + encoding="utf-8", + ) + return home, transcript + + def _payload(self, transcript: Path, prompt: str, event: str) -> str: + return json.dumps( + { + "hook_event_name": event, + "session_id": self.UUID, + "transcript_path": str(transcript), + "prompt": prompt, + } + ) + + def test_the_first_prompt_claims_the_name_and_the_stop_firing_does_not( + self, + ) -> None: + with tempfile.TemporaryDirectory() as raw: + home, transcript = self._prebaked_home(Path(raw)) + env = {"HOME": str(home)} + first = inventory_core.auto_title_from_hook( + self._payload( + transcript, + "trace the cdn purge failure across both edges", + "UserPromptSubmit", + ), + environ=env, + ) + self.assertEqual( + "Trace the cdn purge failure across both", first["title"] + ) + self.assertEqual( + "automatic", + inventory_core.name_owner("claude", self.UUID, environ=env), + ) + # session_start fires twice for a brand-new session and the hook + # fires again at Stop. The claim is what keeps this a no-op. + before = transcript.read_bytes() + again = inventory_core.auto_title_from_hook( + self._payload(transcript, "a different second prompt here", "Stop"), + environ=env, + ) + self.assertIsNone(again["title"]) + self.assertEqual( + "an automatic name already owns this session", again["reason"] + ) + self.assertEqual(before, transcript.read_bytes()) + + def test_a_human_rename_stops_the_hook_before_it_writes_anything(self) -> None: + with tempfile.TemporaryDirectory() as raw: + home, transcript = self._prebaked_home(Path(raw)) + env = {"HOME": str(home)} + state = home / "state" + state.mkdir(mode=0o700) + path = home / ".config" / "session-kit" / "inventory.json" + inventory_core._private_alias_parent(path) + with mock.patch.dict( + os.environ, {"SESSION_KIT_CONFIG": str(path)}, clear=False + ): + inventory_core.mutate_canonical_alias( + {"state_dir": state}, "claude", self.UUID, "the operator named this" + ) + before = transcript.read_bytes() + result = inventory_core.auto_title_from_hook( + self._payload( + transcript, "trace the cdn purge failure now", "UserPromptSubmit" + ), + environ=env, + ) + self.assertIsNone(result["title"]) + self.assertEqual("a human name owns this session", result["reason"]) + self.assertEqual([], result["pushes"]) + self.assertEqual(before, transcript.read_bytes()) + + +class CodexFirstTurnOwnershipTests(unittest.TestCase): + """A Codex thread is claimed at its first turn, the first moment it exists.""" + + def _codex_root(self, base: Path, rows: list[tuple]) -> Path: + import sqlite3 + + codex = base / ".codex" + codex.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(codex / "state_5.sqlite") + connection.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT," + " first_user_message TEXT, updated_at INTEGER)" + ) + connection.executemany("INSERT INTO threads VALUES (?, ?, ?, ?)", rows) + connection.commit() + connection.close() + return codex + + def _run(self, base: Path, codex: Path) -> list[dict[str, str]]: + with mock.patch.dict( + os.environ, {"SESSION_KIT_CODEX_HOME": str(codex)}, clear=False + ): + os.environ.pop("SESSION_KIT_CODEX_DB", None) + return inventory_core.codex_pending_auto_titles( + environ={"HOME": str(base)} + ) + + def _thread_title(self, codex: Path, uuid: str) -> str: + import sqlite3 + + connection = sqlite3.connect(codex / "state_5.sqlite") + try: + return connection.execute( + "SELECT title FROM threads WHERE id = ?", (uuid,) + ).fetchone()[0] + finally: + connection.close() + + def test_the_first_turn_claims_the_thread_and_a_later_pass_leaves_it_alone( + self, + ) -> None: + import time + + uuid = uuid_for(221) + now = int(time.time()) + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + base.chmod(0o700) + codex = self._codex_root( + base, + [(uuid, "trace the cdn purge failure", "trace the cdn purge failure", now - 60)], + ) + titled = self._run(base, codex) + self.assertEqual(1, len(titled)) + env = {"HOME": str(base)} + self.assertEqual( + "automatic", inventory_core.name_owner("codex", uuid, environ=env) + ) + named = self._thread_title(codex, uuid) + # Codex re-stamps the raw prompt over the title and the index entry + # is gone; only the claim keeps the second pass from renaming. + import sqlite3 + + connection = sqlite3.connect(codex / "state_5.sqlite") + connection.execute( + "UPDATE threads SET title = ? WHERE id = ?", + ("trace the cdn purge failure", uuid), + ) + connection.commit() + connection.close() + (codex / "session_index.jsonl").unlink() + self.assertEqual([], self._run(base, codex)) + self.assertTrue(named) + self.assertFalse((codex / "session_index.jsonl").is_file()) + + def test_a_human_rename_is_never_titled_or_healed(self) -> None: + import time + + uuid = uuid_for(222) + now = int(time.time()) + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + base.chmod(0o700) + codex = self._codex_root( + base, + [(uuid, "trace the cdn purge failure", "trace the cdn purge failure", now - 60)], + ) + state = base / "state" + state.mkdir(mode=0o700) + config = base / ".config" / "session-kit" / "inventory.json" + inventory_core._private_alias_parent(config) + with mock.patch.dict( + os.environ, {"SESSION_KIT_CONFIG": str(config)}, clear=False + ): + inventory_core.mutate_canonical_alias( + {"state_dir": state}, "codex", uuid, "the operator named this" + ) + # A curated index entry plus a prompt-echo title is exactly the + # shape the healer re-asserts. It must not touch a human's name. + (codex / "session_index.jsonl").write_text( + json.dumps({"id": uuid, "thread_name": "An older automatic name"}) + + "\n", + encoding="utf-8", + ) + self.assertEqual([], self._run(base, codex)) + self.assertEqual( + "trace the cdn purge failure", self._thread_title(codex, uuid) + ) + + def test_a_queued_retry_never_replays_over_a_human_rename_after_a_restart( + self, + ) -> None: + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + base.chmod(0o700) + uuid = uuid_for(223) + state = base / ".local" / "state" / "session-kit" + state.mkdir(mode=0o700, parents=True) + # The retry queue is on disk from before the reboot. + (state / "provider-title-retries.json").write_text( + json.dumps( + { + "schema_version": 1, + "entries": { + f"codex:{uuid}": { + "provider": "codex", + "uuid": uuid, + "title": "An Older Automatic Name", + "attempts": 0, + "updated_at": "2026-08-09T00:00:00Z", + } + }, + } + ), + encoding="utf-8", + ) + (state / "provider-title-retries.json").chmod(0o600) + config = base / ".config" / "session-kit" / "inventory.json" + inventory_core._private_alias_parent(config) + with mock.patch.dict( + os.environ, {"SESSION_KIT_CONFIG": str(config)}, clear=False + ): + inventory_core.mutate_canonical_alias( + {"state_dir": base / "scratch"}, "codex", uuid, "the operator named this" + ) + with mock.patch.object( + inventory_core, "propagate_provider_title" + ) as push: + replayed = inventory_core._reconcile_pending_provider_titles( + {"state_dir": state}, "codex", {"HOME": str(base)} + ) + self.assertEqual([], replayed) + push.assert_not_called() + + +class NativeRenameOwnershipTests(unittest.TestCase): + """A /rename typed into a provider is a person naming their own work.""" + + def _sandbox(self, base: Path) -> tuple[Path, Path, dict[str, str]]: + home = base / "home" + home.mkdir(mode=0o700) + config = home / ".config" / "session-kit" / "inventory.json" + inventory_core._private_alias_parent(config) + return home, config, {"HOME": str(home)} + + def _seed(self, config: Path, document: dict) -> None: + config.write_text(json.dumps(document), encoding="utf-8") + config.chmod(0o600) + + def _document(self, config: Path) -> dict: + return json.loads(config.read_text(encoding="utf-8")) + + def _title(self, document: dict, provider: str, uuid: str, native: str) -> tuple: + """What a row would show, with the document's own evidence.""" + return inventory_core._provider_title_info( + provider, + uuid, + native, + document.get("aliases", {}), + "/srv/project", + 1_700_000_000_000, + document.get("automatic_titles", {}), + provider_title_is_explicit=True, + pushed_titles=document.get("pushed_titles", {}), + name_ownership=document.get("name_ownership", {}), + ) + + def test_the_live_stale_room_self_corrects_and_never_reverts(self) -> None: + """Seeded exactly as observed: alias and automatic both stale. + + The room was self-named "Session Kit Audit"; the automatic title was + written as an alias too, so the alias masked the native store. The + operator typed /rename in Codex. Both Codex stores read "kit test"; the picker + still read "Session Kit Audit". + """ + uuid = uuid_for(301) + key = f"codex:{uuid}" + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + base.chmod(0o700) + home, config, env = self._sandbox(base) + self._seed( + config, + { + "schema_version": 1, + "aliases": {key: "Session Kit Audit"}, + "automatic_titles": {key: "Session Kit Audit"}, + }, + ) + # The row is honest immediately: the alias is kit-authored (it + # equals the automatic title), so the rename outranks it before + # anything has been written. + self.assertEqual( + ("kit test", "native"), + self._title(self._document(config), "codex", uuid, "kit test"), + ) + adopted = inventory_core.adopt_native_rename( + "codex", uuid, "kit test", environ=env + ) + # Byte for byte, including the lower case actually typed. A + # human title is an opaque string: no Title Case, no folding. + self.assertEqual("kit test", adopted) + document = self._document(config) + # After: one name, owned by a person, on every tier at once. + self.assertEqual("kit test", document["aliases"][key]) + self.assertEqual("kit test", document["pushed_titles"][key]) + self.assertEqual("human", document["name_ownership"][key]["owner"]) + self.assertNotIn(key, document.get("automatic_titles", {})) + self.assertEqual( + ("kit test", "alias"), + self._title(document, "codex", uuid, "kit test"), + ) + # Every surface, byte-exact: the row, the detail view, the alias + # list. Nothing along the way re-cases a name a person chose. + rendered = inventory_core.render_inventory( + { + "schema_version": 1, + "generated_at": "2026-08-09T00:00:00Z", + "source": "live", + "stale": False, + "warnings": [], + "sessions": [ + { + "row": 1, + "terminal_number": 4, + "shpool_id": "s-1", + "provider": "codex", + "display_provider": "codex", + "identity": {"uuid": uuid, "confidence": "exact"}, + "title": document["aliases"][key], + "display_title": document["aliases"][key], + "agent_status": "idle", + "availability": "ready", + "shpool_status": "Disconnected", + "cwd": "/srv/project", + "subagents": [], + } + ], + "outside_agents": [], + } + ) + self.assertIn("kit test", rendered) + self.assertNotIn("Kit Test", rendered) + self.assertNotIn("Session Kit Audit", rendered) + self.assertEqual( + "human", inventory_core.name_owner("codex", uuid, environ=env) + ) + # A reconciliation pass is idempotent, nothing left to adopt. + self.assertEqual( + "", inventory_core.adopt_native_rename("codex", uuid, "kit test", environ=env) + ) + # And no automatic path may put the old name back, ever. + self.assertIn( + "already owns", + inventory_core.claim_automatic_name("codex", uuid, environ=env), + ) + with self.assertRaisesRegex( + inventory_core.CollectionError, "already owns" + ): + self._refuse_automatic(config, base, uuid) + self.assertEqual( + "kit test", self._document(config)["aliases"][key] + ) + + def test_codex_rename_survives_titler_and_restore_on_both_surfaces(self) -> None: + """A settled Codex /rename is adopted before restore can overwrite it.""" + import sqlite3 + import time + + uuid = uuid_for(302) + key = f"codex:{uuid}" + automatic = "Crawl Budget Report Review" + chosen = "A Hand Chosen Title" + chosen_again = "A Hand Chosen Title Again" + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + base.chmod(0o700) + home, config, env = self._sandbox(base) + state = base / "state" + state.mkdir(mode=0o700) + codex = home / ".codex" + codex.mkdir(mode=0o700) + database = codex / "state_5.sqlite" + connection = sqlite3.connect(database) + connection.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT," + " first_user_message TEXT, updated_at INTEGER)" + ) + connection.execute( + "INSERT INTO threads VALUES (?, ?, ?, ?)", + ( + uuid, + "trace the cdn purge failure", + "trace the cdn purge failure", + int(time.time()), + ), + ) + connection.commit() + connection.close() + env.update( + { + "SESSION_KIT_CONFIG": str(config), + "SESSION_KIT_STATE_DIR": str(state), + "SESSION_KIT_CODEX_HOME": str(codex), + "SESSION_KIT_CODEX_LIVE_RENAME": "0", + } + ) + with mock.patch.dict(os.environ, env, clear=False): + inventory_core.mutate_canonical_automatic_title( + {"state_dir": state}, + "codex", + uuid, + automatic, + overwrite=True, + ) + seeded = inventory_core.propagate_provider_title( + "codex", uuid, automatic, environ=env + ) + self.assertEqual([], seeded["provider_title_warnings"]) + + # This is the exact store shape Codex /rename leaves: the + # index and threads.title carry the same operator-typed name. + with (codex / "session_index.jsonl").open( + "a", encoding="utf-8" + ) as index: + index.write( + json.dumps({"id": uuid, "thread_name": chosen}) + "\n" + ) + connection = sqlite3.connect(database) + connection.execute( + "UPDATE threads SET title = ? WHERE id = ?", (chosen, uuid) + ) + connection.commit() + connection.close() + + inventory_core.codex_pending_auto_titles(environ=env) + document = self._document(config) + self.assertEqual(chosen, document["aliases"][key]) + self.assertEqual("human", document["name_ownership"][key]["owner"]) + self.assertNotIn(key, document.get("automatic_titles", {})) + self.assertEqual( + "human", + inventory_core.name_owner("codex", uuid, environ=env), + ) + + # Restore runs this exact alias-push path before launching the + # provider. It must re-assert the adopted name, never the old + # automatic title. + output = io.StringIO() + with contextlib.redirect_stdout(output): + status = inventory_core._alias_command( + argparse.Namespace( + alias_action="push", provider="codex", uuid=uuid + ), + inventory_core.load_config(), + ) + self.assertEqual(0, status, output.getvalue()) + + # Human ownership is not a reason to ignore later native + # news. Rename the restored thread again, reconcile again, + # and restore again through the real alias-push path. + with (codex / "session_index.jsonl").open( + "a", encoding="utf-8" + ) as index: + index.write( + json.dumps({"id": uuid, "thread_name": chosen_again}) + "\n" + ) + connection = sqlite3.connect(database) + connection.execute( + "UPDATE threads SET title = ? WHERE id = ?", + (chosen_again, uuid), + ) + connection.commit() + connection.close() + + inventory_core.codex_pending_auto_titles(environ=env) + document = self._document(config) + self.assertEqual(chosen_again, document["aliases"][key]) + self.assertEqual(chosen_again, document["pushed_titles"][key]) + self.assertEqual("human", document["name_ownership"][key]["owner"]) + self.assertNotIn(key, document.get("automatic_titles", {})) + + output = io.StringIO() + with contextlib.redirect_stdout(output): + status = inventory_core._alias_command( + argparse.Namespace( + alias_action="push", provider="codex", uuid=uuid + ), + inventory_core.load_config(), + ) + self.assertEqual(0, status, output.getvalue()) + + latest = inventory_core.read_codex_session_index( + codex / "session_index.jsonl" + ) + connection = sqlite3.connect(database) + stored = connection.execute( + "SELECT title FROM threads WHERE id = ?", (uuid,) + ).fetchone()[0] + connection.close() + self.assertEqual(chosen_again, latest[uuid]) + self.assertEqual(chosen_again, stored) + + def _refuse_automatic(self, config: Path, base: Path, uuid: str) -> None: + state = base / "state" + state.mkdir(mode=0o700, exist_ok=True) + with mock.patch.dict( + os.environ, {"SESSION_KIT_CONFIG": str(config)}, clear=False + ): + inventory_core.mutate_canonical_automatic_title( + {"state_dir": state}, "codex", uuid, "Session Kit Audit", overwrite=True + ) + + def test_a_rename_after_the_kit_pushed_its_own_title_is_adopted(self) -> None: + for provider in ("claude", "codex"): + with self.subTest(provider=provider): + uuid = uuid_for(310 if provider == "claude" else 311) + key = f"{provider}:{uuid}" + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + base.chmod(0o700) + home, config, env = self._sandbox(base) + self._seed( + config, + { + "schema_version": 1, + "aliases": {}, + "automatic_titles": {key: "Session Kit Audit"}, + "pushed_titles": {key: "Session Kit Audit"}, + }, + ) + # The kit's own echo is not a rename. + self.assertEqual( + "", + inventory_core.adopt_native_rename( + provider, uuid, "Session Kit Audit", environ=env + ), + ) + self.assertEqual( + "Kit Test", + inventory_core.adopt_native_rename( + provider, uuid, "Kit Test", environ=env + ), + ) + document = self._document(config) + self.assertEqual("Kit Test", document["aliases"][key]) + self.assertEqual( + "human", document["name_ownership"][key]["owner"] + ) + self.assertEqual( + ("Kit Test", "alias"), + self._title(document, provider, uuid, "Kit Test"), + ) + + def test_derived_placeholder_is_never_adopted_but_missing_source_is(self) -> None: + uuid = uuid_for(312) + key = f"claude:{uuid}" + for source, expected in (("derived", ""), ("", "v2-5e")): + with self.subTest(source=source or "missing"): + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + base.chmod(0o700) + _home, config, env = self._sandbox(base) + self._seed( + config, + { + "schema_version": 1, + "aliases": {key: "Session Kit Closeout"}, + "automatic_titles": {key: "Session Kit Closeout"}, + "pushed_titles": {key: "Session Kit Closeout"}, + }, + ) + adopted = inventory_core.adopt_native_rename( + "claude", + uuid, + "v2-5e", + native_name_source=source, + environ=env, + ) + self.assertEqual(expected, adopted) + document = self._document(config) + if source == "derived": + self.assertEqual("Session Kit Closeout", document["aliases"][key]) + else: + self.assertEqual("v2-5e", document["aliases"][key]) + + def test_pre_push_native_value_is_pending_but_a_third_value_is_human(self) -> None: + uuid = uuid_for(317) + key = f"claude:{uuid}" + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + base.chmod(0o700) + _home, config, env = self._sandbox(base) + self._seed( + config, + { + "schema_version": 1, + "aliases": {key: "New Kit Name"}, + "automatic_titles": {key: "New Kit Name"}, + "pushed_titles": {key: "New Kit Name"}, + "pending_native_titles": { + key: { + "title": "Old Registry Name", + "nameSince": 100, + "nameSource": "", + } + }, + }, + ) + self.assertEqual( + "", + inventory_core.adopt_native_rename( + "claude", + uuid, + "Old Registry Name", + native_name_since=100, + environ=env, + ), + ) + self.assertEqual( + "A Third Human Name", + inventory_core.adopt_native_rename( + "claude", + uuid, + "A Third Human Name", + native_name_since=200, + environ=env, + ), + ) + document = self._document(config) + self.assertEqual("A Third Human Name", document["aliases"][key]) + self.assertNotIn(key, document.get("pending_native_titles", {})) + + def test_catch_up_then_rename_back_to_old_text_is_adopted_immediately(self) -> None: + """Exact blocking lane sequence: push, catch up, then rename back.""" + uuid = uuid_for(318) + key = f"claude:{uuid}" + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + base.chmod(0o700) + _home, config, env = self._sandbox(base) + self._seed( + config, + { + "schema_version": 1, + "aliases": {key: "New Kit Name"}, + "automatic_titles": {key: "New Kit Name"}, + "pushed_titles": {key: "New Kit Name"}, + "pending_native_titles": { + key: { + "title": "Old Human Name", + "nameSince": 100, + "nameSource": "user", + } + }, + }, + ) + + self.assertEqual( + "", + inventory_core.adopt_native_rename( + "claude", + uuid, + "New Kit Name", + native_name_source="", + native_name_since=100, + environ=env, + ), + ) + self.assertNotIn( + key, self._document(config).get("pending_native_titles", {}) + ) + + self.assertEqual( + "Old Human Name", + inventory_core.adopt_native_rename( + "claude", + uuid, + "Old Human Name", + native_name_source="user", + native_name_since=200, + environ=env, + ), + ) + document = self._document(config) + self.assertEqual("Old Human Name", document["aliases"][key]) + self.assertEqual("human", document["name_ownership"][key]["owner"]) + + def test_a_native_rename_after_sp_name_replaces_it_for_good(self) -> None: + """`sp name` is not a shield. The newest human act is the name. + + No timestamp is needed: `pushed_titles` holds the last value the kit + wrote into the provider's store, and `sp name` writes through that + same push. A native store that disagrees with it can only have been + typed afterwards, so the divergence itself dates the rename. + """ + for provider, number in (("claude", 315), ("codex", 316)): + with self.subTest(provider=provider): + uuid = uuid_for(number) + key = f"{provider}:{uuid}" + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + base.chmod(0o700) + home, config, env = self._sandbox(base) + # Exactly what `sp name Old Name` leaves behind: the alias, + # human ownership, and the value it pushed to the provider. + self._seed( + config, + { + "schema_version": 1, + "aliases": {key: "Old Name"}, + "pushed_titles": {key: "Old Name"}, + "name_ownership": { + key: {"owner": "human", "at": "2026-08-09T00:00:00Z"} + }, + }, + ) + # The row shows it before anything is written. + self.assertEqual( + ("New Name", "native"), + self._title(self._document(config), provider, uuid, "New Name"), + ) + self.assertEqual( + "New Name", + inventory_core.adopt_native_rename( + provider, uuid, "New Name", environ=env + ), + ) + document = self._document(config) + self.assertEqual("New Name", document["aliases"][key]) + self.assertEqual("New Name", document["pushed_titles"][key]) + self.assertEqual( + "human", document["name_ownership"][key]["owner"] + ) + self.assertEqual( + ("New Name", "alias"), + self._title(document, provider, uuid, "New Name"), + ) + # Ownership stays human; the stale value cannot come back. + self.assertEqual( + "human", + inventory_core.name_owner(provider, uuid, environ=env), + ) + self.assertIn( + "already owns", + inventory_core.claim_automatic_name( + provider, uuid, environ=env + ), + ) + self.assertEqual( + "", + inventory_core.adopt_native_rename( + provider, uuid, "New Name", environ=env + ), + ) + self.assertNotIn( + "Old Name", json.dumps(self._document(config)) + ) + + def test_a_capitalisation_only_rename_sticks_byte_exact(self) -> None: + """Changing only the capitals is still a person renaming their work. + + The kit has no standing to rule that it did not count, and no reason + to believe a store that disagrees about case is the store echoing + itself. Exact comparison, exact storage, exact display. + """ + uuid = uuid_for(314) + key = f"codex:{uuid}" + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + base.chmod(0o700) + home, config, env = self._sandbox(base) + self._seed( + config, + { + "schema_version": 1, + "aliases": {}, + "automatic_titles": {key: "Session Kit Audit"}, + "pushed_titles": {key: "Session Kit Audit"}, + }, + ) + adopted = inventory_core.adopt_native_rename( + "codex", uuid, "session kit audit", environ=env + ) + self.assertEqual("session kit audit", adopted) + document = self._document(config) + self.assertEqual("session kit audit", document["aliases"][key]) + self.assertEqual("session kit audit", document["pushed_titles"][key]) + self.assertEqual("human", document["name_ownership"][key]["owner"]) + self.assertNotIn(key, document.get("automatic_titles", {})) + self.assertEqual( + ("session kit audit", "alias"), + self._title(document, "codex", uuid, "session kit audit"), + ) + self.assertEqual( + "human", inventory_core.name_owner("codex", uuid, environ=env) + ) + # And the automatic tier can never put the capitals back. + self.assertIn( + "already owns", + inventory_core.claim_automatic_name("codex", uuid, environ=env), + ) + + def test_a_thread_the_kit_never_named_keeps_its_ordinary_precedence(self) -> None: + """No kit value means no rename to detect: a seeded prompt is not one.""" + uuid = uuid_for(313) + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + base.chmod(0o700) + home, config, env = self._sandbox(base) + self._seed(config, {"schema_version": 1, "aliases": {}}) + self.assertEqual( + "", + inventory_core.adopt_native_rename( + "codex", uuid, "trace the cdn purge failure", environ=env + ), + ) + self.assertEqual({}, self._document(config).get("name_ownership", {})) + + +class AccountProfileReadersTests(unittest.TestCase): + """A session on an enrolled account keeps its transcript in that + account's profile, not under ``~/.claude``. Three readers answered this + question and only one of them had been widened, so the window's own name + and every queued title retry stayed blind to account sessions.""" + + def profile(self, home: Path, alias: str) -> Path: + profile = ( + home / ".local" / "share" / "session-kit" / "accounts" / "claude" / alias + ) + (profile / "projects" / "-srv-project").mkdir(parents=True) + return profile + + def test_a_bounce_is_requested_for_a_session_on_an_account(self) -> None: + """"" with clear=False means "defer, a name may still arrive", so the + picker never asked for the one safe provider bounce and the window + title stayed stale until the person typed something. That is the + second half of "it didn't rename the session".""" + import datetime as dt + + uuid = uuid_for(120) + with tempfile.TemporaryDirectory() as raw: + home = Path(raw) / "home" + sessions = home / ".claude" / "sessions" + sessions.mkdir(parents=True) + (home / ".claude" / "projects").mkdir(parents=True) + intent = sessions / f"{uuid}.nameintent" + intent.write_text("Count The Great Lakes\n", encoding="utf-8") + stamp = ( + dt.datetime.fromtimestamp( + intent.stat().st_mtime - 120, dt.timezone.utc + ) + .isoformat() + .replace("+00:00", "Z") + ) + profile = self.profile(home, "fixture") + (profile / "projects" / "-srv-project" / f"{uuid}.jsonl").write_text( + json.dumps( + { + "type": "user", + "message": {"role": "user", "content": "who plays tonight"}, + "timestamp": stamp, + } + ) + + "\n", + encoding="utf-8", + ) + + self.assertEqual( + ("Count The Great Lakes", False), + inventory_core.claude_bounce_prepare(uuid, home=home), + ) + + def test_a_title_retry_is_judged_for_a_session_on_an_account(self) -> None: + """Every queued retry answered "defer" on every pass for an account + session, so a title push that failed once was never retried and its + entry never left the ledger.""" + names = sys.modules["sessionkit_inventory.names"] + uuid = uuid_for(121) + with tempfile.TemporaryDirectory() as raw: + home = Path(raw) / "home" + (home / ".claude" / "projects").mkdir(parents=True) + profile = self.profile(home, "fixture") + (profile / "projects" / "-srv-project" / f"{uuid}.jsonl").write_text( + json.dumps({"type": "user", "sessionId": uuid}) + "\n", + encoding="utf-8", + ) + + verdict = names._provider_title_retry_disposition( + "claude", + uuid, + "Some Title", + {"HOME": os.fspath(home)}, + codex_home=lambda *a, **k: home / ".codex", + codex_title_echoes_prompt=lambda *a, **k: False, + home_resolver=lambda: home, + transcript_signals=inventory_core.read_claude_transcript_signals, + read_session_index=lambda *a, **k: {}, + ) + + self.assertEqual("retry", verdict) + + +class TwoProfilesHoldOneConversationTests(unittest.TestCase): + """An account switch leaves the same conversation id in two profiles. + + Which one describes the session has to be decided by evidence. Sorting + the roots by alias and letting "the last record win" handed the answer to + whichever profile sorted last, so a stale copy could out-rank the file the + live session is writing -- and an unrecognised name from a stale copy is + adopted as a person's rename, which wins for good. + """ + + def profile(self, home: Path, alias: str) -> Path: + profile = ( + home / ".local" / "share" / "session-kit" / "accounts" / "claude" / alias + ) + (profile / "projects" / "-srv-project").mkdir(parents=True) + return profile + + def write(self, path: Path, uuid: str, title: str, color: str) -> None: + path.write_text( + json.dumps({"type": "ai-title", "aiTitle": title, "sessionId": uuid}) + + "\n" + + json.dumps( + {"type": "agent-color", "agentColor": color, "sessionId": uuid} + ) + + "\n", + encoding="utf-8", + ) + + def test_the_newest_copy_wins_not_the_alphabetically_last(self) -> None: + uuid = uuid_for(122) + with tempfile.TemporaryDirectory() as raw: + home = Path(raw) / "home" + (home / ".claude" / "projects").mkdir(parents=True) + live = ( + self.profile(home, "aaa-live") + / "projects" + / "-srv-project" + / f"{uuid}.jsonl" + ) + stale = ( + self.profile(home, "zzz-stale") + / "projects" + / "-srv-project" + / f"{uuid}.jsonl" + ) + self.write(live, uuid, "The live title", "cyan") + self.write(stale, uuid, "A stale copy", "red") + old = time.time() - 86_400 + os.utime(stale, (old, old)) + + signals = inventory_core.read_claude_transcript_signals(uuid, home) + + self.assertEqual("The live title", signals["ai_title"]) + self.assertEqual("cyan", signals["agent_color"]) + + def test_a_fifth_profile_cannot_hide_the_current_one(self) -> None: + """The read stops after a few files. Taken in alias order that cut-off + could exclude the profile actually in use; taken newest-first it + cannot.""" + uuid = uuid_for(123) + with tempfile.TemporaryDirectory() as raw: + home = Path(raw) / "home" + (home / ".claude" / "projects").mkdir(parents=True) + old = time.time() - 86_400 + for alias in ("aaa", "bbb", "ccc", "ddd"): + path = ( + self.profile(home, alias) + / "projects" + / "-srv-project" + / f"{uuid}.jsonl" + ) + path.write_text( + json.dumps({"type": "user", "sessionId": uuid}) + "\n", + encoding="utf-8", + ) + os.utime(path, (old, old)) + current = ( + self.profile(home, "zzz-current") + / "projects" + / "-srv-project" + / f"{uuid}.jsonl" + ) + self.write(current, uuid, "The fifth is current", "green") + + signals = inventory_core.read_claude_transcript_signals(uuid, home) + + self.assertEqual("The fifth is current", signals["ai_title"]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_pty_child_exit.py b/tests/test_pty_child_exit.py new file mode 100644 index 0000000..0d68166 --- /dev/null +++ b/tests/test_pty_child_exit.py @@ -0,0 +1,122 @@ +"""A forked test child dies when its exec fails, instead of running the suite. + +This is the bug that took a machine down on 2026-08-11, not a hypothetical. +Five tests fork a pty and immediately exec a shell or `sp` in the child. None +of them guarded that child, so a failing `execve` -- EAGAIN or ENOMEM under +process pressure, ENOENT if a fixture path moved -- let the exception unwind +into the harness the child was forked from. The child does not die there: it +carries on as a second copy of the test runner and forks again at the next pty +test. Under exactly the pressure that causes the first failure, that is +exponential, and it produced roughly 900 python processes and a load average of +684 on a production host before anybody read a traceback. + +Two tests, because the failure has two shapes: + +* the static one, which fails when someone adds a sixth unguarded `pty.fork()` + a year from now and nothing else notices; +* the live one, which forks a real child at a path that cannot be executed and + proves it exits rather than returning. + +The exit status is 127 -- the conventional "exec failed" status -- so a child +that hit the guard can never be confused with a legitimate `1` from `sp` or the +login script, several of which these harnesses already assert on. +""" + +from __future__ import annotations + +import ast +import os +import pty +import unittest + +from tests.support import REPO + + +EXEC_FAILED = 127 + + +def fork_sites(tree: ast.Module) -> list[ast.If]: + """Every `if pid == 0:` guarding a child, by the fork call above it.""" + children: list[ast.If] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + call = node.value + if ( + not isinstance(call, ast.Call) + or not isinstance(call.func, ast.Attribute) + or call.func.attr != "fork" + or not isinstance(call.func.value, ast.Name) + or call.func.value.id != "pty" + ): + continue + parent = getattr(node, "parent", None) + body = getattr(parent, "body", None) or [] + index = body.index(node) if node in body else -1 + following = body[index + 1] if 0 <= index < len(body) - 1 else None + if isinstance(following, ast.If): + children.append(following) + return children + + +def annotate(tree: ast.Module) -> ast.Module: + for parent in ast.walk(tree): + for child in ast.iter_child_nodes(parent): + child.parent = parent # type: ignore[attr-defined] + return tree + + +def exits_the_child(block: ast.If) -> bool: + """Whether every path out of the child branch calls os._exit.""" + for node in ast.walk(block): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "_exit" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "os" + ): + handler = getattr(node, "parent", None) + while handler is not None and not isinstance(handler, ast.Try): + handler = getattr(handler, "parent", None) + if handler is not None: + return True + return False + + +class ForkedChildGuardTests(unittest.TestCase): + def test_every_pty_child_in_the_suite_exits_on_a_failed_exec(self) -> None: + unguarded: list[str] = [] + for path in sorted((REPO / "tests").glob("test_*.py")): + tree = annotate(ast.parse(path.read_text(encoding="utf-8"), filename=str(path))) + for block in fork_sites(tree): + if not exits_the_child(block): + unguarded.append(f"{path.name}:{block.lineno}") + self.assertEqual( + [], + unguarded, + "these pty.fork() children can return into the test runner and fork " + f"again: {unguarded}. Wrap the child body in try/finally with " + "os._exit(127).", + ) + + def test_a_child_whose_exec_fails_dies_with_the_exec_failed_status(self) -> None: + """The live half: fork for real, fail the exec for real, reap the child.""" + missing = REPO / "tests" / "no-such-executable-for-this-test" + self.assertFalse(missing.exists()) + pid, descriptor = pty.fork() + if pid == 0: # pragma: no cover - this branch never returns + try: + os.execv(os.fspath(missing), [os.fspath(missing)]) + finally: + os._exit(EXEC_FAILED) + try: + _, status = os.waitpid(pid, 0) + finally: + os.close(descriptor) + self.assertTrue(os.WIFEXITED(status), status) + self.assertEqual(EXEC_FAILED, os.WEXITSTATUS(status)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_public_export.py b/tests/test_public_export.py index cea2dc4..f0d296c 100644 --- a/tests/test_public_export.py +++ b/tests/test_public_export.py @@ -7,24 +7,21 @@ import tempfile import unittest -from tests.support import REPO +from tests.support import REPO, source_tree_ignore class PublicExportTests(unittest.TestCase): + def test_third_party_notice_names_every_shipped_shpool_patch(self) -> None: + notice = (REPO / "THIRD_PARTY_NOTICES").read_text(encoding="utf-8") + patches = sorted((REPO / "shpool-patch").glob("[0-9][0-9][0-9][0-9]-*.patch")) + self.assertEqual(6, len(patches)) + for patch in patches: + self.assertIn(f"shpool-patch/{patch.name}", notice) + self.assertNotIn("Maniple", notice) + def make_source(self, root: Path) -> tuple[Path, str]: source = root / "source" - shutil.copytree( - REPO, - source, - ignore=shutil.ignore_patterns( - ".git", - ".claude", - ".mypy_cache", - ".ruff_cache", - "__pycache__", - "*.pyc", - ), - ) + shutil.copytree(REPO, source, ignore=source_tree_ignore()) subprocess.run(["git", "init", "-q", source], check=True) subprocess.run( ["git", "-C", source, "config", "user.name", "Session Kit Tests"], @@ -80,6 +77,7 @@ def export( def test_export_contains_runtime_release_and_provenance_inputs(self) -> None: with tempfile.TemporaryDirectory( + ignore_cleanup_errors=True, prefix="session-kit-public-export.", dir=REPO.parent, ) as temporary: @@ -92,8 +90,11 @@ def test_export_contains_runtime_release_and_provenance_inputs(self) -> None: self.assertEqual(result.returncode, 0, result.stdout + result.stderr) for relative in ( "LICENSES/Apache-2.0.txt", + "bin/reset-collection-order.py", "deploy/session-kit-release", + "extras/statusline-quota-refresh.example", "lib/sessionkit_inventory/lifecycle.py", + "lib/sessionkit_inventory/projects.py", "lib/sessionkit_inventory/providers.py", "lib/sessionkit_inventory/reaper.py", "lib/sessionkit_inventory/state_io.py", @@ -102,6 +103,7 @@ def test_export_contains_runtime_release_and_provenance_inputs(self) -> None: "tests/support.py", "tests/test_lifecycle_shell.py", "tests/test_macos_preview.py", + "tests/test_projects.py", "tests/test_reaper_autoclose.py", "tests/test_release.py", "tools/build-public-tree", @@ -109,6 +111,12 @@ def test_export_contains_runtime_release_and_provenance_inputs(self) -> None: "tools/check-doc-links", ): self.assertTrue((destination / relative).is_file(), relative) + for relative in ( + "LICENSES/MIT-maniple.txt", + "docs/build-tracks-2026-08-12.md", + "tools/reset-collection-order.py", + ): + self.assertFalse((destination / relative).exists(), relative) source_record = json.loads( (destination / "SOURCE.json").read_text(encoding="utf-8") ) @@ -118,6 +126,7 @@ def test_export_contains_runtime_release_and_provenance_inputs(self) -> None: def test_exported_tree_can_export_itself(self) -> None: with tempfile.TemporaryDirectory( + ignore_cleanup_errors=True, prefix="session-kit-public-export.", dir=REPO.parent, ) as temporary: @@ -184,6 +193,7 @@ def test_exported_tree_can_export_itself(self) -> None: def test_export_uses_manifest_from_requested_commit(self) -> None: with tempfile.TemporaryDirectory( + ignore_cleanup_errors=True, prefix="session-kit-public-export.", dir=REPO.parent, ) as temporary: @@ -200,6 +210,7 @@ def test_export_uses_manifest_from_requested_commit(self) -> None: def test_export_rejects_partial_inventory_package(self) -> None: with tempfile.TemporaryDirectory( + ignore_cleanup_errors=True, prefix="session-kit-public-export.", dir=REPO.parent, ) as temporary: @@ -225,6 +236,7 @@ def test_export_rejects_partial_inventory_package(self) -> None: def test_export_rejects_manifest_pattern_that_matches_nothing(self) -> None: with tempfile.TemporaryDirectory( + ignore_cleanup_errors=True, prefix="session-kit-public-export.", dir=REPO.parent, ) as temporary: @@ -243,6 +255,7 @@ def test_export_rejects_manifest_pattern_that_matches_nothing(self) -> None: def test_export_rejects_private_marker_without_echoing_it(self) -> None: with tempfile.TemporaryDirectory( + ignore_cleanup_errors=True, prefix="session-kit-public-export.", dir=REPO.parent, ) as temporary: diff --git a/tests/test_public_scan.py b/tests/test_public_scan.py index 68a1c6a..1ccf555 100644 --- a/tests/test_public_scan.py +++ b/tests/test_public_scan.py @@ -9,6 +9,11 @@ REPO = Path(__file__).resolve().parents[1] SCANNER = REPO / "tools" / "public-scan" +# One shipped, approved binary, used as the fixture for every digest case +# below. It has to be a file the scanner really approves at the path it +# really lives at, so it moves whenever the artwork does. +APPROVED_IMAGE = REPO / "docs" / "assets" / "readme" / "picker.png" +APPROVED_IMAGE_PATH = "docs/assets/readme/picker.png" class PublicScanTests(unittest.TestCase): @@ -16,10 +21,29 @@ def setUp(self) -> None: self.root = Path( tempfile.mkdtemp(prefix="session-kit-public-scan.", dir=REPO.parent) ) + # Auxiliary files live outside the scanned root so a baseline fixture + # never becomes part of the tree or history under test. + self.workspace = Path( + tempfile.mkdtemp(prefix="session-kit-public-scan-aux.", dir=REPO.parent) + ) (self.root / "README.md").write_text("public fixture\n", encoding="utf-8") def tearDown(self) -> None: shutil.rmtree(self.root) + shutil.rmtree(self.workspace) + + def write_baseline(self, body: str, name: str = "baseline") -> Path: + path = self.workspace / name + path.write_text(body, encoding="utf-8") + return path + + def blob_id(self, relative: str) -> str: + return subprocess.run( + ["git", "-C", str(self.root), "hash-object", relative], + check=True, + stdout=subprocess.PIPE, + text=True, + ).stdout.strip() def run_scan(self, *args: str) -> subprocess.CompletedProcess[str]: return subprocess.run( @@ -53,6 +77,12 @@ def commit(self, message: str) -> None: subprocess.run(["git", "-C", self.root, "add", "."], check=True) subprocess.run(["git", "-C", self.root, "commit", "-qm", message], check=True) + def copy_approved_image(self, relative: str = APPROVED_IMAGE_PATH) -> Path: + target = self.root / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(APPROVED_IMAGE, target) + return target + def test_clean_tree_ignores_tool_and_git_caches(self) -> None: secret = "ghp_" + ("a" * 20) for relative in ( @@ -129,6 +159,288 @@ def test_directory_symlink_is_rejected(self) -> None: self.assertNotEqual(result.returncode, 0) self.assertIn("linked-directory: symlink is not allowed", result.stdout) + def test_approved_binary_is_accepted(self) -> None: + self.copy_approved_image() + + result = self.run_scan() + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_approved_binary_with_changed_digest_is_rejected(self) -> None: + approved = self.copy_approved_image() + payload = bytearray(approved.read_bytes()) + payload[-1] ^= 1 + approved.write_bytes(payload) + + result = self.run_scan() + + self.assertNotEqual(result.returncode, 0) + self.assertIn("approved binary digest mismatch", result.stdout) + + def test_approved_bytes_at_another_path_are_rejected(self) -> None: + self.copy_approved_image("docs/assets/other.png") + + result = self.run_scan() + + self.assertNotEqual(result.returncode, 0) + self.assertIn("binary file is not allowed", result.stdout) + + def test_other_binary_is_rejected(self) -> None: + (self.root / "other.bin").write_bytes(b"fixture\0binary") + + result = self.run_scan() + + self.assertNotEqual(result.returncode, 0) + self.assertIn("binary file is not allowed", result.stdout) + + def test_history_rejects_superseded_digest(self) -> None: + self.init_git() + approved = self.copy_approved_image() + payload = bytearray(approved.read_bytes()) + payload[-1] ^= 1 + approved.write_bytes(payload) + self.commit("add altered image") + self.copy_approved_image() + self.commit("restore approved image") + + tree_only = self.run_scan() + history = self.run_scan("--git-history") + + self.assertEqual(tree_only.returncode, 0, tree_only.stdout + tree_only.stderr) + self.assertNotEqual(history.returncode, 0) + self.assertIn("approved binary digest mismatch", history.stdout) + + def test_history_rejects_removed_other_binary(self) -> None: + self.init_git() + other = self.root / "other.bin" + other.write_bytes(b"fixture\0binary") + self.commit("add other binary") + other.unlink() + self.commit("remove other binary") + + tree_only = self.run_scan() + history = self.run_scan("--git-history") + + self.assertEqual(tree_only.returncode, 0, tree_only.stdout + tree_only.stderr) + self.assertNotEqual(history.returncode, 0) + self.assertIn("binary file is not allowed", history.stdout) + + def test_baseline_excuses_only_the_exact_blob_it_names(self) -> None: + marker = "restricted" + "fixture" + self.init_git() + (self.root / "reviewed.txt").write_text(marker + "\n", encoding="utf-8") + (self.root / "novel.txt").write_text(marker + " two\n", encoding="utf-8") + reviewed = self.blob_id("reviewed.txt") + novel = self.blob_id("novel.txt") + self.commit("add fixtures") + (self.root / "reviewed.txt").unlink() + (self.root / "novel.txt").unlink() + self.commit("remove fixtures") + baseline = self.write_baseline( + f"# reviewed on a date\n{reviewed} reviewed.txt\n" + ) + + unlisted = self.run_scan("--git-history", "--private-markers") + listed = self.run_scan( + "--git-history", "--private-markers", "--baseline", str(baseline) + ) + + self.assertNotEqual(unlisted.returncode, 0) + self.assertIn(reviewed[:12], unlisted.stdout) + self.assertIn(novel[:12], unlisted.stdout) + # The reviewed blob is forgiven; the one nobody reviewed still fails. + self.assertNotEqual(listed.returncode, 0) + self.assertNotIn(reviewed[:12], listed.stdout) + self.assertIn(novel[:12], listed.stdout) + self.assertNotIn(marker, listed.stdout) + + def test_baseline_scan_passes_when_every_flagged_blob_is_reviewed(self) -> None: + marker = "restricted" + "fixture" + self.init_git() + (self.root / "reviewed.txt").write_text(marker + "\n", encoding="utf-8") + reviewed = self.blob_id("reviewed.txt") + self.commit("add fixture") + (self.root / "reviewed.txt").unlink() + self.commit("remove fixture") + baseline = self.write_baseline(f"{reviewed} reviewed.txt\n") + + result = self.run_scan( + "--git-history", "--private-markers", "--baseline", str(baseline) + ) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("1 of 1 baselined history object(s) excused", result.stdout) + + def test_baseline_never_excuses_a_secret(self) -> None: + marker = "restricted" + "fixture" + secret = "ghp_" + ("b" * 20) + self.init_git() + (self.root / "both.txt").write_text(f"{marker}\n{secret}\n", encoding="utf-8") + both = self.blob_id("both.txt") + self.commit("add fixture") + (self.root / "both.txt").unlink() + self.commit("remove fixture") + baseline = self.write_baseline(f"{both} both.txt\n") + + result = self.run_scan( + "--git-history", "--private-markers", "--baseline", str(baseline) + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("GitHub token", result.stdout) + self.assertNotIn(secret, result.stdout) + + def test_baseline_does_not_reach_the_working_tree(self) -> None: + """Forgiving a published blob must not let the same bytes live on.""" + marker = "restricted" + "fixture" + self.init_git() + (self.root / "kept.txt").write_text(marker + "\n", encoding="utf-8") + kept = self.blob_id("kept.txt") + self.commit("add fixture") + baseline = self.write_baseline(f"{kept} kept.txt\n") + + result = self.run_scan( + "--git-history", "--private-markers", "--baseline", str(baseline) + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("kept.txt: private marker", result.stdout) + + def test_baseline_requires_the_history_scan(self) -> None: + baseline = self.write_baseline(f"{'a' * 40} somewhere.txt\n") + + result = self.run_scan("--private-markers", "--baseline", str(baseline)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("--baseline applies to --git-history scans", result.stderr) + + def test_unusable_baseline_fails_closed(self) -> None: + blob = "a" * 40 + cases = { + "missing": (None, "baseline is unreadable"), + "empty": ("# nothing but a comment\n", "baseline lists no objects"), + "malformed": ("not-a-blob-id somewhere.txt\n", "malformed baseline line"), + "short": (f"{blob[:39]} somewhere.txt\n", "malformed baseline line"), + "uppercase": ( + f"{blob.upper()} somewhere.txt\n", + "malformed baseline line", + ), + "duplicate": ( + f"{blob} one.txt\n{blob} two.txt\n", + "duplicate baseline object", + ), + } + for name, (body, reason) in cases.items(): + with self.subTest(baseline=name): + if body is None: + path = self.workspace / "absent" + else: + path = self.write_baseline(body, name=name) + + result = self.run_scan( + "--git-history", "--private-markers", "--baseline", str(path) + ) + + # The refusal has to name what is wrong; argparse prints the + # option in its usage line whatever happens, so matching the + # word "baseline" alone would pass for the wrong reason. + self.assertNotEqual(result.returncode, 0) + self.assertIn(reason, result.stderr) + + def test_history_reads_commit_messages(self) -> None: + """A message is published text, so the rules that guard files apply.""" + marker = "restricted" + "fixture" + self.init_git() + (self.root / "ordinary.txt").write_text("nothing notable\n", encoding="utf-8") + self.commit(f"mention {marker} in the subject") + + tree_only = self.run_scan("--private-markers") + history = self.run_scan("--git-history", "--private-markers") + + # The tracked file is clean, so only the message can be the difference. + self.assertEqual(tree_only.returncode, 0, tree_only.stdout + tree_only.stderr) + self.assertNotEqual(history.returncode, 0) + self.assertIn("message: private marker", history.stdout) + self.assertNotIn(marker, history.stdout) + + def test_history_reads_a_secret_in_a_commit_message(self) -> None: + secret = "ghp_" + ("c" * 20) + self.init_git() + (self.root / "ordinary.txt").write_text("nothing notable\n", encoding="utf-8") + self.commit(f"paste {secret} where nobody looked") + + result = self.run_scan("--git-history") + + self.assertNotEqual(result.returncode, 0) + self.assertIn("GitHub token", result.stdout) + # Naming the secret in the refusal would publish it a second time. + self.assertNotIn(secret, result.stdout) + + def test_baseline_excuses_a_reviewed_commit_message(self) -> None: + """Published messages cannot be edited, so review is the only remedy.""" + marker = "restricted" + "fixture" + self.init_git() + (self.root / "ordinary.txt").write_text("nothing notable\n", encoding="utf-8") + self.commit(f"mention {marker} in the subject") + reviewed = subprocess.run( + ["git", "-C", str(self.root), "rev-parse", "HEAD"], + check=True, + stdout=subprocess.PIPE, + text=True, + ).stdout.strip() + baseline = self.write_baseline(f"{reviewed} reviewed message\n") + + result = self.run_scan( + "--git-history", "--private-markers", "--baseline", str(baseline) + ) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("1 of 1 baselined history object(s) excused", result.stdout) + + def test_baseline_never_excuses_a_secret_in_a_commit_message(self) -> None: + marker = "restricted" + "fixture" + secret = "ghp_" + ("d" * 20) + self.init_git() + (self.root / "ordinary.txt").write_text("nothing notable\n", encoding="utf-8") + self.commit(f"mention {marker} and paste {secret}") + reviewed = subprocess.run( + ["git", "-C", str(self.root), "rev-parse", "HEAD"], + check=True, + stdout=subprocess.PIPE, + text=True, + ).stdout.strip() + baseline = self.write_baseline(f"{reviewed} reviewed message\n") + + result = self.run_scan( + "--git-history", "--private-markers", "--baseline", str(baseline) + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("GitHub token", result.stdout) + self.assertNotIn(secret, result.stdout) + + def test_shipped_baseline_is_parsable_and_additive_only(self) -> None: + """The committed baseline must survive the parser it is written for.""" + shipped = REPO / "tools" / "public-scan-history-baseline" + self.init_git() + self.commit("empty history") + + result = self.run_scan( + "--git-history", "--private-markers", "--baseline", str(shipped) + ) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + # None of the shipped blobs exist in this fixture's history, and the + # summary has to say so rather than claim the whole list was used. + self.assertIn("0 of 38 baselined history object(s) excused", result.stdout) + entries = [ + line.split("#", 1)[0].strip() + for line in shipped.read_text(encoding="utf-8").splitlines() + ] + blobs = [entry.split()[0] for entry in entries if entry] + self.assertEqual(sorted(set(blobs)), sorted(blobs)) + self.assertTrue(blobs) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_readme_art.py b/tests/test_readme_art.py new file mode 100644 index 0000000..15330d2 --- /dev/null +++ b/tests/test_readme_art.py @@ -0,0 +1,216 @@ +"""Guards for the README's generated artwork. + +The pictures on the front page are rendered from live code by the tools under +`tools/`, which means they can go wrong in ways prose cannot: a screen can be +captured with another screen stacked on top of it, two pictures can describe +inventories that contradict each other, and a laid-out figure can let its text +escape the box it was drawn in. Each of those has actually happened. Every one +of them is pinned here. + +The rendering tests need a browser, and CI does not have one, so they skip +rather than fail there. The reasoning tests need nothing and always run. +""" + +from __future__ import annotations + +from pathlib import Path +import re +import sys +import unittest + +from tests.support import REPO + +TOOLS = REPO / "tools" +if str(TOOLS) not in sys.path: + sys.path.insert(0, str(TOOLS)) + +ASSETS = REPO / "docs" / "assets" / "readme" + + +def _capture(): + import readme_capture + + return readme_capture + + +class ReferencedArtworkTests(unittest.TestCase): + """The README and the asset directory must describe the same set.""" + + def setUp(self) -> None: + self.readme = (REPO / "README.md").read_text(encoding="utf-8") + + def referenced(self) -> set[str]: + return { + Path(path).name + for path in re.findall(r'src="([^"]+)"', self.readme) + if "assets/readme/" in path + } + + def test_every_referenced_picture_exists(self) -> None: + for name in sorted(self.referenced()): + with self.subTest(name): + self.assertTrue((ASSETS / name).is_file(), f"{name} is not on disk") + + def test_no_orphan_pictures_ship(self) -> None: + """An asset nothing points at is dead weight in the release archive. + + The social preview is uploaded through GitHub's settings rather than + referenced from the page, so it is named here on purpose. + """ + + on_disk = {path.name for path in ASSETS.glob("*.png")} + self.assertEqual(set(), on_disk - self.referenced() - {"social-preview.png"}) + + def test_the_readme_still_shows_the_picker(self) -> None: + self.assertIn("picker.png", self.referenced()) + + +class ScreenSeparationTests(unittest.TestCase): + """One captured frame can hold two screens. Neither may pollute the other.""" + + LIST = ( + " 7 sessions · 5 ready · 2 open elsewhere\n" + " Ready\n" + " 53 Review Release Notes | CLD | personal | Opus 5 | question | 11m\n" + " 57 Fix Login Timeout | CLD | personal | Opus 5 | needs you | 6m\n" + " 60 Document Release | CLD | personal | Opus 5 | working | 9m\n" + "\n" + " ↵ open 53 · # · kill k # · new n · more m · needs you a · help ? · " + "history h # · quit q or b\n" + " ❯ " + ) + NEEDS = ( + "\n needs you: 2\n\n" + " Sessions\n" + " 53 Review Release Notes | Claude | question | needs you 11 min\n" + " 57 Fix Login Timeout | Claude | needs you 6 min\n" + "\n ↵ back · b back · help ?\n\n" + ) + + def stacked(self) -> str: + """What the picker really writes: leaving `a` repaints the list here.""" + + return self.NEEDS + _capture().REPAINT + " " + self.LIST + + def test_the_list_screen_is_taken_from_after_the_repaint(self) -> None: + capture = _capture() + frame = capture.list_frame([self.stacked()]) + self.assertNotIn("needs you:", capture.plain(frame)) + self.assertIn("kill", capture.plain(frame)) + + def test_the_needs_screen_is_taken_from_before_the_repaint(self) -> None: + capture = _capture() + frame = capture.needs_frame([self.stacked()]) + self.assertNotIn("7 sessions", capture.plain(frame)) + self.assertIn("needs you: 2", capture.plain(frame)) + + def test_the_needs_screen_keeps_its_prompt(self) -> None: + """The repaint overwrites it, and a terminal picture needs one.""" + + capture = _capture() + self.assertTrue( + capture.plain(capture.needs_frame([self.stacked()])).rstrip().endswith("❯") + ) + + def test_a_capture_with_no_needs_screen_is_refused(self) -> None: + with self.assertRaises(RuntimeError): + _capture().needs_frame([self.LIST]) + + def test_a_capture_with_no_list_screen_is_refused(self) -> None: + with self.assertRaises(RuntimeError): + _capture().list_frame([self.NEEDS]) + + +class ScreenAgreementTests(unittest.TestCase): + """Two pictures on one page may not contradict each other. + + This is not hypothetical. An ordinary finished Codex turn reports + `agent_status: idle` with `needs_you: false`; the list prints `needs you` + for it because that is what idle maps to, and the needs-you screen leaves + it out because it reads the raw flag. The first capture taken for these + figures showed four waiting sessions on one picture and three on the other. + """ + + LIST = ScreenSeparationTests.LIST + NEEDS = ScreenSeparationTests.NEEDS + + def test_agreeing_screens_pass(self) -> None: + _capture().refuse_disagreeing_screens(self.LIST, self.NEEDS) + + def test_a_missing_session_is_refused(self) -> None: + short = self.NEEDS.replace("needs you: 2", "needs you: 1").replace( + " 57 Fix Login Timeout | Claude | needs you 6 min\n", "" + ) + with self.assertRaises(SystemExit) as refusal: + _capture().refuse_disagreeing_screens(self.LIST, short) + self.assertIn("57", str(refusal.exception)) + + def test_a_wrong_headline_count_is_refused(self) -> None: + """The number and the rows under it are separate evidence.""" + + miscounted = self.NEEDS.replace("needs you: 2", "needs you: 9") + with self.assertRaises(SystemExit): + _capture().refuse_disagreeing_screens(self.LIST, miscounted) + + def test_idle_counts_as_waiting(self) -> None: + """It is a needs-you session gone quiet, not a fifth state.""" + + self.assertIn("idle", _capture().ATTENTION_WORDS) + + def test_a_needs_you_row_is_not_counted_as_a_list_row(self) -> None: + """Both screens use the same row shape; only the list screen counts.""" + + self.assertEqual([], _capture().attention_rows(self.NEEDS)) + + +class LayoutRefusalTests(unittest.TestCase): + """A figure whose text escapes its box must not be saved.""" + + def setUp(self) -> None: + import readme_art_lib + + self.art = readme_art_lib + if self.art.chromium_path() is None: + self.skipTest("no chromium available for layout checks") + # A browser on the box does not mean a driver in this interpreter, and + # rendering needs both. Checking only for chromium turned a missing + # package into two errors on any environment that has one without the + # other -- a virtualenv built from requirements-dev.txt on a machine + # where chromium is installed system-wide is exactly that, and it read + # as a real failure until the traceback was opened. + try: + import playwright.sync_api # noqa: F401 + except ImportError: + self.skipTest("no playwright driver available for layout checks") + + def test_text_outside_its_box_stops_the_render(self) -> None: + import tempfile + + body = ( + '<div style="width:60px;height:20px;overflow:hidden">' + "a sentence far too long to fit inside sixty pixels</div>" + ) + html = self.art.page(body, "", width=200, height=100) + with tempfile.TemporaryDirectory() as directory: + with self.assertRaises(self.art.RenderError): + self.art.render( + html, Path(directory) / "x.png", width=200, height=100, scale=1 + ) + + def test_a_figure_that_fits_renders(self) -> None: + import tempfile + + html = self.art.page( + '<div style="width:180px;height:40px;overflow:hidden">ok</div>', + "", + width=200, + height=100, + ) + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "x.png" + self.art.render(html, output, width=200, height=100, scale=1) + self.assertTrue(output.is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_readme_keys.py b/tests/test_readme_keys.py new file mode 100644 index 0000000..b4119b0 --- /dev/null +++ b/tests/test_readme_keys.py @@ -0,0 +1,99 @@ +"""The README's key table must name every key the picker's command bar offers. + +`tools/render-readme-picker` renders its terminal from live code, so a feature +merged privately and not yet released appears in the screenshot the moment +someone regenerates it. The guard that catches this is only worth having if it +actually fires: an earlier cut of it silently passed because the frame's colour +escapes sit flush against the keys, and because the README's "`b` or `q`" row +names two keys in one line. Both mistakes are pinned here. +""" + +from __future__ import annotations + +from pathlib import Path +import sys +import unittest + +from tests.support import REPO + +TOOLS = REPO / "tools" +if str(TOOLS) not in sys.path: + sys.path.insert(0, str(TOOLS)) + +import readme_keys # noqa: E402 + +# A footer exactly as the picker emits one, colour escapes included. +G, OFF = "\x1b[1m\x1b[32m", "\x1b[0m" +FOOTER = ( + f" {G}↵{OFF} open 53 · {G}#{OFF} · kill {G}k #{OFF} · new {G}n{OFF} · " + f"more {G}m{OFF} · needs you {G}a{OFF} · help {G}?{OFF} · " + f"history {G}h #{OFF} · leave {G}q or b{OFF}" +) + + +class OfferedKeyTests(unittest.TestCase): + def test_colour_escapes_do_not_hide_a_key(self) -> None: + """The escapes abut the keys, so a naive word boundary finds nothing.""" + + self.assertEqual( + {"k", "n", "m", "a", "?", "h", "q", "b"}, readme_keys.offered_keys(FOOTER) + ) + + def test_a_frame_without_a_command_bar_is_refused(self) -> None: + with self.assertRaises(RuntimeError): + readme_keys.offered_keys("7 sessions · 5 ready\nReady\n") + + +class DocumentedKeyTests(unittest.TestCase): + def test_the_readme_names_every_key_the_picker_offers(self) -> None: + """The shipped README and the shipped footer have to agree.""" + + self.assertLessEqual( + readme_keys.offered_keys(FOOTER), readme_keys.documented_keys() + ) + + def test_a_row_naming_two_keys_yields_both(self) -> None: + """ "`b` or `q`" is one row and two keys.""" + + keys = readme_keys.documented_keys() + self.assertIn("b", keys) + self.assertIn("q", keys) + + def test_the_question_mark_is_a_key(self) -> None: + """It is not a word character, so \\b around it matches nothing.""" + + self.assertIn("?", readme_keys.documented_keys()) + + def test_a_readme_with_no_key_table_is_refused(self) -> None: + """An empty answer here would silently disable every check above.""" + + with self.assertRaises(RuntimeError): + readme_keys.documented_keys(_write_readme("# Session Kit\n\nNo table.\n")) + + +class RefusalTests(unittest.TestCase): + def test_an_undocumented_key_stops_the_render(self) -> None: + # `p` played this part until pinning shipped and the README learned + # it; `z` is nobody's key, so the refusal stays honest. + futured = FOOTER.replace("· leave", f"· zap {G}z #{OFF} · leave") + with self.assertRaises(SystemExit) as refusal: + readme_keys.refuse_undocumented_keys(futured) + self.assertIn("z", str(refusal.exception)) + + def test_a_documented_footer_passes(self) -> None: + readme_keys.refuse_undocumented_keys(FOOTER) + + +def _write_readme(body: str) -> Path: + import tempfile + + handle = tempfile.NamedTemporaryFile( + "w", suffix=".md", delete=False, encoding="utf-8" + ) + handle.write(body) + handle.close() + return Path(handle.name) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reaper_autoclose.py b/tests/test_reaper_autoclose.py index 46b1dc7..aec5264 100644 --- a/tests/test_reaper_autoclose.py +++ b/tests/test_reaper_autoclose.py @@ -9,6 +9,7 @@ import tempfile import time import unittest +from unittest import mock from tests.support import REPO @@ -43,6 +44,10 @@ def fixture() -> tuple[dict, dict, dict]: "source": "live", "stale": False, "warnings": [], + "daemon_generation": { + "pid": 10, + "process_start_ticks": 1_000, + }, "sessions": [ { "shpool_id_raw": SESSION_ID, @@ -85,6 +90,43 @@ def fixture() -> tuple[dict, dict, dict]: class AutoClosePlanningTests(unittest.TestCase): + def test_environment_planner_refuses_daemon_identity_outside_guard(self) -> None: + shpool, inventory, _ = fixture() + inventory["daemon_generation"] = { + "pid": 20, + "process_start_ticks": 2_000, + } + with tempfile.TemporaryDirectory(prefix=".reaper-bound-", dir=REPO) as raw: + root = Path(raw) + state = root / "state" + proc = root / "proc" + state.mkdir() + proc.mkdir() + environment = { + "HOME": os.fspath(root / "home"), + "XDG_STATE_HOME": os.fspath(root / "xdg-state"), + "XDG_DATA_HOME": os.fspath(root / "xdg-data"), + "XDG_CONFIG_HOME": os.fspath(root / "xdg-config"), + "XDG_CACHE_HOME": os.fspath(root / "xdg-cache"), + "XDG_RUNTIME_DIR": os.fspath(root / "xdg-runtime"), + "SESSION_KIT_CONFIG": os.fspath( + root / "xdg-config/session-kit.json" + ), + "SK_REAPER_SHPOOL_JSON": json.dumps(shpool), + "SK_REAPER_INVENTORY_JSON": json.dumps(inventory), + "SK_REAPER_STATE_DIR": os.fspath(state), + "SK_REAPER_PROC_ROOT": os.fspath(proc), + # The retired first-name result must not choose PID 10. + "SK_REAPER_DAEMON_PID": "10", + "SK_REAPER_AUTO_CLOSE_HOURS": "72", + } + with mock.patch.dict(os.environ, environment, clear=False): + with self.assertRaisesRegex( + reaper.CollectionError, + "bound shpool daemon generation changed", + ): + reaper.plan_from_environment(write_state=False) + def test_requires_continuous_exact_proof_for_full_72_hours(self) -> None: shpool, inventory, facts = fixture() first_at = 20 * HOUR_NS @@ -121,6 +163,127 @@ def test_requires_continuous_exact_proof_for_full_72_hours(self) -> None: self.assertEqual(10_000, candidate["shell_start_ticks"]) self.assertEqual(UUID, candidate["provider_uuid"]) + def test_unreadable_daemon_child_environment_refuses_the_whole_scan( + self, + ) -> None: + with tempfile.TemporaryDirectory(prefix="reaper-environ-") as raw: + proc_root = Path(raw) + for pid, ppid, comm in ((10, 1, "shpool"), (100, 10, "bash")): + entry = proc_root / str(pid) + entry.mkdir() + fields = ["S", str(ppid), *(["0"] * 17), str(pid * 100)] + (entry / "stat").write_text( + f"{pid} ({comm}) {' '.join(fields)}\n", encoding="utf-8" + ) + (entry / "comm").write_text(f"{comm}\n", encoding="utf-8") + (entry / "cmdline").write_bytes(b"bash\0") + (entry / "environ").write_bytes(b"SHPOOL_SESSION_NAME=main\0") + # The daemon's own child answers nothing: a live shell that cannot + # name itself must never be read as a session with no shell. + child_environ = proc_root / "100" / "environ" + child_environ.unlink() + child_environ.mkdir() + with self.assertRaises(reaper.CollectionError) as refused: + reaper.scan_shell_facts(proc_root, 10) + self.assertIn("unreadable environment", str(refused.exception)) + + def test_shell_less_phantom_clears_first_pass_and_requires_exact_shape( + self, + ) -> None: + phantom_id = "s20260730-012406-838224" + shpool = { + "sessions": [ + { + "name": phantom_id, + "status": "Disconnected", + "started_at_unix_ms": 1_800_000_000_000, + "last_disconnected_at_unix_ms": 1_800_000_100_000, + } + ] + } + item = { + "shpool_id_raw": phantom_id, + "provider": "unknown", + "mutation_allowed": False, + "mutation_rejection_reason": "missing-shell-generation", + "shpool_shell": None, + "subagents": [], + "identity": {"uuid": None, "pid": None}, + "diagnostics": [ + f"expected one daemon child for {phantom_id!r}, found 0", + "identity candidates: Claude=0, Codex=0", + ], + } + inventory = { + "source": "live", + "stale": False, + "warnings": [], + "sessions": [item], + } + first_at = 20 * HOUR_NS + observations, candidates = reaper.plan_auto_close( + shpool, + inventory, + {}, + None, + now_monotonic_ns=first_at, + minimum_age_ns=72 * HOUR_NS, + ) + # Exact process absence needs no aging window: one complete pass is + # enough, unlike a live empty shell waiting on provider-exit policy. + self.assertEqual(1, len(candidates["candidates"])) + self.assertEqual( + "shell-less-phantom", candidates["candidates"][0]["class"] + ) + self.assertIn(phantom_id, observations["observations"]) + ready = reaper.plan_auto_close( + shpool, + inventory, + {}, + observations, + now_monotonic_ns=first_at + 72 * HOUR_NS, + minimum_age_ns=72 * HOUR_NS, + )[1] + self.assertEqual(1, len(ready["candidates"])) + candidate = ready["candidates"][0] + self.assertEqual("shell-less-phantom", candidate["class"]) + self.assertEqual(phantom_id, candidate["shpool_id"]) + # ANY surviving shell fact, live pid, or different quarantine reason + # disqualifies the phantom shape entirely. + for breaker in ( + lambda: {"facts": {phantom_id: {"shell_pid": 1, "shell_start_ticks": 2, "empty": True}}}, + lambda: {"item": {"identity": {"uuid": None, "pid": 4242}}}, + lambda: {"item": {"mutation_rejection_reason": "outside-shpool"}}, + lambda: {"item": {"mutation_allowed": True}}, + lambda: {"item": {"diagnostics": ["one daemon child present"]}}, + # An unreadable environment means the shell may be alive and + # merely unable to name itself. + lambda: { + "item": { + "diagnostics": [ + f"expected one daemon child for {phantom_id!r}, found 0", + "1 daemon child process(es) have an unreadable " + "environment; session names cannot be proven", + ] + } + }, + lambda: {"item": {"subagents": [{"pid": 7}]}}, + ): + change = breaker() + broken_item = {**item, **change.get("item", {})} + broken_inventory = {**inventory, "sessions": [broken_item]} + result = reaper.plan_auto_close( + shpool, + broken_inventory, + change.get("facts", {}), + observations, + now_monotonic_ns=first_at + 72 * HOUR_NS, + minimum_age_ns=72 * HOUR_NS, + )[1] + self.assertEqual( + [], result["candidates"], msg=f"breaker leaked: {change}" + ) + def test_every_authorized_safety_predicate_fails_closed(self) -> None: cases = { "attached": lambda s, i, f: ( @@ -290,14 +453,27 @@ def setUp(self) -> None: write_executable( self.bin / "shpool", """#!/usr/bin/env python3 -import json,os,pathlib,sys +import json,os,pathlib,shutil,sys state=pathlib.Path(os.environ["FAKE_SHPOOL_STATE"]) value=json.loads(state.read_text()) args=sys.argv[1:] if args == ["list","--json"]: print(json.dumps(value)) raise SystemExit(0) +if args == ["attach","--cmd","/bin/true","--dir","/",os.environ.get("FAKE_TARGET", "")]: + target=args[-1] + before=len(value["sessions"]) + value["sessions"]=[ + row for row in value["sessions"] if row.get("name") != target + ] + if len(value["sessions"]) == before: + raise SystemExit(4) + state.write_text(json.dumps(value)+"\\n") + pathlib.Path(os.environ["FAKE_KILL_LOG"]).write_text("attach-exit "+target+"\\n") + raise SystemExit(0) if len(args) == 2 and args[0] == "kill": + if not pathlib.Path(os.environ["SESSION_KIT_TEST_EXACT_SIGNAL_LOG"]).is_file(): + raise SystemExit(89) before=len(value["sessions"]) value["sessions"]=[ row for row in value["sessions"] if row.get("name") != args[1] @@ -306,6 +482,10 @@ def setUp(self) -> None: raise SystemExit(4) state.write_text(json.dumps(value)+"\\n") pathlib.Path(os.environ["FAKE_KILL_LOG"]).write_text(args[1]+"\\n") + shutil.rmtree( + pathlib.Path(os.environ["SESSION_KIT_PROC_ROOT"])/"100", + ignore_errors=True, + ) raise SystemExit(0) raise SystemExit(2) """, @@ -363,9 +543,14 @@ def environment(self) -> dict[str, str]: "SESSION_KIT_REAPER_SENTINEL": str(self.base / "enabled"), "SESSION_KIT_AUTO_CLOSE_HOURS": "1", "SESSION_KIT_TESTING": "1", + "SESSION_KIT_TEST_EXACT_SIGNAL": "remove", + "SESSION_KIT_TEST_EXACT_SIGNAL_LOG": str( + self.base / "exact-signal.log" + ), "SESSION_KIT_TEST_MONOTONIC_NS": str(TEST_NOW_NS), "FAKE_SHPOOL_STATE": str(self.shpool_state), "FAKE_KILL_LOG": str(self.kill_log), + "FAKE_TARGET": SESSION_ID, "FAKE_INVENTORY": str(self.inventory), "PYTHONDONTWRITEBYTECODE": "1", } @@ -403,10 +588,19 @@ def test_two_fresh_verifications_then_exact_close_and_minimal_log( closed = self.run_reaper() self.assertEqual(0, closed.returncode, closed.stderr) self.assertIn("auto_candidates=1 actions=1", closed.stdout) - self.assertEqual(SESSION_ID + "\n", self.kill_log.read_text()) + self.assertEqual( + f"attach-exit {SESSION_ID}\n", self.kill_log.read_text() + ) + self.assertEqual( + "100\t10000\t15\n", + (self.base / "exact-signal.log").read_text(), + ) self.assertEqual( [], - json.loads(self.shpool_state.read_text())["sessions"], + [ + row["name"] + for row in json.loads(self.shpool_state.read_text())["sessions"] + ], ) action_log = self.state / "action-events.jsonl" self.assertEqual(0o600, stat.S_IMODE(action_log.stat().st_mode)) @@ -419,6 +613,101 @@ def test_two_fresh_verifications_then_exact_close_and_minimal_log( [(item["action"], item["outcome"]) for item in actions], ) + def test_one_pass_clears_a_preexisting_phantom_and_keeps_live_shell(self) -> None: + phantom = "s20260816-040100-77" + shpool = json.loads(self.shpool_state.read_text()) + shpool["sessions"].append( + { + "name": phantom, + "status": "Disconnected", + "started_at_unix_ms": 1_800_000_200_000, + "last_disconnected_at_unix_ms": 1_800_000_300_000, + } + ) + self.shpool_state.write_text(json.dumps(shpool) + "\n") + inventory = json.loads(self.inventory.read_text()) + inventory["sessions"].append( + { + "shpool_id_raw": phantom, + "started_at_unix_ms": 1_800_000_200_000, + "shpool_status": "Disconnected", + "availability": "ready", + "provider": "unknown", + "mutation_allowed": False, + "mutation_rejection_reason": "missing-shell-generation", + "shpool_shell": None, + "subagents": [], + "identity": {"uuid": None, "pid": None}, + "diagnostics": [ + f"expected one daemon child for {phantom!r}, found 0", + "identity candidates: Claude=0, Codex=0", + ], + } + ) + self.inventory.write_text(json.dumps(inventory) + "\n") + environment = self.environment() + environment["FAKE_TARGET"] = phantom + + swept = subprocess.run( + [REAPER, "--auto-close"], + cwd=REPO, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=20, + ) + + self.assertEqual(0, swept.returncode, swept.stderr) + self.assertIn("auto_candidates=1 actions=1", swept.stdout) + remaining = json.loads(self.shpool_state.read_text())["sessions"] + self.assertEqual([SESSION_ID], [row["name"] for row in remaining]) + self.assertTrue((self.proc / "100").is_dir(), "live shell was touched") + self.assertFalse((self.base / "exact-signal.log").exists()) + self.assertEqual(f"attach-exit {phantom}\n", self.kill_log.read_text()) + + def test_reparented_same_session_process_blocks_phantom_sweep(self) -> None: + phantom = "s20260816-040200-78" + self._write_process( + 200, + 1, + "codex", + f"SHPOOL_SESSION_NAME={phantom}\0".encode(), + ) + facts = reaper.scan_shell_facts(self.proc, 10, daemon_start_ticks=1_000) + self.assertEqual( + {"unbound_session_processes": [200]}, facts[phantom] + ) + + def test_scheduled_run_appends_a_versioned_private_file_log_line(self) -> None: + result = self.run_reaper() + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("auto_candidates=0 actions=0", result.stdout) + + log = self.state / "reaper.log" + self.assertEqual(0o600, stat.S_IMODE(log.stat().st_mode)) + lines = log.read_text(encoding="utf-8").splitlines() + self.assertEqual(1, len(lines)) + self.assertRegex( + lines[0], + # The pass with nothing to close still runs the working-copy sweep, + # and a copy it KEPT for unmerged work is the line worth finding + # later, so both counts are on every summary. + r"^schema_version=1 timestamp=\S+ mode=auto " + r"auto_candidates=0 actions=0 " + r"worktrees_released=0 worktrees_kept=0$", + ) + + def test_zero_candidate_summary_survives_a_reaper_log_write_failure(self) -> None: + (self.state / "reaper.log").mkdir() + + result = self.run_reaper() + + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("auto_candidates=0 actions=0", result.stdout) + self.assertIn("reaper log write", result.stderr) + def test_human_input_proof_never_creates_a_candidate_or_kill(self) -> None: inventory = json.loads(self.inventory.read_text(encoding="utf-8")) inventory["sessions"][0]["user_input_after_provider_exit"] = True @@ -454,37 +743,77 @@ def test_dry_run_is_read_only(self) -> None: ) self.assertFalse(self.kill_log.exists()) - def test_old_picker_temp_files_expire_only_without_a_live_picker(self) -> None: + def test_old_picker_temp_files_expire_in_both_spellings(self) -> None: + # mktemp's real form is <label>.json.SUFFIX; the legacy inverted + # spelling is still collected so stragglers from old releases go too. old = self.state / "login-snapshot.ABC123.json" old_guard = self.state / "sp-guard.GHI789.json" old_recovery = self.state / "login-recovery.JKL012.json" + old_view = self.state / "login-view.json.a1B2c3" + old_live = self.state / "login-live.json.d4E5f6" + old_done = self.state / "login-live-done.json.g7H8i9" + old_tui = self.state / "tui-stderr.a1B2c3D4" + old_tui.mkdir(mode=0o700) + (old_tui / "tail").write_text("captured\n", encoding="utf-8") + (old_tui / "tail").chmod(0o600) + os.mkfifo(old_tui / "stream", mode=0o600) recent = self.state / "login-view.DEF456.json" unrelated = self.state / "inventory.json" - for path in (old, old_guard, old_recovery, recent, unrelated): + aged = (old, old_guard, old_recovery, old_view, old_live, old_done) + for path in aged + (recent, unrelated): path.write_text("{}\n", encoding="utf-8") path.chmod(0o600) expired_at = time.time() - 25 * 60 * 60 - for path in (old, old_guard, old_recovery, unrelated): + for path in aged + (unrelated,): os.utime(path, (expired_at, expired_at)) + for path in (old_tui / "tail", old_tui / "stream", old_tui): + os.utime(path, (expired_at, expired_at), follow_symlinks=False) expired = self.run_reaper() self.assertEqual(0, expired.returncode, expired.stderr) - self.assertFalse(old.exists()) - self.assertFalse(old_guard.exists()) - self.assertFalse(old_recovery.exists()) + for path in aged: + self.assertFalse(path.exists(), path) + self.assertFalse(old_tui.exists()) self.assertTrue(recent.is_file()) self.assertTrue(unrelated.is_file()) - self.assertIn("expired_picker_temp_files=3", expired.stderr) + self.assertIn("expired_picker_temp_files=7", expired.stderr) - blocked = self.state / "picker-proof.abcdefgh.json" - blocked.write_text("{}\n", encoding="utf-8") - blocked.chmod(0o600) + def test_a_live_picker_no_longer_blocks_temp_expiry(self) -> None: + """A live picker's temps refresh every few seconds; the 24-hour age + cutoff alone protects them. The old whole-run abort made expiry + permanently dead on any box that always has a picker open.""" + expired_at = time.time() - 25 * 60 * 60 + blocked = self.state / "picker-proof.json.abc123" + fresh = self.state / "login-view.json.zz9yy8" + for path in (blocked, fresh): + path.write_text("{}\n", encoding="utf-8") + path.chmod(0o600) os.utime(blocked, (expired_at, expired_at)) self._write_process(200, 1, "shpool_login") retained = self.run_reaper() self.assertEqual(0, retained.returncode, retained.stderr) - self.assertTrue(blocked.is_file()) - self.assertNotIn("expired_picker_temp_files", retained.stderr) + self.assertFalse(blocked.exists()) + self.assertTrue(fresh.is_file()) + self.assertIn("expired_picker_temp_files=1", retained.stderr) + + def test_reaper_keeps_an_aged_tui_capture_held_by_a_live_process(self) -> None: + capture = self.state / "tui-stderr.z9Y8x7W6" + capture.mkdir(mode=0o700) + (capture / "tail").write_text("still live\n", encoding="utf-8") + (capture / "tail").chmod(0o600) + os.mkfifo(capture / "stream", mode=0o600) + expired_at = time.time() - 25 * 60 * 60 + for path in (capture / "tail", capture / "stream", capture): + os.utime(path, (expired_at, expired_at), follow_symlinks=False) + self._write_process(200, 1, "shpool_login_launcher") + descriptors = self.proc / "200/fd" + descriptors.mkdir() + (descriptors / "2").symlink_to(capture / "stream") + + retained = self.run_reaper() + + self.assertEqual(0, retained.returncode, retained.stderr) + self.assertTrue(capture.is_dir()) def test_second_verification_race_refuses_close(self) -> None: first = self.run_reaper() @@ -548,6 +877,314 @@ def test_second_verification_race_refuses_close(self) -> None: self.assertIn("changed or is no longer safe", raced.stderr) self.assertFalse(self.kill_log.exists()) + def test_the_hourly_pass_sweeps_a_zombie_subagent_worker(self) -> None: + # The trigger, not the code (operator rule): the timer's own + # `shpool_reaper --auto-close` pass must close an idle sub-agent + # worker end-to-end. The victim is a real process of ours whose pid + # wears a claude-worker cmdline in the fake /proc, so the sweep's + # TERM is a real signal landing on a real pid -- never a bystander, + # because the pid is our own child. + victim = subprocess.Popen(["sleep", "300"]) + self.addCleanup(victim.wait) + self.addCleanup( + lambda: victim.poll() is None and victim.kill() # type: ignore[func-returns-value] + ) + root = self.proc / str(victim.pid) + root.mkdir() + fields = ["S", "1"] + ["0"] * 17 + ["12345"] + (root / "stat").write_text( + f"{victim.pid} (claude) {' '.join(fields)}\n", encoding="utf-8" + ) + worker = [ + "/home/user/.local/share/claude/versions/2.1.231", + "--agent-id", + "zombie@session-12345678", + "--parent-session-id", + "abcd-1234", + ] + (root / "cmdline").write_bytes("\0".join(worker).encode() + b"\0") + (root / "comm").write_text("claude\n", encoding="utf-8") + worker_session = "12345678-1234-4234-8234-123456789abc" + (root / "environ").write_bytes(b"") + session_record = self.home / ".claude" / "sessions" / f"{victim.pid}.json" + session_record.parent.mkdir(parents=True) + session_record.write_text( + json.dumps( + { + "pid": victim.pid, + "procStart": "12345", + "sessionId": worker_session, + } + ), + encoding="utf-8", + ) + transcript = ( + self.home / ".claude" / "projects" / "fixture" + / f"{worker_session}.jsonl" + ) + transcript.parent.mkdir(parents=True) + transcript.write_text("finished output\n", encoding="utf-8") + environment = self.environment() + # Ten seconds, with passes about a second apart. The window has to be + # WIDER than the gap between passes: nothing is closed on evidence + # older than the window, so a sub-second window with a pass that takes + # a second to run would restart its own clock every time (round 2, + # lane A F2). This is the real cadence in miniature. + environment["SESSION_KIT_SUBAGENT_IDLE_HOURS"] = "0.0028" # ~10s + run = lambda: subprocess.run( # noqa: E731 + [REAPER, "--auto-close"], + cwd=REPO, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=20, + ) + first = run() + self.assertEqual(0, first.returncode, first.stderr) + self.assertIsNone(victim.poll()) # first sighting only arms the clock + # Keep passing at that cadence until the worker has been WATCHED idle + # for a full window, which is the promise the timer actually makes. + deadline = time.time() + 40 + while victim.poll() is None and time.time() < deadline: + time.sleep(1.0) + later = run() + self.assertEqual(0, later.returncode, later.stderr) + self.assertEqual(-15, victim.poll()) # SIGTERM landed + log_lines = ( + (self.state / "subagent-sweep.log").read_text().strip().splitlines() + ) + records = [json.loads(line) for line in log_lines] + self.assertEqual(["SIGTERM"], [r["signal"] for r in records]) + self.assertEqual(victim.pid, records[0]["pid"]) + self.assertEqual("zombie@session-12345678", records[0]["agent_id"]) + + # ---- the working-copy sweep ------------------------------------------ + + def a_delegated_copy(self, branch: str, session: str) -> tuple[Path, Path]: + """A repository and one automatic working copy bound to ``session``.""" + sys.path.insert(0, str(REPO / "lib")) + from sessionkit_inventory import worktrees + + repo = self.base / f"repo-{session}" + repo.mkdir() + environment = { + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_SYSTEM": "/dev/null", + "GIT_AUTHOR_NAME": "Fixture", + "GIT_AUTHOR_EMAIL": "fixture@example.invalid", + "GIT_COMMITTER_NAME": "Fixture", + "GIT_COMMITTER_EMAIL": "fixture@example.invalid", + } + + def run(*arguments: str) -> None: + subprocess.run( + ["git", "-C", str(repo), *arguments], + check=True, capture_output=True, text=True, + env={**os.environ, **environment}, + ) + + run("init", "--initial-branch=main", "--quiet") + (repo / "README").write_text("one\n", encoding="utf-8") + run("add", "README") + run("commit", "--quiet", "-m", "first") + record = worktrees.materialize( + repo=repo, branch=branch, state_dir=self.state, + environ={"SESSION_KIT_WORKTREE_ROOT": str(self.worktree_root)}, + auto=True, origin="machine", + ) + worktrees.bind( + state_dir=self.state, path=record["path"], shpool_id=session, + environ={"SESSION_KIT_WORKTREE_ROOT": str(self.worktree_root)}, + ) + return repo, Path(record["path"]) + + @property + def worktree_root(self) -> Path: + return self.base / "worktree-root" + + def sweep_environment(self) -> dict[str, str]: + value = self.environment() + value["SESSION_KIT_WORKTREE_ROOT"] = str(self.worktree_root) + return value + + def test_the_pass_with_nothing_to_close_still_sweeps(self) -> None: + """Nothing else in the kit runs the sweep, and this is the usual pass. + + A machine with no auto-close candidate is the ordinary case. With the + sweep below the zero-candidate exit, the scheduled half of "a copy + comes back" almost never ran, so a copy kept because a worker was still + writing was kept for good. + """ + _repo, copy = self.a_delegated_copy("worker/gone", "s-not-live") + self.assertTrue(copy.is_dir()) + + result = subprocess.run( + [REAPER, "--auto-close"], cwd=REPO, env=self.sweep_environment(), + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=False, timeout=30, + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("auto_candidates=0", result.stdout) + self.assertIn("worktrees_released=1", result.stdout) + self.assertFalse(copy.exists(), "the copy of a dead session came back") + + def test_a_live_sessions_copy_survives_the_same_pass(self) -> None: + _repo, copy = self.a_delegated_copy("worker/live", SESSION_ID) + + result = subprocess.run( + [REAPER, "--auto-close"], cwd=REPO, env=self.sweep_environment(), + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=False, timeout=30, + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("worktrees_released=0", result.stdout) + self.assertTrue(copy.is_dir(), "a live session's copy was removed") + + def test_a_session_list_it_cannot_read_removes_no_copy(self) -> None: + """An unreadable list is never "nothing is alive", on any path out. + + A payload this malformed makes the pass refuse during auto-close + planning, before the sweep is reached, so the property under test is + not which message appears; it is that **no copy is removed**. The sweep + arriving at its own version of this question is proved at the verb, in + `tests.test_worktree_isolation.TheSweepIsToldWhatIsAliveTests`, where a + payload can be handed straight to the thing that acts on it: that is + the reader that used to turn "I could not understand this" into an + empty `--active` list and then into "nothing is alive". + """ + _repo, copy = self.a_delegated_copy("worker/live", SESSION_ID) + self.shpool_state.write_text( + json.dumps({"sessions": "not a list at all"}) + "\n", encoding="utf-8" + ) + + result = subprocess.run( + [REAPER, "--auto-close"], cwd=REPO, env=self.sweep_environment(), + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=False, timeout=30, + ) + + self.assertNotEqual(0, result.returncode, "the pass must refuse, not proceed") + self.assertNotIn("worktrees_released=1", result.stdout) + self.assertTrue(copy.is_dir(), "a live session's copy was removed") + + def test_the_sweep_never_blocks_the_terminal_reaper(self) -> None: + # A GENUINE sweep crash must leave the terminal-close machinery + # running: the state scratch path is a directory, so the sweep's + # state save raises IsADirectoryError and the module dies mid-pass + # (corrupt JSON and a bad idle-hours value alone are handled inputs, + # not crashes -- lane finding X20-F8). The sweep is additive, never + # a new failure mode for the pass that shares its timer. + environment = self.environment() + environment["SESSION_KIT_SUBAGENT_IDLE_HOURS"] = "not-a-number" + (self.state / "subagent-sweep.state.json").write_text( + "{corrupt", encoding="utf-8" + ) + (self.state / "subagent-sweep.state.json.tmp").mkdir() + result = subprocess.run( + [REAPER, "--auto-close"], + cwd=REPO, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=20, + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("auto_candidates=0 actions=0", result.stdout) + + def test_darwin_never_runs_the_sweep(self) -> None: + # The Darwin proc tree is a regenerated process snapshot. Its mtimes + # describe snapshot creation rather than worker output, and it cannot + # re-read a live PID generation when delivery happens (re-derived + # X20-F1). The platform gate must keep the sweep out of the + # pass entirely: no signal, no state, no log, on a pass that RUNS + # THE DARWIN PATH TO COMPLETION. A deterministic process table is + # served through the reaper's own SESSION_KIT_INVENTORY_CORE seam, + # because an aborted pass would satisfy the absence assertions + # vacuously (X20 round-2 lane catch: the first version of this test + # died in the libproc adapter before ever reaching the gate, at both + # commits). + victim = subprocess.Popen(["sleep", "300"]) + self.addCleanup(victim.wait) + self.addCleanup( + lambda: victim.poll() is None and victim.kill() # type: ignore[func-returns-value] + ) + table = { + "processes": [ + { + "pid": 10, + "ppid": 1, + "comm": "shpool", + "cmdline": ["/opt/homebrew/bin/shpool", "daemon"], + "args_available": True, + "session_name": None, + "start_ticks": 1000, + }, + { + "pid": 100, + "ppid": 10, + "comm": "bash", + "cmdline": ["bash"], + "args_available": True, + "session_name": SESSION_ID, + "start_ticks": 10_000, + }, + { + "pid": victim.pid, + "ppid": 1, + "comm": "claude", + "cmdline": [ + "/Users/user/.local/share/claude/versions/2.1.231", + "--agent-id", + "mac@team", + "--parent-session-id", + "abcd-1234", + ], + "args_available": True, + "session_name": None, + "start_ticks": 12_345, + }, + ] + } + fake_core = self.bin / "darwin_inventory_core.py" + write_executable( + fake_core, + "#!/usr/bin/env python3\n" + "import json,sys\n" + "if sys.argv[1:] == ['platform', 'process-table']:\n" + f" print(json.dumps({table!r}))\n" + " raise SystemExit(0)\n" + "raise SystemExit(2)\n", + ) + environment = self.environment() + environment["SESSION_KIT_TEST_PLATFORM"] = "Darwin" + environment["SESSION_KIT_INVENTORY_CORE"] = str(fake_core) + environment["SESSION_KIT_SUBAGENT_IDLE_HOURS"] = "0.0001" + for attempt in range(2): + result = subprocess.run( + [REAPER, "--auto-close"], + cwd=REPO, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=20, + ) + # The pass must COMPLETE on the Darwin path: an aborted run + # would pass the absence checks below without testing the gate. + self.assertEqual(0, result.returncode, (attempt, result.stderr)) + self.assertIn("auto_candidates=0 actions=0", result.stdout) + time.sleep(0.6) + self.assertIsNone(victim.poll()) # the worker was never signalled + self.assertFalse((self.state / "subagent-sweep.state.json").exists()) + self.assertFalse((self.state / "subagent-sweep.log").exists()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_recolor_reaches_the_window.py b/tests/test_recolor_reaches_the_window.py new file mode 100644 index 0000000..ae644a2 --- /dev/null +++ b/tests/test_recolor_reaches_the_window.py @@ -0,0 +1,617 @@ +"""A color change reaches the window, named session or not, and never at a cost. + +Both provider windows read their color once, when the process starts, so the +kit's one safe restart is what applies a recolor to a session that is already +open. That restart used to demand a title first -- a guard that belongs to the +RENAME case, where restarting a Codex thread before it has a real name burns the +one-shot on a prompt echo. Applied to a recolor it meant an unnamed session +could never take the color the picker showed. + +Everything here drives the real bounce functions with a stubbed session manager +and a fake `kill`, so what is asserted is what the code does: which snapshots +it refuses, that a refusal never signals anything, that the one-shot marker +survives a restart that applied no name, and that the fresh pre-signal proof +catches work that started during the race window. + +The Codex half of a LIVE recolor (no restart at all) is WS-F's app-server work. +What is here is the seam it plugs into, which answers with the real reason +instead of a false success. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +from tests.support import REPO + + +BOUNCE = REPO / "lib" / "sh" / "sp_provider_bounce.sh" +UUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + + +def session_row(**overrides) -> dict: + """A row the bounce would accept, before the test spoils one field.""" + row = { + "shpool_id": "drill-session", + "availability": "ready", + "agent_status": "idle", + "subagents": [], + "recent_output_age_seconds": 900, + "identity": { + "uuid": UUID, + "pid": 4242, + "process_start_ticks": 99000, + }, + } + row.update(overrides) + return row + + +class BounceHarness: + """Run a real bounce against fixture state, and record every signal.""" + + def __init__(self, base: Path, *, provider: str) -> None: + self.base = base + self.provider = provider + self.state = base / "state" + self.state.mkdir(parents=True, exist_ok=True) + self.markers = self.state / "provider-untitled" + self.markers.mkdir(exist_ok=True) + self.marker = self.markers / "drill-session" + self.marker.write_text("", encoding="utf-8") + self.kill_log = base / "kill.log" + self.snapshot = base / "snapshot.json" + self.recheck = base / "recheck.json" + self.core = base / "fake-core" + self.collector = base / "collect.py" + self.pending = self.state / "provider-bounce" / "drill-session" + + def write_collector(self) -> None: + """The real collection the real recheck runs, at the real moment. + + `picker_bounce_codex` writes the bounce marker, then takes a FRESH + guard snapshot to re-prove the session is still idle, and only then + asks the window to exit. That snapshot is a full collection, and it + runs while the old window is still up -- so it publishes + `app_server_window`, which is what tells collection a bounce has + finished. This is that collection, with nothing simulated but the + session list. + """ + self.collector.write_text( + "import sys\n" + f"sys.path.insert(0, {os.fspath(REPO / 'lib')!r})\n" + "from pathlib import Path\n" + "from sessionkit_inventory import origins\n" + "origins.apply_session_origins(\n" + " {'sessions': [{'shpool_id_raw': 'drill-session',\n" + " 'app_server_window': True}]},\n" + f" state_dir=Path({os.fspath(self.state)!r}),\n" + ")\n", + encoding="utf-8", + ) + + def write_state(self, first: dict, second: dict | None = None) -> None: + self.snapshot.write_text(json.dumps({"sessions": [first]}), encoding="utf-8") + self.recheck.write_text( + json.dumps({"sessions": [second if second is not None else first]}), + encoding="utf-8", + ) + + def write_core(self, *, bounce_title: str = "A Real Name", color_ready: bool = True, + bounce_status: int = 0) -> None: + """The inventory core the bounce calls -- run through python3, as it is.""" + self.core.write_text( + "import sys\n" + "argv = sys.argv[1:]\n" + 'if argv[:2] == ["platform", "codex-refresh-target"]:\n' + ' print("\\t".join(argv[4:6]))\n' + " raise SystemExit(0)\n" + 'if argv[:2] == ["platform", "process-is"]:\n' + " raise SystemExit(0)\n" + 'if argv[:2] == ["color", "bounce-ready"]:\n' + f" raise SystemExit({0 if color_ready else 1})\n" + 'if argv[:1] in (["codex-bounce-title"], ["claude-bounce-title"]):\n' + f" title = {bounce_title!r}\n" + " if title:\n" + " print(title)\n" + f" raise SystemExit({bounce_status})\n" + "raise SystemExit(0)\n" + ) + + def run( + self, + *, + reason: str = "color", + mode: str = "automatic", + collect_during_recheck: bool = False, + ) -> subprocess.CompletedProcess: + function = f"picker_bounce_{self.provider}" + recheck_stub = ( + f'sk_guard_snapshot_file() {{ python3 "{self.collector}"; ' + f'cat "{self.recheck}" > "$1"; }}' + if collect_during_recheck + else f'sk_guard_snapshot_file() {{ cat "{self.recheck}" > "$1"; }}' + ) + script = f""" +set -uo pipefail +export SESSION_KIT_STATE_DIR="{self.state}" +export SESSION_KIT_INVENTORY_CORE="{self.core}" +export SESSION_KIT_SHPOOL_CMD=/bin/true +source "{REPO}/bin/session_kit_common" +INVENTORY_CORE="{self.core}" +SNAPSHOT="{self.snapshot}" +source "{BOUNCE}" +# The session manager, the action log and the signal itself are the three +# things this test must observe rather than perform. +{recheck_stub} +picker_action_event() {{ :; }} +kill() {{ printf '%s\\n' "$*" >> "{self.kill_log}"; return 0; }} +SK_PROOF_PROVIDER={self.provider} +SK_PROOF_UUID={UUID} +SK_PROOF_PROVIDER_PID=4242 +SK_PROOF_PROVIDER_START=99000 +SK_PROOF_SHELL_PID=4200 +SK_PROOF_SHELL_START=98000 +{function} drill-session {mode} {reason} +echo "rc=$?" +""" + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True + ) + + @property + def signalled(self) -> bool: + return self.kill_log.is_file() and "-TERM" in self.kill_log.read_text() + + @property + def marker_survives(self) -> bool: + return self.marker.is_file() + + +class ColorBounceBehaviorTests(unittest.TestCase): + def harness(self, provider: str, **core) -> BounceHarness: + raw = tempfile.mkdtemp(prefix=".bounce-", dir=REPO) + self.addCleanup(subprocess.run, ["rm", "-rf", raw]) + harness = BounceHarness(Path(raw), provider=provider) + harness.write_core(**core) + harness.write_collector() + return harness + + def test_an_unnamed_session_is_recolored(self) -> None: + """The whole point of item 20: no name, still recolorable.""" + for provider in ("claude", "codex"): + with self.subTest(provider=provider): + harness = self.harness(provider, bounce_title="", bounce_status=1) + harness.write_state(session_row()) + harness.run(reason="color") + self.assertTrue(harness.signalled) + + def test_an_unnamed_recolor_keeps_the_one_shot_marker(self) -> None: + """Or the session's status bar is stranded on its conversation ID. + + The marker is what every later remedy needs -- the automatic retry at + reopen and the picker's apply-the-pending-title action both refuse + without it. A restart that applied no name must not spend it. + """ + for provider in ("claude", "codex"): + with self.subTest(provider=provider): + harness = self.harness(provider, bounce_title="", bounce_status=1) + harness.write_state(session_row()) + harness.run(reason="color") + self.assertTrue(harness.signalled) + self.assertTrue( + harness.marker_survives, + "the pending-title remedies are gone without it", + ) + + def test_recolor_then_name_still_reaches_the_window(self) -> None: + """The sequence the marker bug broke, end to end. + + Recolor an unnamed session (restart, no name applied), let the titler + name it afterwards, then let the next open ask for the name bounce. + With the marker spent, that second bounce could never run. + """ + for provider in ("claude", "codex"): + with self.subTest(provider=provider): + harness = self.harness(provider, bounce_title="", bounce_status=1) + harness.write_state(session_row()) + harness.run(reason="color") + self.assertTrue(harness.signalled) + self.assertTrue(harness.marker_survives) + + # The thread is named now, and the window is opened again. + harness.kill_log.unlink() + harness.write_core(bounce_title="Named Later") + harness.run(reason="name") + self.assertTrue(harness.signalled, "the name never reached the window") + self.assertFalse(harness.marker_survives) + + def test_a_recolor_that_also_carried_a_name_spends_the_marker(self) -> None: + for provider in ("claude", "codex"): + with self.subTest(provider=provider): + harness = self.harness(provider, bounce_title="A Real Name") + harness.write_state(session_row()) + harness.run(reason="color") + self.assertTrue(harness.signalled) + self.assertFalse(harness.marker_survives) + + def test_a_name_bounce_still_refuses_without_a_name(self) -> None: + for provider in ("claude", "codex"): + with self.subTest(provider=provider): + harness = self.harness(provider, bounce_title="", bounce_status=1) + harness.write_state(session_row()) + harness.run(reason="name") + self.assertFalse(harness.signalled) + self.assertTrue(harness.marker_survives) + + def test_a_color_with_nothing_to_apply_never_signals(self) -> None: + """`color effective` succeeded for any uuid; readiness is the real test.""" + for provider in ("claude", "codex"): + with self.subTest(provider=provider): + harness = self.harness(provider, color_ready=False) + harness.write_state(session_row()) + harness.run(reason="color") + self.assertFalse(harness.signalled) + self.assertTrue(harness.marker_survives) + + def test_a_working_or_busy_session_is_never_signalled(self) -> None: + cases = { + "working": session_row(agent_status="working"), + "state unavailable": session_row(agent_status="state unavailable"), + "attached": session_row(availability="attached"), + "has subagents": session_row(subagents=[{"pid": 5}]), + "just printed": session_row(recent_output_age_seconds=5), + "different process": session_row( + identity={"uuid": UUID, "pid": 999, "process_start_ticks": 99000} + ), + } + for provider in ("claude", "codex"): + for name, row in cases.items(): + with self.subTest(provider=provider, case=name): + harness = self.harness(provider) + harness.write_state(row) + harness.run(reason="color") + self.assertFalse( + harness.signalled, f"{provider} was signalled while {name}" + ) + + def test_work_that_starts_during_the_race_window_stops_the_signal(self) -> None: + """The fresh proof exists for exactly this; it skipped the quiet test. + + First snapshot: quiet and idle. By the time the lock is released the + window is printing again -- status can still read idle while output is + arriving -- and the signal must not be sent. + """ + for provider in ("claude", "codex"): + with self.subTest(provider=provider): + harness = self.harness(provider) + harness.write_state( + session_row(), + session_row(recent_output_age_seconds=3), + ) + harness.run(reason="color") + self.assertFalse(harness.signalled) + + def test_a_state_change_during_the_race_window_stops_the_signal(self) -> None: + for provider in ("claude", "codex"): + for name, second in ( + ("started working", session_row(agent_status="working")), + ("someone attached", session_row(availability="attached")), + ("spawned a subagent", session_row(subagents=[{"pid": 7}])), + ): + with self.subTest(provider=provider, case=name): + harness = self.harness(provider) + harness.write_state(session_row(), second) + harness.run(reason="color") + self.assertFalse(harness.signalled) + + def test_a_quiet_idle_session_is_signalled(self) -> None: + """The control: without it, every refusal above proves nothing.""" + for provider in ("claude", "codex"): + with self.subTest(provider=provider): + harness = self.harness(provider) + harness.write_state(session_row()) + harness.run(reason="color") + self.assertTrue(harness.signalled) + + def test_a_deferred_recolor_is_still_pending_at_the_next_open(self) -> None: + """A busy session refuses the restart; the request must not die there. + + `sp color` asks for one safe restart and a working or attached session + rightly refuses. Without a record of the request, the next open asked + only for a NAME bounce -- which an unnamed session can never satisfy -- + so the window kept its own color while the picker showed the new one. + """ + for provider in ("claude", "codex"): + with self.subTest(provider=provider): + harness = self.harness(provider, bounce_title="", bounce_status=1) + pending = harness.state / "provider-recolor" / "drill-session" + pending.parent.mkdir(exist_ok=True) + pending.write_text("", encoding="utf-8") + # Busy: refused, and the pending record survives. + harness.write_state(session_row(agent_status="working")) + harness.run(reason="color") + self.assertFalse(harness.signalled) + self.assertTrue(pending.is_file()) + # Quiet at the next open: the restart happens and clears it. + harness.write_state(session_row()) + harness.run(reason="color") + self.assertTrue(harness.signalled) + self.assertFalse(pending.is_file()) + + def test_the_picker_retries_a_pending_recolor_at_open(self) -> None: + picker = (REPO / "lib" / "sh" / "sp_picker.sh").read_text(encoding="utf-8") + opener = picker.split("picker_open() {", 1)[1].split("\n}\n", 1)[0] + self.assertIn("provider-recolor/$id", opener) + self.assertIn("automatic color", opener) + commands = (REPO / "lib" / "sh" / "sp_commands.sh").read_text(encoding="utf-8") + recolor = commands.split("color_target() {", 1)[1].split("\n}\n", 1)[0] + self.assertIn('sk_mark_provider_recolor "$id"', recolor) + + def test_the_bounce_survives_its_own_recheck_collection(self) -> None: + """The bounce must not be cancelled by the collection it causes. + + The order inside `picker_bounce_codex` is: write the marker, take a + FRESH guard snapshot to re-prove the session is idle, then signal the + window. That snapshot is a real collection and it runs while the old + window is still up, so it publishes `app_server_window` -- the very + signal that tells collection a bounce has finished and its marker can + go. Read against a marker nobody has consumed yet, that signal is about + the OLD window, and acting on it deletes the instruction before the + session shell has read it. + + What follows is not a hidden row, it is a closed session: the shell + reaches the marker, finds nothing where its instruction should be, + takes the ordinary provider exit instead of relaunching, and the + conversation the person was sitting in ends. The marker stays until the + shell itself empties it. + """ + harness = self.harness("codex") + harness.write_state(session_row()) + + harness.run(reason="name", collect_during_recheck=True) + + self.assertTrue(harness.signalled, "the window was never asked to exit") + self.assertTrue( + harness.pending.is_file(), + "the bounce was cancelled by its own recheck; the shell will end " + "the session instead of relaunching it", + ) + self.assertEqual( + UUID, + harness.pending.read_text(encoding="utf-8").splitlines()[0], + "the shell must still find the conversation it was told to resume", + ) + + def test_the_marker_is_replaced_whole_never_truncated_in_place(self) -> None: + """An empty marker is the shell's receipt, so it must never be a phase. + + `> file` truncates before it writes, so a plain redirect passes through + the empty state on the way to the full one. A collection landing there + reads a receipt for a bounce nobody has performed and removes the + marker -- the same cancellation, through a smaller door. A rename has + no such moment, and a rename is observable: it replaces the file rather + than rewriting it. + """ + harness = self.harness("codex") + harness.write_state(session_row()) + # A marker left by an earlier bounce of the same session: this is the + # case where a redirect rewrites rather than creates, and it is the + # only one in which the difference is observable after the fact. + harness.pending.parent.mkdir(parents=True, exist_ok=True) + harness.pending.write_text("11111111-1111-4111-8111-111111111111\n", encoding="utf-8") + before = harness.pending.stat().st_ino + + harness.run(reason="name") + + self.assertTrue(harness.signalled) + self.assertEqual( + UUID, + harness.pending.read_text(encoding="utf-8").splitlines()[0], + "the marker must name the conversation this bounce is for", + ) + lines = harness.pending.read_text(encoding="utf-8").splitlines() + self.assertEqual(3, len(lines)) + self.assertRegex(lines[2], r"^[A-Za-z0-9]{6,32}$") + self.assertNotEqual( + before, + harness.pending.stat().st_ino, + "the marker was rewritten in place, so it was briefly empty, and a " + "collection landing in that instant reads the shell's receipt for a " + "bounce nobody performed and cancels it", + ) + + def test_a_bogus_reason_is_refused(self) -> None: + harness = self.harness("claude") + harness.write_state(session_row()) + result = harness.run(reason="sideways") + self.assertIn("rc=2", result.stdout) + self.assertFalse(harness.signalled) + + +class ColorBounceContractTests(unittest.TestCase): + """The few things worth pinning as shape rather than behavior.""" + + def function_body(self, name: str) -> str: + source = BOUNCE.read_text(encoding="utf-8") + return source.split(f"{name}() {{", 1)[1].split("\n}\n", 1)[0] + + def test_both_bounces_take_a_reason_and_default_to_name(self) -> None: + for name in ("picker_bounce_claude", "picker_bounce_codex"): + with self.subTest(name): + body = self.function_body(name) + self.assertIn("reason=${3:-name}", body) + + def test_the_recolor_command_asks_for_a_color_bounce(self) -> None: + commands = (REPO / "lib" / "sh" / "sp_commands.sh").read_text(encoding="utf-8") + recolor = commands.split("color_target() {", 1)[1].split("\n}\n", 1)[0] + self.assertIn('sk_refresh_provider_title "$id" "$provider" color', recolor) + rename = commands.split("name_target() {", 1)[1].split("\n}\n", 1)[0] + self.assertNotIn("color\n", rename.split("sk_refresh_provider_title", 1)[1]) + + +class CodexLiveRecolorSeamTests(unittest.TestCase): + def core(self): + import importlib.util + import sys + + if "session_inventory" in sys.modules: + return sys.modules["session_inventory"] + spec = importlib.util.spec_from_file_location( + "session_inventory", REPO / "lib" / "session_inventory.py" + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + def test_without_the_capability_it_names_the_real_reason(self) -> None: + core = self.core() + with tempfile.TemporaryDirectory(prefix=".live-color-", dir=REPO) as raw: + pushes, warnings = core.push_codex_live_color(Path(raw), UUID, "cyan") + self.assertEqual([], pushes) + self.assertEqual(1, len(warnings)) + self.assertIn("next starts", warnings[0]) + + def test_a_declared_capability_without_a_client_never_claims_success(self) -> None: + core = self.core() + with tempfile.TemporaryDirectory(prefix=".live-color-", dir=REPO) as raw: + state = Path(raw) + (state / "app-server").mkdir() + (state / "app-server" / "capabilities.json").write_text( + json.dumps({"theme_set": True}), encoding="utf-8" + ) + pushes, warnings = core.push_codex_live_color(state, UUID, "cyan") + self.assertEqual([], pushes) + self.assertIn("no app-server client is wired", warnings[0]) + + def test_the_client_is_used_once_it_exists(self) -> None: + core = self.core() + with tempfile.TemporaryDirectory(prefix=".live-color-", dir=REPO) as raw: + state = Path(raw) + (state / "app-server").mkdir() + (state / "app-server" / "capabilities.json").write_text( + json.dumps({"theme_set": True}), encoding="utf-8" + ) + seen: list[tuple[str, str]] = [] + + def send(_root: Path, uuid: str, color: str): + seen.append((uuid, color)) + return ["codex-live-color"], [] + + pushes, warnings = core.push_codex_live_color( + state, UUID, "cyan", send=send + ) + self.assertEqual(["codex-live-color"], pushes) + self.assertEqual([], warnings) + self.assertEqual([(UUID, "cyan")], seen) + + def test_capabilities_default_to_nothing_and_ignore_a_symlink(self) -> None: + core = self.core() + with tempfile.TemporaryDirectory(prefix=".live-caps-", dir=REPO) as raw: + state = Path(raw) + self.assertEqual({}, core.codex_live_capabilities(state)) + (state / "app-server").mkdir() + elsewhere = state / "elsewhere.json" + elsewhere.write_text(json.dumps({"theme_set": True}), encoding="utf-8") + link = state / "app-server" / "capabilities.json" + link.symlink_to(elsewhere) + self.assertEqual({}, core.codex_live_capabilities(state)) + os.unlink(link) + link.write_text("{not json", encoding="utf-8") + self.assertEqual({}, core.codex_live_capabilities(state)) + + +class ColorReadinessTests(unittest.TestCase): + """`color bounce-ready`: can a restart actually paint this color?""" + + def sandbox(self, base: Path) -> dict[str, str]: + home = base / "home" + (home / ".claude" / "sessions").mkdir(parents=True, mode=0o700) + (home / ".claude" / "projects" / "-srv-project").mkdir(parents=True, mode=0o700) + (home / ".codex" / "themes").mkdir(parents=True, mode=0o700) + config = base / "session-kit.json" + config.write_text(json.dumps({"schema_version": 1}), encoding="utf-8") + config.chmod(0o600) + state = base / "state" + state.mkdir(mode=0o700) + state.chmod(0o700) + return { + **os.environ, + "HOME": os.fspath(home), + "SESSION_KIT_CONFIG": os.fspath(config), + "SESSION_KIT_STATE_DIR": os.fspath(state), + "SESSION_KIT_CODEX_AUTOTITLE": "0", + } + + def ready(self, env: dict[str, str], provider: str) -> subprocess.CompletedProcess: + return subprocess.run( + [REPO / "lib" / "session_inventory.py", "color", "bounce-ready", provider, UUID], + env=env, + capture_output=True, + text=True, + ) + + def test_claude_needs_a_color_record_the_resumed_window_will_read(self) -> None: + with tempfile.TemporaryDirectory(prefix=".ready-claude-", dir=REPO) as raw: + env = self.sandbox(Path(raw)) + transcript = ( + Path(env["HOME"]) / ".claude" / "projects" / "-srv-project" / f"{UUID}.jsonl" + ) + transcript.write_text( + json.dumps({"type": "user", "message": {"content": "hi"}}) + "\n", + encoding="utf-8", + ) + refused = self.ready(env, "claude") + self.assertEqual(1, refused.returncode, refused.stdout) + self.assertIn("no color record", refused.stderr) + + effective = subprocess.run( + [REPO / "lib" / "session_inventory.py", "color", "effective", "claude", UUID], + env=env, + capture_output=True, + text=True, + ).stdout.strip() + with open(transcript, "a", encoding="utf-8") as handle: + handle.write( + json.dumps( + { + "type": "agent-color", + "agentColor": effective, + "sessionId": UUID, + } + ) + + "\n" + ) + accepted = self.ready(env, "claude") + self.assertEqual(0, accepted.returncode, accepted.stderr) + self.assertEqual(effective, accepted.stdout.strip()) + + def test_codex_needs_the_theme_the_launcher_would_pass(self) -> None: + with tempfile.TemporaryDirectory(prefix=".ready-codex-", dir=REPO) as raw: + env = self.sandbox(Path(raw)) + refused = self.ready(env, "codex") + self.assertEqual(1, refused.returncode, refused.stdout) + self.assertIn("no Codex theme", refused.stderr) + + effective = subprocess.run( + [REPO / "lib" / "session_inventory.py", "color", "effective", "codex", UUID], + env=env, + capture_output=True, + text=True, + ).stdout.strip() + theme = Path(env["HOME"]) / ".codex" / "themes" / f"sk-{effective}.tmTheme" + theme.write_text("<theme/>", encoding="utf-8") + accepted = self.ready(env, "codex") + self.assertEqual(0, accepted.returncode, accepted.stderr) + self.assertEqual(effective, accepted.stdout.strip()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_recovery_one_list.py b/tests/test_recovery_one_list.py new file mode 100644 index 0000000..f05c32f --- /dev/null +++ b/tests/test_recovery_one_list.py @@ -0,0 +1,1970 @@ +"""One list of what can come back, behind both surfaces that show it. + +The operator closed a session and could not get it back. `sp recover` listed +it; the picker's Closed-sessions screen, the screen whose only job is +bringing sessions back, did not list it at all. The two read different +stores: measured live on 2026-08-15 they shared three conversations out of +fifty-one, and thirty-four of the picker's seventy-seven entries were +conversations that were open at that moment, offered for restore. + +These tests pin the single projection both surfaces now read. None of them +reads the real state directory: every case is built from literal records. +""" + +from __future__ import annotations + +import contextlib +import datetime +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +from tests.support import REPO + +sys.path.insert(0, os.fspath(REPO / "lib")) + +from sessionkit_inventory.recovery_list import MAX_ROWS # noqa: E402 +import session_inventory as inventory_core # noqa: E402 +from sessionkit_inventory import ( # noqa: E402 + closed_sessions, + lifecycle, + printed_selectors, +) +from sessionkit_inventory.printed_selectors import ( # noqa: E402 + printed_selectors_path, +) + + +PICKER = REPO / "lib" / "sh" / "shpool_login_recovery.sh" +COMMANDS = REPO / "lib" / "sh" / "sp_commands.sh" +RENDER_BLOCK = ' python3 - "$RECOVERY" <<\'PY\'\n' +SELECT_BLOCK = ( + ' selected=$(python3 - "$RECOVERY" "$answer" "$SCRIPT_DIR/../lib" <<\'PY\'\n' +) +PROVIDER_WORDS = {"CLD": "claude", "CDX": "codex", "SH": "shell", "?": "unknown"} + + +def shell_block(opener: str) -> str: + """The exact python one of the picker's screens runs. + + Lifted out of the shell file rather than copied, so a test of what the + picker prints cannot go on passing after the picker stops printing it. + """ + text = PICKER.read_text(encoding="utf-8") + start = text.index(opener) + len(opener) + return text[start : text.index("\nPY\n", start)] + + +def uuid_for(number: int) -> str: + return f"00000000-0000-4000-8000-{number:012d}" + + +LIVE = uuid_for(1) +CLOSED = uuid_for(2) +LOST = uuid_for(3) +CRASHED = uuid_for(4) +RETIRED = uuid_for(5) +UNREADABLE = uuid_for(6) + + +class RecoveryProjectionTests(unittest.TestCase): + def rows(self, **overrides): + # Imported here, not at module scope: the surface tests below must + # still run against a commit where this module does not exist yet, + # so the differential reads as a wrong list rather than a broken file. + from sessionkit_inventory import recovery_list + + payload = { + "manifest_sessions": [], + "closed_rows": [], + "pending_entries": [], + "live_sessions": [], + "aliases": {}, + "automatic_titles": {}, + "numbers": {}, + } + payload.update(overrides) + return recovery_list.recovery_rows(**payload) + + def test_a_live_conversation_is_never_offered_as_restorable(self) -> None: + """It is open. A restore would collide with the session running it. + + The picker's store had no live filter at all, so a third of what it + offered was work already on screen somewhere else. + """ + rows = self.rows( + pending_entries=[ + { + "provider": "claude", + "uuid": LIVE, + "cwd": "/srv/app", + "title": "Open right now", + "actionable": True, + "source_generation_key": "gen-1", + "old_shpool_id": "s1", + "started_at_unix_ms": 1_000, + } + ], + closed_rows=[ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": "Count the great lakes", + "title_source": "native", + "restorable": True, + "closed_at_unix_ms": 2_000, + } + ], + live_sessions=[ + {"provider": "claude", "identity": {"uuid": LIVE}}, + ], + ) + + self.assertEqual([CLOSED], [row["uuid"] for row in rows]) + + def test_a_conversation_closed_on_purpose_is_offered_back(self) -> None: + """The one they wanted. The pending store drops these by design.""" + rows = self.rows( + closed_rows=[ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": "Count the great lakes", + "title_source": "native", + "restorable": True, + "closed_at_unix_ms": 2_000, + } + ], + numbers={f"ai:claude:{CLOSED}": 106}, + ) + + self.assertEqual(1, len(rows)) + self.assertTrue(rows[0]["restorable"]) + self.assertEqual("Count the great lakes", rows[0]["display_name"]) + self.assertEqual(106, rows[0]["number"]) + + def test_a_session_keeps_the_number_it_has_everywhere_else(self) -> None: + """A number is bound to the CONVERSATION and outlives the close. + + Both lists used to number their own rows 1..N, so a session was 106 + on every other screen and 1 here. Worse, `sp restore 3` indexed a + list rebuilt since it was printed. + """ + rows = self.rows( + closed_rows=[ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": "Second", + "title_source": "alias", + "restorable": True, + "closed_at_unix_ms": 2_000, + }, + { + "provider": "codex", + "uuid": LOST, + "cwd": "/srv/app", + "title": "First", + "title_source": "alias", + "restorable": True, + "closed_at_unix_ms": 9_000, + }, + ], + numbers={f"ai:claude:{CLOSED}": 106, f"ai:codex:{LOST}": 42}, + ) + + # Newest first, and each row carries its own number rather than its + # position in this particular list. + self.assertEqual([42, 106], [row["number"] for row in rows]) + + def test_a_conversation_nothing_named_reads_unnamed(self) -> None: + """No placeholders. "the Claude session" is the absence of a name.""" + rows = self.rows( + closed_rows=[ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": "Claude in v2 at Aug 14 23:44", + "title_source": "context", + "restorable": True, + "closed_at_unix_ms": 2_000, + }, + { + "provider": "codex", + "uuid": LOST, + "cwd": "/srv/app", + "title": "", + "restorable": True, + "closed_at_unix_ms": 1_000, + }, + ], + ) + + self.assertEqual(["unnamed", "unnamed"], [r["display_name"] for r in rows]) + self.assertEqual([False, False], [r["named"] for r in rows]) + + def test_a_name_something_chose_is_shown(self) -> None: + """Alias, provider rename, and kit-derived titles are all names.""" + for source in ("alias", "native", "automatic"): + with self.subTest(source=source): + rows = self.rows( + closed_rows=[ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": "Count the great lakes", + "title_source": source, + "restorable": True, + "closed_at_unix_ms": 2_000, + } + ], + ) + self.assertEqual("Count the great lakes", rows[0]["display_name"]) + self.assertTrue(rows[0]["named"]) + + def test_a_name_store_outranks_a_recorded_label(self) -> None: + """The stores that hold a chosen name outlive the session.""" + rows = self.rows( + closed_rows=[ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": "Claude in v2 at Aug 14 23:44", + "title_source": "context", + "restorable": True, + "closed_at_unix_ms": 2_000, + } + ], + aliases={f"claude:{CLOSED}": "Great Lakes Count"}, + ) + + self.assertEqual("Great Lakes Count", rows[0]["display_name"]) + + def test_a_record_with_no_provenance_is_judged_by_shape(self) -> None: + """Never invent a name; never throw one away either. + + Manifests and pending records keep no provenance at all, so a title + with none is measured against the labels the kit itself generates, + and against nothing else. Every one of these was found on the real + machine being shown as though somebody had chosen it. + """ + for title in ( + "the shell session", + "the Claude session", + "Idle shell", + "Claude", + "v2-35", + "v2-af", + "Claude in v2 at Aug 14 23:44", + "Codex started Aug 14 23:44", + ): + with self.subTest(title=title): + rows = self.rows( + closed_rows=[ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": title, + "restorable": True, + "closed_at_unix_ms": 2_000, + } + ], + ) + self.assertEqual("unnamed", rows[0]["display_name"]) + self.assertFalse(rows[0]["named"]) + + # And a name a person typed, with the same missing provenance, is + # still their name. + rows = self.rows( + closed_rows=[ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": "Orphaned record", + "restorable": True, + "closed_at_unix_ms": 2_000, + } + ], + ) + self.assertEqual("Orphaned record", rows[0]["display_name"]) + + def test_history_only_says_so_in_a_sentence(self) -> None: + """A shell has no conversation; an unreadable transcript cannot come + back. Both are listed, neither pretends to be restorable.""" + rows = self.rows( + closed_rows=[ + { + "provider": "shell", + "uuid": "", + "cwd": "/srv/app", + "title": "", + "restorable": False, + "closed_at_unix_ms": 2_000, + "shpool_id": "s20260814-000000-1", + } + ], + ) + + self.assertEqual(1, len(rows)) + self.assertFalse(rows[0]["restorable"]) + self.assertIn("no conversation to reopen", rows[0]["history_only_reason"]) + self.assertEqual("unnamed", rows[0]["display_name"]) + + def test_a_transcript_that_is_gone_is_history_on_every_store(self) -> None: + """One readability rule, asked of all three stores. + + The ledger dropped the row outright, so the sentence written for this + case could never print; the manifest and the pending queue never + asked at all, so the same conversation was offered as a full restore + and `sp restore` walked into a missing file. + """ + stores = { + "closed_rows": [ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": "Gone", + "title_source": "native", + "restorable": True, + "closed_at_unix_ms": 2_000, + } + ], + "manifest_sessions": [ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": "Gone", + "title_source": "native", + "crashed_at_unix_ms": 2_000, + } + ], + "pending_entries": [ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": "Gone", + "title_source": "native", + "actionable": True, + "source_generation_key": "gen-1", + "old_shpool_id": "s-old", + "started_at_unix_ms": 2_000, + } + ], + } + for store, records in stores.items(): + with self.subTest(store=store): + rows = self.rows( + still_readable=lambda provider, uuid: False, **{store: records} + ) + self.assertEqual(1, len(rows)) + self.assertFalse(rows[0]["restorable"]) + self.assertIn( + "no longer on this machine", rows[0]["history_only_reason"] + ) + self.assertEqual("Gone", rows[0]["display_name"]) + + def test_a_conversation_whose_records_disagree_is_not_called_a_shell( + self, + ) -> None: + """It is a conversation with a transcript, held for them to look at. + + Every non-actionable pending row was told it was a plain shell with + nothing to reopen, on the list, again when they typed its number, and + again from `sp restore`. + """ + rows = self.rows( + pending_entries=[ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": "Conflicted work", + "title_source": "native", + "actionable": False, + "conflict_fields": ["cwd", "command"], + "source_generation_key": "gen-1", + "old_shpool_id": "s-old", + "started_at_unix_ms": 2_000, + } + ], + ) + + self.assertFalse(rows[0]["restorable"]) + self.assertIn("launch records disagree", rows[0]["history_only_reason"]) + self.assertNotIn("plain shell", rows[0]["history_only_reason"]) + self.assertEqual(["cwd", "command"], rows[0]["conflict_fields"]) + + def test_a_closed_shell_with_no_directory_is_still_listed(self) -> None: + """The ledger knows which session it was; the list has to say so. + + A record with no conversation is kept only by its own identity, and + the closed loop was not passing the one the ledger holds, so a shell + closed while the inventory cache was unreadable was on no screen at + all. + """ + rows = self.rows( + closed_rows=[ + { + "provider": "shell", + "uuid": "", + "cwd": "", + "title": "Idle shell", + "restorable": False, + "closed_at_unix_ms": 2_000, + "shpool_id": "s20260814-000000-1", + } + ], + ) + + self.assertEqual(1, len(rows)) + self.assertEqual("s20260814-000000-1", rows[0]["old_shpool_id"]) + self.assertIn("no conversation to reopen", rows[0]["history_only_reason"]) + + def test_two_conversations_never_share_a_selector(self) -> None: + """Growing a colliding short id once is not the same as growing it. + + These two share their first twelve hex characters, so the one widening + step left both rows carrying "aaaaaaaaaaaa". + """ + first = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1" + second = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2" + rows = self.rows( + closed_rows=[ + { + "provider": "claude", + "uuid": uuid, + "cwd": "/srv/app", + "title": "Work", + "title_source": "alias", + "restorable": True, + "closed_at_unix_ms": 2_000 + offset, + } + for offset, uuid in enumerate((first, second)) + ], + ) + + self.assertEqual(2, len({row["short_id"] for row in rows})) + + def test_one_number_claimed_twice_names_neither_conversation(self) -> None: + """This read does not go through the validating registry reader. + + A number that stands for two conversations resolves to whichever the + lookup reaches first, silently. Neither row keeps it. + """ + rows = self.rows( + closed_rows=[ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": "First", + "title_source": "alias", + "restorable": True, + "closed_at_unix_ms": 2_000, + }, + { + "provider": "codex", + "uuid": LOST, + "cwd": "/srv/app", + "title": "Second", + "title_source": "alias", + "restorable": True, + "closed_at_unix_ms": 1_000, + }, + ], + numbers={f"ai:claude:{CLOSED}": 7, f"ai:codex:{LOST}": 7}, + ) + + self.assertEqual([None, None], [row["number"] for row in rows]) + + def test_the_cap_never_drops_a_conversation_that_can_come_back(self) -> None: + """The reproduction as it was reported: five hundred newer closes and + one older crash, all of them restorable, at the real cap. + + The aggregate limit is a bound on reading, and a history-only row is + the only kind of row that exists to be read. Applied to the merged + list it took the one thing the list is for. + """ + rows = self.rows( + closed_rows=[ + { + "provider": "claude", + "uuid": f"{index:08x}-0000-4000-8000-000000000000", + "cwd": "/srv/app", + "title": f"Closed {index}", + "title_source": "alias", + "restorable": True, + "closed_at_unix_ms": 1_000_000 + index, + } + for index in range(MAX_ROWS) + ], + manifest_sessions=[ + { + "provider": "codex", + "uuid": LOST, + "cwd": "/srv/app", + "title": "Older crash", + "title_source": "alias", + "crashed_at_unix_ms": 1, + } + ], + ) + + self.assertEqual(MAX_ROWS + 1, len(rows)) + self.assertIn(LOST, [row["uuid"] for row in rows]) + self.assertTrue(all(row["restorable"] for row in rows)) + + def test_the_cap_keeps_what_can_still_come_back(self) -> None: + """The cap is a bound on the read, not a retirement policy. + + Applied to the merged list, it let history-only rows push a still + restorable conversation off the one list that offers it. + """ + rows = self.rows( + closed_rows=[ + { + "provider": "shell", + "uuid": "", + "cwd": "/srv/app", + "title": "", + "restorable": False, + "closed_at_unix_ms": 9_000 + index, + "shpool_id": f"s-shell-{index}", + } + for index in range(3) + ] + + [ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": "Old but restorable", + "title_source": "alias", + "restorable": True, + "closed_at_unix_ms": 1_000, + } + ], + limit=3, + ) + + self.assertEqual(3, len(rows)) + self.assertIn(CLOSED, [row["uuid"] for row in rows]) + # Display order is untouched: newest first, the kept rows in place. + self.assertEqual( + sorted((row["when_unix_ms"] for row in rows), reverse=True), + [row["when_unix_ms"] for row in rows], + ) + + def test_a_row_that_can_come_back_carries_no_excuse(self) -> None: + rows = self.rows( + closed_rows=[ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": "Named", + "title_source": "alias", + "restorable": True, + "closed_at_unix_ms": 2_000, + } + ], + ) + self.assertEqual("", rows[0]["history_only_reason"]) + + def test_one_conversation_is_one_row_and_keeps_its_ack_keys(self) -> None: + """The stores overlap. The newest event describes it; the keys the + acknowledgment needs exist on one record and must survive.""" + rows = self.rows( + pending_entries=[ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": "", + "actionable": True, + "source_generation_key": "gen-7", + "old_shpool_id": "s-old", + "started_at_unix_ms": 1_000, + } + ], + closed_rows=[ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": "Count the great lakes", + "title_source": "native", + "restorable": True, + "closed_at_unix_ms": 5_000, + } + ], + ) + + self.assertEqual(1, len(rows)) + self.assertEqual("Count the great lakes", rows[0]["display_name"]) + self.assertEqual("gen-7", rows[0]["source_generation_key"]) + self.assertEqual("s-old", rows[0]["old_shpool_id"]) + + def test_retention_is_restorability_not_age(self) -> None: + """An old conversation that still restores is still offered. + + An age cutoff would silently drop work that comes back perfectly -- + the same failure as the missing session that prompted all this. + """ + rows = self.rows( + closed_rows=[ + { + "provider": "claude", + "uuid": CLOSED, + "cwd": "/srv/app", + "title": "Ancient but restorable", + "title_source": "alias", + "restorable": True, + "closed_at_unix_ms": 1, + } + ], + ) + + self.assertEqual(1, len(rows)) + self.assertTrue(rows[0]["restorable"]) + + +class WhatAScreenPrintedTests(unittest.TestCase): + """The record a typed word is checked against, and what it will not claim. + + It exists for one question: does this word still mean the row it was + printed beside? Every answer it gives that is not a plain yes has to be + distinguishable from one, or the check is worse than not having it. + """ + + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".printed-", dir=REPO) + self.addCleanup(self.temp.cleanup) + self.state = Path(self.temp.name) + + def row(self, selector: str, uuid: str, provider: str = "claude") -> dict: + return {"selector": selector, "provider": provider, "uuid": uuid} + + def verdict(self, selector: str, uuid: str, provider: str = "claude") -> str: + return printed_selectors.check_printed( + self.state, selector, provider, uuid + )["verdict"] + + def test_a_word_answers_for_the_conversation_it_was_printed_beside(self) -> None: + printed_selectors.remember_printed( + self.state, [self.row("@19:00", CLOSED), self.row("unnamed", RETIRED)] + ) + + self.assertEqual("agrees", self.verdict("@19:00", CLOSED)) + self.assertEqual("disagrees", self.verdict("@19:00", RETIRED)) + # Case and spacing are folded the way both surfaces fold them. + self.assertEqual("agrees", self.verdict(" UNNAMED ", RETIRED)) + self.assertEqual("disagrees", self.verdict("unnamed", CLOSED)) + + def test_a_word_no_screen_printed_is_not_an_answer_at_all(self) -> None: + """Unknown is a third answer, never a quiet yes. + + Nothing on this machine has shown them that word, so there is no + earlier meaning for it to have drifted from -- and saying so is what + lets the caller decide, instead of being told a check happened. + """ + printed_selectors.remember_printed(self.state, [self.row("@19:00", CLOSED)]) + + self.assertEqual("unknown", self.verdict("@20:00", RETIRED)) + self.assertEqual("unknown", self.verdict("", CLOSED)) + + def test_a_conversation_that_leaves_the_list_keeps_its_word(self) -> None: + """The next screen does not release a word by omitting it. + + A row that stops being restorable loses its selector, so the word it + was printed under simply is not on the new screen. Forgetting it there + would hand it to whichever row takes it next -- which is the failure + this record exists to catch. + """ + printed_selectors.remember_printed(self.state, [self.row("@19:00", CLOSED)]) + printed_selectors.remember_printed(self.state, [self.row("@20:00", RETIRED)]) + + self.assertEqual("agrees", self.verdict("@19:00", CLOSED)) + self.assertEqual("disagrees", self.verdict("@19:00", LOST)) + self.assertEqual("agrees", self.verdict("@20:00", RETIRED)) + + def test_the_screen_shown_last_is_the_one_that_counts(self) -> None: + """Rebinding is what the fallback is FOR, while a name is shared. + + A word that legitimately moves to another row and is PRINTED there has + been shown to them under its new meaning, so that is the meaning it + keeps. + """ + printed_selectors.remember_printed(self.state, [self.row("unnamed", CLOSED)]) + printed_selectors.remember_printed(self.state, [self.row("unnamed", RETIRED)]) + + self.assertEqual("agrees", self.verdict("unnamed", RETIRED)) + self.assertEqual("disagrees", self.verdict("unnamed", CLOSED)) + + def test_a_row_with_no_word_or_no_conversation_is_not_recorded(self) -> None: + """Only a word they could type, for a conversation it could open.""" + written = printed_selectors.remember_printed( + self.state, + [ + self.row("", CLOSED), + self.row("a shell", "", provider="shell"), + self.row("@19:00", "not-a-uuid"), + self.row("Older work", RETIRED), + "not a row", + ], + ) + + self.assertEqual(1, written["remembered"]) + self.assertEqual("agrees", self.verdict("Older work", RETIRED)) + + def test_a_record_it_cannot_read_is_no_record_rather_than_a_claim(self) -> None: + """Damage must not read as "this word means something else". + + A refusal built on an unreadable file would block restores for a + reason nobody could act on; the honest answer is that this machine + knows nothing about that word. + """ + printed_selectors.remember_printed(self.state, [self.row("@19:00", CLOSED)]) + printed_selectors_path(self.state).write_text("{ not json", encoding="utf-8") + + self.assertEqual("unknown", self.verdict("@19:00", CLOSED)) + self.assertEqual({}, printed_selectors.load_printed(self.state)) + + # And it recovers by being written again, rather than staying broken. + printed_selectors.remember_printed(self.state, [self.row("@19:00", RETIRED)]) + self.assertEqual("agrees", self.verdict("@19:00", RETIRED)) + + def test_it_is_bounded_and_drops_the_oldest_first(self) -> None: + """A record that grows without limit is a different fault. + + What it drops it reports as unknown, which is allowed -- the direction + this whole mechanism fails in. + """ + limit = printed_selectors.MAX_REMEMBERED + printed_selectors.remember_printed( + self.state, + [self.row(f"@{index}", uuid_for(index)) for index in range(limit + 10)], + now_unix_ms=1_000, + ) + printed_selectors.remember_printed( + self.state, [self.row("newest", CLOSED)], now_unix_ms=2_000 + ) + + self.assertEqual(limit, len(printed_selectors.load_printed(self.state))) + self.assertEqual("agrees", self.verdict("newest", CLOSED)) + + def test_the_record_is_owner_only(self) -> None: + printed_selectors.remember_printed(self.state, [self.row("@19:00", CLOSED)]) + + self.assertEqual( + 0o600, printed_selectors_path(self.state).stat().st_mode & 0o777 + ) + + +class BothSurfacesShowOneListTests(unittest.TestCase): + """The differential: same state in, same list out, on both surfaces.""" + + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".recovery-", dir=REPO) + self.base = Path(self.temp.name) + self.addCleanup(self.temp.cleanup) + self.state = self.base / "state" + self.state.mkdir(mode=0o700) + self.home = self.base / "home" + (self.home / ".claude" / "projects" / "-srv-app").mkdir( + parents=True, mode=0o700 + ) + self.data = self.home / ".local" / "share" / "session-kit" + self.data.mkdir(parents=True, mode=0o700) + + # A conversation closed on purpose, with a real transcript so it is + # genuinely restorable, and the number it has everywhere else. + for uuid in (CLOSED, RETIRED): + ( + self.home / ".claude" / "projects" / "-srv-app" / f"{uuid}.jsonl" + ).write_text('{"type":"user"}\n', encoding="utf-8") + (self.data / "closed-sessions.jsonl").write_text( + "".join( + json.dumps(record) + "\n" + for record in ( + { + "provider": "claude", + "uuid": CLOSED, + "title": "Count the great lakes", + "title_source": "native", + "cwd": os.fspath(self.base), + "closed_at_unix_ms": 5_000, + "origin": "human", + "shpool_id": "", + "account_alias": "", + }, + # Closed long enough ago that its number finished its + # quarantine and went back into circulation. It still + # restores, so it is still offered, under its name, + # because it no longer has a number to be offered under. + { + "provider": "claude", + "uuid": RETIRED, + "title": "Older work", + "title_source": "native", + "cwd": os.fspath(self.base), + "closed_at_unix_ms": 3_000, + "origin": "human", + "shpool_id": "", + "account_alias": "", + }, + ) + ), + encoding="utf-8", + ) + (self.state / "terminal-numbers.json").write_text( + json.dumps({"schema_version": 1, "bindings": {f"ai:claude:{CLOSED}": 106}}), + encoding="utf-8", + ) + # A live conversation, which neither surface may offer. + (self.state / "inventory.json").write_text( + json.dumps( + { + "schema_version": 1, + "sessions": [ + {"provider": "claude", "identity": {"uuid": LIVE}} + ], + } + ), + encoding="utf-8", + ) + (self.state / "recovery-pending.json").write_text( + json.dumps( + { + "schema_version": 1, + "source_boot_id": "boot-1", + "source_daemon_generation": 1, + "sessions": { + "s-live": { + "provider": "claude", + "uuid": LIVE, + "cwd": "/srv/app", + "title": "Open right now", + "started_at_unix_ms": 1_000, + } + }, + "outside_agents": {}, + } + ), + encoding="utf-8", + ) + self.config = self.base / "config.json" + self.config.write_text( + json.dumps({"schema_version": 1, "aliases": {}}), encoding="utf-8" + ) + self.config.chmod(0o600) + + def env(self) -> dict[str, str]: + environment = os.environ.copy() + environment.update( + { + "HOME": os.fspath(self.home), + "SESSION_KIT_CONFIG": os.fspath(self.config), + "SESSION_KIT_STATE_DIR": os.fspath(self.state), + } + ) + environment.pop("CLAUDE_CONFIG_DIR", None) + return environment + + def core(self, *argv: str) -> str: + result = subprocess.run( + [sys.executable, os.fspath(REPO / "lib" / "session_inventory.py"), *argv], + env=self.env(), + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(0, result.returncode, result.stderr) + return result.stdout + + def picker_entries(self) -> list[dict]: + """The payload behind both screens.""" + return json.loads(self.core("recovery-pending", "list")).get("entries") or [] + + def payload_file(self) -> Path: + path = self.base / "recovery-payload.json" + path.write_text(self.core("recovery-pending", "list"), encoding="utf-8") + return path + + def picker_screen(self) -> str: + """The picker's Closed-sessions screen, drawn by the picker's code.""" + block = self.base / "picker_render.py" + block.write_text(shell_block(RENDER_BLOCK), encoding="utf-8") + drawn = subprocess.run( + [sys.executable, os.fspath(block), os.fspath(self.payload_file())], + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(0, drawn.returncode, drawn.stderr) + return drawn.stdout + + def cli_screen( + self, *, verb: str = "show_recovery", core: Path | None = None + ) -> tuple[int, str]: + """`sp recover`, run out of the file `sp` runs it out of.""" + inventory_core = core or REPO / "lib" / "session_inventory.py" + script = f""" +set -u +INVENTORY_CORE={os.fspath(inventory_core)!r} +sk_provider_name() {{ + case "$1" in claude) printf CLD;; codex) printf CDX;; shell) printf SH;; *) printf '?';; esac +}} +sk_die() {{ printf 'session-kit: %s\\n' "$*" >&2; return 1; }} +source {os.fspath(COMMANDS)!r} +restore_exact() {{ printf 'restore-exact %s %s %s\\n' "$1" "$2" "$3" >&2; }} +{verb} +""" + shown = subprocess.run( + ["bash", "-c", script], + env=self.env(), + text=True, + capture_output=True, + check=False, + ) + return shown.returncode, shown.stdout + shown.stderr + + def test_one_recover_invocation_uses_one_closed_ledger_snapshot(self) -> None: + """Forgetting between the former two reads cannot silently hide a row.""" + target = RETIRED + (self.state / "recovery-pending.json").write_text( + json.dumps( + { + "schema_version": 1, + "source_boot_id": "boot-race", + "source_daemon_generation": { + "pid": 10, + "process_start_ticks": 100, + }, + "sessions": { + "retained-target": { + "provider": "claude", + "uuid": target, + "cwd": os.fspath(self.base), + "title": "Older work", + "started_at_unix_ms": 2_000, + } + }, + "outside_agents": {}, + } + ), + encoding="utf-8", + ) + lifecycle.record_close_intent(self.state, provider="claude", uuid=target) + + marker = self.base / "forget-landed" + wrapper = self.base / "race_core.py" + wrapper.write_text( + """#!/usr/bin/env python3 +import os +from pathlib import Path +import subprocess +import sys + +real = os.environ["RACE_REAL_CORE"] +completed = subprocess.run( + [sys.executable, real, *sys.argv[1:]], text=True, capture_output=True +) +sys.stdout.write(completed.stdout) +sys.stderr.write(completed.stderr) +old_first_read = ( + sys.argv[1:3] == ["recovery-pending", "list"] + and "--without-closed" in sys.argv[1:] +) +one_snapshot_read = ( + sys.argv[1:3] == ["recovery-pending", "list"] + and "--stream-recovery-snapshot" in sys.argv[1:] +) +marker = Path(os.environ["RACE_MARKER"]) +if completed.returncode == 0 and (old_first_read or one_snapshot_read) and not marker.exists(): + marker.touch(mode=0o600) + subprocess.run( + [sys.executable, real, "closed-sessions", "forget", "claude", os.environ["RACE_UUID"]], + check=True, + stdout=subprocess.DEVNULL, + ) +raise SystemExit(completed.returncode) +""", + encoding="utf-8", + ) + wrapper.chmod(0o700) + environment = self.env() + environment.update( + { + "RACE_REAL_CORE": os.fspath( + REPO / "lib" / "session_inventory.py" + ), + "RACE_MARKER": os.fspath(marker), + "RACE_UUID": target, + } + ) + with mock.patch.dict(os.environ, environment, clear=True): + code, shown = self.cli_screen(core=wrapper) + + self.assertEqual(0, code, shown) + self.assertTrue(marker.exists(), "the real forget transaction did not land") + self.assertIn("Older work", shown) + remaining = (self.data / "closed-sessions.jsonl").read_text( + encoding="utf-8" + ) + self.assertNotIn(target, remaining) + + def test_one_list_projection_uses_one_closed_ledger_snapshot(self) -> None: + """The picker payload cannot pair suppression with a later ledger read.""" + target = RETIRED + (self.state / "recovery-pending.json").write_text( + json.dumps( + { + "schema_version": 1, + "source_boot_id": "boot-race", + "source_daemon_generation": { + "pid": 10, + "process_start_ticks": 100, + }, + "sessions": { + "retained-target": { + "provider": "claude", + "uuid": target, + "cwd": os.fspath(self.base), + "title": "Older work", + "started_at_unix_ms": 2_000, + } + }, + "outside_agents": {}, + } + ), + encoding="utf-8", + ) + lifecycle.record_close_intent(self.state, provider="claude", uuid=target) + environment = self.env() + real_snapshot = closed_sessions.closed_snapshot + snapshots = 0 + + @contextlib.contextmanager + def forget_after_first_snapshot(*args, **kwargs): + nonlocal snapshots + with real_snapshot(*args, **kwargs) as rows: + yield rows + snapshots += 1 + if snapshots == 1: + forgotten = closed_sessions.forget( + provider="claude", uuid=target, environ=environment + ) + self.assertEqual(1, forgotten) + + config = {"schema_version": 1, "state_dir": self.state, "aliases": {}} + with mock.patch.dict( + os.environ, environment, clear=True + ), mock.patch.object( + closed_sessions, "closed_snapshot", forget_after_first_snapshot + ): + payload = inventory_core.recovery_list_payload(config) + + self.assertEqual(1, snapshots) + self.assertIn(target, {row["uuid"] for row in payload["entries"]}) + remaining = (self.data / "closed-sessions.jsonl").read_text( + encoding="utf-8" + ) + self.assertNotIn(target, remaining) + + def picker_rows(self, screen: str) -> list[dict]: + rows: list[dict] = [] + for line in screen.splitlines(): + if not line.strip(): + continue + if line.startswith(" "): + rows[-1]["reason"] = line.strip() + continue + shape = re.fullmatch( + r"\s+(\S+)\s\s\[([A-Za-z]+)\] (.+?) \[([^\[\]]+)\]" + r"(?: \[review required: (.+)\])?", + line, + ) + self.assertIsNotNone(shape, f"picker row not parsed: {line!r}") + assert shape is not None + rows.append( + { + "token": shape.group(1), + "provider": shape.group(2).casefold(), + "name": shape.group(3), + "age": shape.group(4), + "reason": "", + } + ) + return rows + + def cli_rows(self, screen: str) -> list[dict]: + rows: list[dict] = [] + note = ", lost with its session" + for line in screen.splitlines(): + if not line.strip() or line.startswith("Bring one back"): + continue + if line.startswith(" "): + rows[-1]["reason"] = line.strip() + continue + shape = re.fullmatch(r"\s*(\S+)\s+(\S+)\s+(.+) · (.+)", line) + self.assertIsNotNone(shape, f"`sp recover` row not parsed: {line!r}") + assert shape is not None + age = shape.group(4) + if age.endswith(note): + age = age[: -len(note)] + rows.append( + { + "token": shape.group(1), + "provider": PROVIDER_WORDS.get(shape.group(2), shape.group(2)), + "name": shape.group(3), + "age": age, + "reason": "", + } + ) + return rows + + def test_both_surfaces_show_the_same_screen_for_the_same_state(self) -> None: + """Ruling 1, as they meet it: two renderers, one state, one list. + + The test this replaces called the same core command twice and asserted + the results matched, so it could not see the two screens disagree, + which is what they were doing about every age on the list and about + how to act on a row with no number. + """ + entries = self.picker_entries() + + # The right list first: the conversation closed on purpose is offered, + # the one that is open right now never is. + self.assertEqual([CLOSED, RETIRED], [row["uuid"] for row in entries]) + self.assertNotIn(LIVE, [row["uuid"] for row in entries]) + + picker_screen = self.picker_screen() + code, cli_screen = self.cli_screen() + self.assertEqual(0, code, cli_screen) + picker = self.picker_rows(picker_screen) + cli = self.cli_rows(cli_screen) + + self.assertEqual(len(entries), len(picker), picker_screen) + self.assertEqual( + [(row["token"], row["provider"], row["name"]) for row in picker], + [(row["token"], row["provider"], row["name"]) for row in cli], + ) + for shown, said in zip(picker, cli): + # The picker says which event it is timing; the age itself is the + # same measurement of the same field. + self.assertTrue( + shown["age"].endswith(said["age"]), + f"{shown['age']!r} is not {said['age']!r}", + ) + self.assertNotIn("unknown", shown["age"]) + self.assertEqual(shown["reason"], said["reason"]) + # And no screen prints a conversation identifier to get there. + for screen in (picker_screen, cli_screen): + for uuid in (CLOSED, RETIRED, LIVE): + self.assertNotIn(uuid[:8], screen) + + def test_the_picker_offers_the_session_closed_on_purpose(self) -> None: + """The exact failure: this screen could not offer what they closed.""" + entries = self.picker_entries() + + self.assertEqual(CLOSED, entries[0]["uuid"]) + self.assertEqual("Count the great lakes", entries[0]["display_name"]) + self.assertEqual(106, entries[0]["number"]) + self.assertTrue(entries[0]["restorable"]) + + def test_missing_closed_row_is_offered_and_diagnosed_on_the_cli(self) -> None: + """A tombstone cannot hide retained recovery evidence by itself.""" + ledger = self.data / "closed-sessions.jsonl" + retained = [ + json.loads(line) + for line in ledger.read_text(encoding="utf-8").splitlines() + if json.loads(line).get("uuid") != CLOSED + ] + ledger.write_text( + "".join(json.dumps(row) + "\n" for row in retained), + encoding="utf-8", + ) + lifecycle.record_close_intent( + self.state, provider="claude", uuid=CLOSED + ) + (self.state / "recovery-pending.json").write_text( + json.dumps( + { + "schema_version": 1, + "source_boot_id": "boot-1", + "source_daemon_generation": { + "pid": 10, + "process_start_ticks": 100, + }, + "sessions": { + "closed-before-replacement": { + "provider": "claude", + "uuid": CLOSED, + "cwd": os.fspath(self.base), + "title": "Count the great lakes", + "started_at_unix_ms": 5_000, + } + }, + "outside_agents": {}, + } + ), + encoding="utf-8", + ) + + entries = self.picker_entries() + self.assertIn(CLOSED, {row["uuid"] for row in entries}) + code, shown = self.cli_screen() + self.assertEqual(0, code, shown) + self.assertIn(f"close-intent inconsistency for claude:{CLOSED}", shown) + self.assertIn("row is missing", shown) + self.assertIn("Count the great lakes", shown) + + def test_a_conversation_open_outside_the_kit_is_not_offered(self) -> None: + """Ruling 3 does not care whose terminal it is open in. + + The inventory's outside collection was dropped before the live set was + built, so an exact conversation running outside the kit was listed as + restorable. The picker caught it at the last moment; `sp restore` did + not catch it at all. + """ + (self.state / "inventory.json").write_text( + json.dumps( + { + "schema_version": 1, + "sessions": [{"provider": "claude", "identity": {"uuid": LIVE}}], + "outside_agents": [ + { + "provider": "claude", + "identity": {"uuid": CLOSED, "confidence": "exact"}, + } + ], + } + ), + encoding="utf-8", + ) + + entries = self.picker_entries() + + self.assertEqual([RETIRED], [row["uuid"] for row in entries]) + + def test_a_row_with_no_number_is_acted_on_by_the_name_it_shows(self) -> None: + """Ruling 1's "either way in", for the row that has no number. + + The screen printed a dash where the selector belongs while the parser + quietly accepted a short id it never showed, and `sp recover` printed + that short id, a conversation identifier, as the way in. Both + surfaces now offer the same row the same way: by its name. + """ + entries = self.picker_entries() + retired = next(row for row in entries if row["uuid"] == RETIRED) + self.assertIsNone(retired["number"]) + self.assertTrue(retired["restorable"]) + + picker = self.picker_rows(self.picker_screen()) + self.assertEqual("—", picker[1]["token"]) + self.assertEqual("Older work", picker[1]["name"]) + + chosen = self.select("Older work") + self.assertEqual(0, chosen.returncode, chosen.stderr) + self.assertEqual([retired["short_id"]], chosen.stdout.split()) + + code, shown = self.cli_screen(verb='restore_listed "older work"') + self.assertEqual(0, code, shown) + self.assertIn(f"restore-exact claude {RETIRED} {self.base}", shown) + self.assertIn("Restored Older work.", shown) + + def select(self, answer: str) -> subprocess.CompletedProcess: + """The picker's selection parser, run as the picker runs it.""" + return self.select_from(self.payload_file(), answer) + + def select_from(self, payload: Path, answer: str) -> subprocess.CompletedProcess: + """The same parser, against a screen that was drawn earlier. + + The picker builds its payload once per screen and acts out of that + file, so this is how it answers a word typed at a screen the state has + moved on from. + """ + block = self.base / "picker_select.py" + block.write_text(shell_block(SELECT_BLOCK), encoding="utf-8") + return subprocess.run( + [ + sys.executable, + os.fspath(block), + os.fspath(payload), + answer, + os.fspath(REPO / "lib"), + ], + text=True, + capture_output=True, + check=False, + ) + + def test_a_selection_it_cannot_read_restores_nothing(self) -> None: + """A typo is not a command. + + The parser's refusal was caught and then undone: every comma-separated + fragment was added back, so "2,garbage" restored session 106 and + "2,,4" restored two sessions. + """ + for answer in ("106,garbage", "106,,42", "106,", "garbage", "0", "1-"): + with self.subTest(answer=answer): + refused = self.select(answer) + self.assertEqual(2, refused.returncode, refused.stdout) + self.assertEqual("", refused.stdout.strip()) + + # And the selection it can read still works. + chosen = self.select("106") + self.assertEqual(0, chosen.returncode, chosen.stderr) + self.assertEqual(["106"], chosen.stdout.split()) + + def shared_name_ledger(self, *, same_second: bool) -> None: + """Two numberless conversations that nothing named.""" + (self.state / "terminal-numbers.json").write_text( + json.dumps({"schema_version": 1, "bindings": {}}), encoding="utf-8" + ) + self.ledger( + [ + self.closed_record( + uuid, + "", + closed_at_unix_ms=5_000 if same_second else 5_000 - index * 1_000, + ) + for index, uuid in enumerate((CLOSED, RETIRED)) + ] + ) + + def test_two_rows_with_one_name_each_get_their_own_selector(self) -> None: + """Everything nobody named reads "unnamed", so the name cannot be it. + + Typing that one word restored the first row on one surface and was + refused on the other. Each of them now goes by the time of its own + event, which is fixed for as long as the row exists, is printed where + its number would be, and is the same string on both screens. + """ + self.shared_name_ledger(same_second=False) + + picker = self.picker_rows(self.picker_screen()) + code, shown = self.cli_screen() + self.assertEqual(0, code, shown) + cli = self.cli_rows(shown) + + tokens = [row["token"] for row in picker] + self.assertEqual(tokens, [row["token"] for row in cli]) + self.assertEqual(2, len(set(tokens)), tokens) + for token in tokens: + self.assertRegex(token, r"^@\d{2}:\d{2}(:\d{2})?$") + self.assertEqual(["unnamed", "unnamed"], [row["name"] for row in picker]) + + # Each token brings back its own row, on both surfaces. + entries = self.picker_entries() + for entry, token in zip(entries, tokens): + chosen = self.select(token) + self.assertEqual(0, chosen.returncode, chosen.stderr) + self.assertEqual([entry["short_id"]], chosen.stdout.split()) + code, restored = self.cli_screen(verb=f'restore_listed "{token}"') + self.assertEqual(0, code, restored) + self.assertIn(f"restore-exact claude {entry['uuid']}", restored) + + # And the word they share is nobody's selector now. + refused = self.select("unnamed") + self.assertEqual(2, refused.returncode, refused.stdout) + code, refused_cli = self.cli_screen(verb='restore_listed "unnamed"') + self.assertEqual(2, code, refused_cli) + self.assertNotIn("restore-exact", refused_cli) + + def test_one_word_means_the_same_thing_on_both_surfaces(self) -> None: + """A closed shell is called "unnamed" too, and it is the ordinary case. + + The CLI counted the shell as a second answer and refused; the picker + never had a way to select it and restored the conversation. One typed + word, two surfaces, two outcomes -- on the mechanism that replaced the + missing selector. + """ + (self.state / "terminal-numbers.json").write_text( + json.dumps({"schema_version": 1, "bindings": {}}), encoding="utf-8" + ) + self.ledger( + [ + self.closed_record(CLOSED, "", closed_at_unix_ms=5_000), + { + "provider": "shell", + "uuid": "", + "title": "Idle shell", + "cwd": os.fspath(self.base), + "closed_at_unix_ms": 4_000, + "origin": "human", + "shpool_id": "s20260814-000000-1", + "account_alias": "", + }, + ] + ) + + picker = self.picker_rows(self.picker_screen()) + self.assertEqual(["unnamed", "unnamed"], [row["name"] for row in picker]) + + chosen = self.select("unnamed") + self.assertEqual(0, chosen.returncode, chosen.stderr) + entries = self.picker_entries() + conversation = next(row for row in entries if row["uuid"] == CLOSED) + self.assertEqual([conversation["short_id"]], chosen.stdout.split()) + + code, restored = self.cli_screen(verb='restore_listed "unnamed"') + self.assertEqual(0, code, restored) + self.assertIn(f"restore-exact claude {CLOSED}", restored) + + def test_a_selector_that_names_two_rows_is_refused(self) -> None: + """Same name, same second: nothing left to tell them apart. + + Both surfaces refuse rather than one of them guessing, which is the + standard the module already held short ids to. + """ + self.shared_name_ledger(same_second=True) + + refused = self.select("unnamed") + self.assertEqual(2, refused.returncode, refused.stdout) + self.assertEqual("", refused.stdout.strip()) + + code, shown = self.cli_screen(verb='restore_listed "unnamed"') + self.assertEqual(2, code, shown) + self.assertNotIn("restore-exact", shown) + + def ledger(self, records: list[dict]) -> None: + (self.data / "closed-sessions.jsonl").write_text( + "".join(json.dumps(record) + "\n" for record in records), + encoding="utf-8", + ) + + def closed_record(self, uuid: str, title: str, **overrides) -> dict: + record = { + "provider": "claude", + "uuid": uuid, + "title": title, + "cwd": os.fspath(self.base), + "closed_at_unix_ms": 5_000, + "origin": "human", + "shpool_id": "", + "account_alias": "", + } + record.update(overrides) + return record + + def test_every_made_up_name_reads_unnamed_on_both_screens(self) -> None: + """Ruling 4, on the labels the real machine was actually showing. + + None of these records carries provenance, manifests and pending + records never have any, so each was shown as though somebody had + chosen it. + """ + placeholders = ( + "the shell session", + "the Claude session", + "Idle shell", + "Claude", + "v2-35", + "v2-af", + "Claude in v2 at Aug 14 23:44", + ) + records = [] + for index, title in enumerate(placeholders, 10): + uuid = uuid_for(index) + ( + self.home / ".claude" / "projects" / "-srv-app" / f"{uuid}.jsonl" + ).write_text('{"type":"user"}\n', encoding="utf-8") + records.append( + self.closed_record(uuid, title, closed_at_unix_ms=9_000 - index) + ) + # One name a person typed, to prove the shape test is not a blanket. + typed = uuid_for(30) + ( + self.home / ".claude" / "projects" / "-srv-app" / f"{typed}.jsonl" + ).write_text('{"type":"user"}\n', encoding="utf-8") + records.append( + self.closed_record(typed, "Orphaned record", closed_at_unix_ms=8_000) + ) + self.ledger(records) + + picker = self.picker_rows(self.picker_screen()) + code, shown = self.cli_screen() + self.assertEqual(0, code, shown) + cli = self.cli_rows(shown) + + self.assertEqual(len(placeholders) + 1, len(picker)) + self.assertEqual([row["name"] for row in picker], [row["name"] for row in cli]) + self.assertEqual( + ["unnamed"] * len(placeholders) + ["Orphaned record"], + [row["name"] for row in picker], + ) + + def test_a_transcript_that_is_gone_says_so_on_both_screens(self) -> None: + """Ruling 5 for the class that had no way to say it. + + The ledger dropped the row, so this sentence could never print, while + the crash manifest offered the same conversation as a full restore. + """ + self.ledger([self.closed_record(UNREADABLE, "Gone with the disk")]) + (self.state / "terminal-numbers.json").write_text( + json.dumps( + {"schema_version": 1, "bindings": {f"ai:claude:{UNREADABLE}": 106}} + ), + encoding="utf-8", + ) + + picker = self.picker_rows(self.picker_screen()) + code, shown = self.cli_screen() + self.assertEqual(0, code, shown) + cli = self.cli_rows(shown) + + self.assertEqual(1, len(picker)) + self.assertEqual("Gone with the disk", picker[0]["name"]) + self.assertIn("no longer on this machine", picker[0]["reason"]) + self.assertEqual(picker[0]["reason"], cli[0]["reason"]) + + code, refused = self.cli_screen(verb="restore_listed 106") + self.assertEqual(1, code, refused) + self.assertIn("no longer on this machine", refused) + self.assertNotIn("restore-exact", refused) + + def test_a_conversation_whose_records_disagree_says_so_on_both_screens( + self, + ) -> None: + """The sentence that was unreachable, printing on both surfaces. + + Two launch records for one conversation that disagree about where it + ran. It is a Claude conversation with a transcript; it was being + called a plain shell with nothing to reopen. + """ + self.ledger([]) + (self.home / ".claude" / "projects" / "-srv-app" / f"{LOST}.jsonl").write_text( + '{"type":"user"}\n', encoding="utf-8" + ) + (self.state / "recovery-pending.json").write_text( + json.dumps( + { + "schema_version": 1, + "source_boot_id": "boot-1", + "source_daemon_generation": 1, + "sessions": { + "s-one": { + "provider": "claude", + "uuid": LOST, + "cwd": os.fspath(self.base), + "title": "Orphaned record", + "started_at_unix_ms": 9_000, + }, + "s-two": { + "provider": "claude", + "uuid": LOST, + "cwd": os.fspath(self.base / "other"), + "title": "Orphaned record", + "started_at_unix_ms": 9_000, + }, + }, + "outside_agents": {}, + } + ), + encoding="utf-8", + ) + (self.state / "terminal-numbers.json").write_text( + json.dumps({"schema_version": 1, "bindings": {f"ai:claude:{LOST}": 110}}), + encoding="utf-8", + ) + + picker = self.picker_rows(self.picker_screen()) + code, shown = self.cli_screen() + self.assertEqual(0, code, shown) + cli = self.cli_rows(shown) + + self.assertEqual(1, len(picker)) + self.assertIn("launch records disagree", picker[0]["reason"]) + self.assertNotIn("plain shell", picker[0]["reason"]) + self.assertEqual(picker[0]["reason"], cli[0]["reason"]) + + code, refused = self.cli_screen(verb="restore_listed 110") + self.assertEqual(1, code, refused) + self.assertIn("launch records disagree", refused) + self.assertNotIn("plain shell", refused) + self.assertNotIn("restore-exact", refused) + + def transcript_path(self, uuid: str) -> Path: + return self.home / ".claude" / "projects" / "-srv-app" / f"{uuid}.jsonl" + + def write_transcript(self, uuid: str) -> Path: + path = self.transcript_path(uuid) + path.write_text('{"type":"user"}\n', encoding="utf-8") + return path + + @staticmethod + def at(day: int, hour: int, minute: int = 0) -> int: + """One local wall-clock moment, in the milliseconds a record holds. + + Built through local time on purpose: the selector a shared name falls + back to is the local clock face of the row's own event, so a fixture + that wants two events to print the same face has to mean the same + face on the machine running the test. + """ + return int(datetime.datetime(2026, 8, day, hour, minute).timestamp() * 1000) + + def selectors(self) -> dict[str, str]: + return {row["uuid"]: row["selector"] for row in self.picker_entries()} + + @staticmethod + def handle(row: dict) -> str: + """What the picker passes on once a word has chosen a row. + + Its number when it has one, and otherwise the conversation's own id -- + which no screen prints and nothing accepts as input. + """ + number = row["number"] + return str(number) if isinstance(number, int) else str(row["short_id"]) + + def test_a_printed_selector_never_names_a_different_conversation(self) -> None: + """The word they read means the row they read it beside, or nothing. + + Two conversations named Alpha share the name, so each goes by the + clock face of its own event: `@19:00` and `@20:00`. They read that + screen. Before they type, `@19:00`'s transcript goes missing -- so it + stays listed as history only, with no selector -- and a third Alpha + closes the NEXT DAY at the same time of day, taking the word `@19:00` + for itself. `sp restore @19:00` then restored that third conversation + and said "Restored Alpha.", which is the name of the one they asked for. + Nothing on the screen could tell them a different conversation opened. + """ + read_row, kept, newcomer = uuid_for(70), uuid_for(71), uuid_for(72) + (self.state / "terminal-numbers.json").write_text( + json.dumps({"schema_version": 1, "bindings": {}}), encoding="utf-8" + ) + for uuid in (read_row, kept): + self.write_transcript(uuid) + self.ledger( + [ + self.closed_record(read_row, "Alpha", closed_at_unix_ms=self.at(13, 19)), + self.closed_record(kept, "Alpha", closed_at_unix_ms=self.at(13, 20)), + ] + ) + + # The screen they read, printed by the code that prints it. + code, screen = self.cli_screen() + self.assertEqual(0, code, screen) + printed = self.selectors() + token = printed[read_row] + self.assertEqual("@19:00", token) + self.assertEqual("@20:00", printed[kept]) + self.assertIn(token, screen) + + # The list changes under them: the row they read can no longer come back, + # and another Alpha closes a day later at the same time of day. + self.transcript_path(read_row).unlink() + self.write_transcript(newcomer) + self.ledger( + [ + self.closed_record(read_row, "Alpha", closed_at_unix_ms=self.at(13, 19)), + self.closed_record(kept, "Alpha", closed_at_unix_ms=self.at(13, 20)), + self.closed_record(newcomer, "Alpha", closed_at_unix_ms=self.at(14, 19)), + ] + ) + rebuilt = self.selectors() + self.assertEqual("", rebuilt[read_row]) + self.assertEqual(token, rebuilt[newcomer]) + + # They type what they read. It named one conversation; it does not name + # that one now, so it names nothing. + code, refused = self.cli_screen(verb=f'restore_listed "{token}"') + self.assertEqual(1, code, refused) + self.assertNotIn("restore-exact", refused) + self.assertNotIn("Restored", refused) + self.assertIn("was printed beside a different conversation", refused) + self.assertIn("sp recover", refused) + + # And the word that still means its own row still brings it back. + code, restored = self.cli_screen(verb=f'restore_listed "{rebuilt[kept]}"') + self.assertEqual(0, code, restored) + self.assertIn(f"restore-exact claude {kept}", restored) + + def test_the_picker_acts_on_the_list_it_printed(self) -> None: + """The other surface's half of the same promise. + + The picker builds its payload once and both draws and acts out of that + one file, so a rebuild between the two cannot move a word onto another + row. This pins that: the same token, against the screen it was printed + from, still answers for the conversation it was printed beside -- even + after the state underneath says something else. + """ + read_row, kept, newcomer = uuid_for(70), uuid_for(71), uuid_for(72) + (self.state / "terminal-numbers.json").write_text( + json.dumps({"schema_version": 1, "bindings": {}}), encoding="utf-8" + ) + for uuid in (read_row, kept): + self.write_transcript(uuid) + self.ledger( + [ + self.closed_record(read_row, "Alpha", closed_at_unix_ms=self.at(13, 19)), + self.closed_record(kept, "Alpha", closed_at_unix_ms=self.at(13, 20)), + ] + ) + drawn = self.base / "drawn-screen.json" + drawn.write_text(self.core("recovery-pending", "list"), encoding="utf-8") + printed = { + row["uuid"]: row + for row in json.loads(drawn.read_text(encoding="utf-8"))["entries"] + } + + self.transcript_path(read_row).unlink() + self.write_transcript(newcomer) + self.ledger( + [ + self.closed_record(read_row, "Alpha", closed_at_unix_ms=self.at(13, 19)), + self.closed_record(kept, "Alpha", closed_at_unix_ms=self.at(13, 20)), + self.closed_record(newcomer, "Alpha", closed_at_unix_ms=self.at(14, 19)), + ] + ) + self.assertEqual("@19:00", self.selectors()[newcomer]) + + chosen = self.select_from(drawn, "@19:00") + self.assertEqual(0, chosen.returncode, chosen.stderr) + self.assertEqual([self.handle(printed[read_row])], chosen.stdout.split()) + + def test_a_name_of_digits_and_spaces_means_one_row_on_both_screens(self) -> None: + """The headline claim, in the shape it did not consider. + + A conversation a person named `2 4`, sitting beside sessions 2 and 4. + The picker split the typed word on whitespace before deciding what + kind of answer it was, so it read a name as a list of numbers and + restored Beta and Gamma; `sp restore` matched the whole word and + brought back the conversation named `2 4`. One typed word, two + screens, two entirely different answers. + """ + beta, gamma = uuid_for(20), uuid_for(40) + for uuid in (CLOSED, beta, gamma): + self.write_transcript(uuid) + (self.state / "terminal-numbers.json").write_text( + json.dumps( + { + "schema_version": 1, + "bindings": {f"ai:claude:{beta}": 2, f"ai:claude:{gamma}": 4}, + } + ), + encoding="utf-8", + ) + self.ledger( + [ + self.closed_record(CLOSED, "2 4", closed_at_unix_ms=5_000), + self.closed_record(beta, "Beta", closed_at_unix_ms=4_000), + self.closed_record(gamma, "Gamma", closed_at_unix_ms=3_000), + ] + ) + + named = next(row for row in self.picker_entries() if row["uuid"] == CLOSED) + self.assertEqual("2 4", named["selector"]) + + chosen = self.select("2 4") + self.assertEqual(0, chosen.returncode, chosen.stderr) + self.assertEqual([named["short_id"]], chosen.stdout.split()) + + code, restored = self.cli_screen(verb='restore_listed "2 4"') + self.assertEqual(0, code, restored) + self.assertIn(f"restore-exact claude {CLOSED}", restored) + + # The numbers still mean the sessions that own them. + chosen = self.select("2,4") + self.assertEqual(0, chosen.returncode, chosen.stderr) + self.assertEqual(["2", "4"], chosen.stdout.split()) + + def test_a_name_that_is_a_number_never_takes_that_number(self) -> None: + """A person may call a conversation `2`, and session 2 may exist. + + The name group and the number column were settled separately, so both + rows answered to `2` -- and both surfaces sent it to the numbered one. + A selector belongs to one row on the whole list or it belongs to none, + so the named row goes by the time of its own event instead. + """ + beta = uuid_for(20) + for uuid in (CLOSED, beta): + self.write_transcript(uuid) + (self.state / "terminal-numbers.json").write_text( + json.dumps({"schema_version": 1, "bindings": {f"ai:claude:{beta}": 2}}), + encoding="utf-8", + ) + self.ledger( + [ + self.closed_record(CLOSED, "2", closed_at_unix_ms=self.at(13, 19)), + self.closed_record(beta, "Beta", closed_at_unix_ms=self.at(13, 18)), + ] + ) + + rows = {row["uuid"]: row for row in self.picker_entries()} + self.assertEqual("2", rows[beta]["selector"]) + self.assertEqual("@19:00", rows[CLOSED]["selector"]) + + for token, uuid in (("2", beta), ("@19:00", CLOSED)): + with self.subTest(token=token): + chosen = self.select(token) + self.assertEqual(0, chosen.returncode, chosen.stderr) + self.assertEqual([self.handle(rows[uuid])], chosen.stdout.split()) + code, restored = self.cli_screen(verb=f'restore_listed "{token}"') + self.assertEqual(0, code, restored) + self.assertIn(f"restore-exact claude {uuid}", restored) + + def two_alphas(self) -> None: + """Two conversations one name, so each goes by its own clock face.""" + (self.state / "terminal-numbers.json").write_text( + json.dumps({"schema_version": 1, "bindings": {}}), encoding="utf-8" + ) + for uuid in (uuid_for(70), uuid_for(71)): + self.write_transcript(uuid) + self.ledger( + [ + self.closed_record( + uuid_for(70), "Alpha", closed_at_unix_ms=self.at(13, 19) + ), + self.closed_record( + uuid_for(71), "Alpha", closed_at_unix_ms=self.at(13, 20) + ), + ] + ) + + def check(self, token: str, provider: str, uuid: str) -> subprocess.CompletedProcess: + return subprocess.run( + [ + sys.executable, + os.fspath(REPO / "lib" / "session_inventory.py"), + "recovery-selectors", + "check", + token, + provider, + uuid, + ], + env=self.env(), + text=True, + capture_output=True, + check=False, + ) + + def test_sp_recover_writes_down_the_screen_it_prints(self) -> None: + """The record is made from the bytes that were rendered, not a rebuild. + + `sp restore` has no frozen screen to act out of, so this is the only + thing that can tell it the word it was handed has changed rows. + """ + self.two_alphas() + + code, screen = self.cli_screen() + self.assertEqual(0, code, screen) + + agreed = self.check("@19:00", "claude", uuid_for(70)) + self.assertEqual(0, agreed.returncode, agreed.stderr) + self.assertEqual("agrees", json.loads(agreed.stdout)["verdict"]) + + moved = self.check("@19:00", "claude", uuid_for(72)) + self.assertEqual(4, moved.returncode, moved.stdout) + self.assertEqual("disagrees", json.loads(moved.stdout)["verdict"]) + + # A word nobody has been shown is not evidence either way, and saying + # so is not the same as saying it agrees. + unseen = self.check("@04:00", "claude", uuid_for(72)) + self.assertEqual(0, unseen.returncode, unseen.stderr) + self.assertEqual("unknown", json.loads(unseen.stdout)["verdict"]) + + def test_the_picker_writes_down_the_screen_it_draws(self) -> None: + """Both surfaces print the same words, so both record them. + + A word read off the picker and typed at `sp restore` is checked the + same way as one read off `sp recover`. + """ + self.two_alphas() + drawn = subprocess.run( + ["bash", "-c", f""" +set -u +SK_STATE_DIR={os.fspath(self.state)!r} +TEMP_FILES=() +new_temp() {{ NEW_TEMP=$(mktemp "$SK_STATE_DIR/$1.json.XXXXXX"); }} +STATUS_CMD={os.fspath(REPO / "bin" / "shpool_status")!r} +source {os.fspath(PICKER)!r} +recovery_list || exit 1 +printf 'unrecorded=%s\\n' "${{RECOVERY_UNRECORDED:-}}" +"""], + env=self.env(), + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(0, drawn.returncode, drawn.stderr) + self.assertIn("unrecorded=\n", drawn.stdout) + + agreed = self.check("@20:00", "claude", uuid_for(71)) + self.assertEqual(0, agreed.returncode, agreed.stderr) + self.assertEqual("agrees", json.loads(agreed.stdout)["verdict"]) + + def test_a_screen_that_could_not_be_written_down_says_so(self) -> None: + """The check is worth having only if its absence is visible. + + A screen that quietly failed to record itself would leave `sp restore` + unable to catch a word that has changed rows, while both of them went + on looking exactly as they do when it can. + """ + self.two_alphas() + # Something is in the way of the one file this writes. What it is does + # not matter; that the screen still prints, and still says the check + # is missing, does. + printed_selectors_path(self.state).mkdir() + + code, screen = self.cli_screen() + + self.assertEqual(0, code, screen) + self.assertIn("Alpha", screen) + self.assertIn("could not be written down", screen) + + def test_a_provider_that_exited_still_comes_back_under_its_real_name( + self, + ) -> None: + """A close after the provider exited used to be filed as a shell. + + The conversation is still there and still restorable; what exited was + a process. Recorded as the conversation it is, it comes back named. + """ + (self.data / "closed-sessions.jsonl").write_text( + json.dumps( + { + "provider": "claude", + "uuid": CLOSED, + "title": "Count the great lakes", + "title_source": "native", + "cwd": "/srv/app", + "closed_at_unix_ms": 7_000, + "origin": "human", + "shpool_id": "s-gone", + "account_alias": "", + } + ) + + "\n", + encoding="utf-8", + ) + + entries = self.picker_entries() + + self.assertEqual(1, len(entries)) + self.assertEqual("claude", entries[0]["provider"]) + self.assertEqual("Count the great lakes", entries[0]["display_name"]) + self.assertTrue(entries[0]["restorable"]) + self.assertEqual("", entries[0]["history_only_reason"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_recovery_pending.py b/tests/test_recovery_pending.py index 48d4c11..d647d50 100644 --- a/tests/test_recovery_pending.py +++ b/tests/test_recovery_pending.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy from concurrent.futures import ThreadPoolExecutor import json import os @@ -12,6 +13,7 @@ from tests.test_inventory import inventory_core, inventory_fixture, uuid_for from tests.support import REPO +from sessionkit_inventory import lifecycle, snapshot as snapshot_module, state_io def pending_session(provider: str, uuid: str, title: str) -> dict: @@ -20,6 +22,7 @@ def pending_session(provider: str, uuid: str, title: str) -> dict: "provider": provider, "uuid": uuid, "title": title, + "started_at_unix_ms": 1_700_000_000_000, "cwd": "/srv/project", "argv": argv, "command": " ".join(argv), @@ -67,6 +70,12 @@ def setUp(self) -> None: self.state = Path(self.temp.name) / "state" self.state.mkdir() self.state.chmod(0o700) + self.data = Path(self.temp.name) / "data" + self.data.mkdir(mode=0o700) + self.data_environment = mock.patch.dict( + os.environ, {"SESSION_KIT_DATA_DIR": str(self.data)} + ) + self.data_environment.start() self.pending_path = self.state / "recovery-pending.json" self.pending_path.write_text( json.dumps(pending_document(), sort_keys=True), encoding="utf-8" @@ -78,6 +87,11 @@ def setUp(self) -> None: "max_proc_nodes": 8192, "max_proc_depth": 32, } + paths = inventory_core._state_paths(self.config) + inventory_core.atomic_write_json( + paths["collection_sequence_floor"], + {"schema_version": 1, "last_collection_start": 1}, + ) self.boot_file = Path(self.temp.name) / "boot-id" self.old_boot_env = os.environ.get("SESSION_KIT_BOOT_ID_FILE") os.environ["SESSION_KIT_BOOT_ID_FILE"] = str(self.boot_file) @@ -87,8 +101,163 @@ def tearDown(self) -> None: os.environ.pop("SESSION_KIT_BOOT_ID_FILE", None) else: os.environ["SESSION_KIT_BOOT_ID_FILE"] = self.old_boot_env + self.data_environment.stop() self.temp.cleanup() + def test_failed_pending_publication_preserves_the_loss_for_retry(self) -> None: + """Loss evidence lands before inventory and manifest discard its source.""" + lost_uuid = uuid_for(91) + live_uuid = uuid_for(92) + generation = {"pid": 12345, "process_start_ticks": 67890} + boot_id = "retry-boot" + + def row(name: str, uuid: str) -> dict: + return { + "shpool_id_raw": name, + "shpool_id": name, + "provider": "codex", + "title": name, + "started_at_unix_ms": 1_700_000_000_000, + "identity": {"uuid": uuid, "confidence": "exact"}, + "recovery": { + "available": True, + "provider": "codex", + "uuid": uuid, + "cwd": "/srv/project", + "argv": ["codex", "resume", uuid], + "command": f"codex resume {uuid}", + }, + } + + def inventory(*rows: dict) -> dict: + return { + "schema_version": inventory_core.SCHEMA_VERSION, + "generated_at": "2026-08-15T12:00:00Z", + "source": "live", + "stale": False, + "warnings": [], + "daemon_generation": copy.deepcopy(generation), + "sessions": [copy.deepcopy(item) for item in rows], + "outside_agents": [], + } + + lost = row("lost", lost_uuid) + survivor = row("survivor", live_uuid) + previous = inventory(lost, survivor) + current = inventory(survivor) + paths = inventory_core._state_paths(self.config) + self.pending_path.unlink() + inventory_core.atomic_write_json(paths["inventory"], previous) + inventory_core.atomic_write_json( + paths["manifest"], + { + "schema_version": inventory_core.SCHEMA_VERSION, + "generated_at": previous["generated_at"], + "boot_id": boot_id, + "daemon_generation": copy.deepcopy(generation), + "sessions": { + item["shpool_id_raw"]: pending_session( + item["provider"], + item["identity"]["uuid"], + item["title"], + ) + for item in previous["sessions"] + }, + "outside_agents": {}, + }, + ) + lifecycle.record_close_intent( + self.state, provider="codex", uuid=lost_uuid + ) + real_atomic = inventory_core.atomic_write_json + + def run_snapshot(*, fail_pending: bool) -> dict: + def injected_atomic(path: Path, payload: object) -> None: + if fail_pending and path == paths["pending"]: + raise OSError("injected recovery-pending write failure") + real_atomic(path, payload) + + def enqueue(paths_arg, now, before, *, boot_id): + return inventory_core.enqueue_lost_conversations( + paths_arg, + now, + before, + boot_id=boot_id, + config=self.config, + ) + + with mock.patch.object( + inventory_core, "atomic_write_json", side_effect=injected_atomic + ): + return snapshot_module.snapshot( + write_state=True, + config=self.config, + schema_version=inventory_core.SCHEMA_VERSION, + load_config=lambda: self.config, + state_paths=inventory_core._state_paths, + state_lock=inventory_core.StateLock, + read_state_json=inventory_core._read_state_json, + collect_live=lambda _settings: copy.deepcopy(current), + boot_id_factory=lambda: boot_id, + persist_last_exact=lambda *_args, **_kwargs: None, + apply_provider_exit_states=lambda *_args, **_kwargs: None, + prune_inactive_state=lambda *_args, **_kwargs: None, + read_terminal_registry=lambda *_args, **_kwargs: { + "schema_version": inventory_core.SCHEMA_VERSION, + "boot_id": boot_id, + "next_number": 1, + "bindings": {}, + }, + read_terminal_retirements=lambda *_args, **_kwargs: {}, + apply_terminal_numbers=lambda _inv, registry, **_kwargs: registry, + terminal_retirement_payload=lambda retired, boot_id: { + "schema_version": inventory_core.SCHEMA_VERSION, + "boot_id": boot_id, + "retired": retired, + }, + atomic_write_json=real_atomic, + quarantine_orphaned_provider_untitled_markers=lambda *_args, **_kwargs: [], + update_recovery_state=inventory_core.update_recovery_state, + enqueue_lost_conversations=enqueue, + apply_session_origins=lambda *_args, **_kwargs: None, + capture_bounce_receipts=lambda _root: frozenset(), + capture_bounce_cleanup_generations=lambda _root: frozenset(), + capture_lifecycle_generations=lambda _root: frozenset(), + capture_origin_generations=lambda _root: frozenset(), + capture_provider_untitled_generations=lambda _root: frozenset(), + capture_session_color_generations=lambda _root: frozenset(), + prune_origins=lambda *_args, **_kwargs: 0, + publish_session_colors=lambda *_args, **_kwargs: 0, + cold_inventory=lambda message: inventory_core._cold_inventory( + message + ), + ) + + with self.assertRaisesRegex(OSError, "injected recovery-pending"): + run_snapshot(fail_pending=True) + self.assertFalse(self.pending_path.exists()) + self.assertEqual( + ["lost", "survivor"], + [ + item["shpool_id_raw"] + for item in inventory_core._read_state_json(paths["inventory"])[ + "sessions" + ] + ], + ) + self.assertEqual( + ["lost", "survivor"], + sorted( + inventory_core._read_state_json(paths["manifest"])["sessions"] + ), + ) + + run_snapshot(fail_pending=False) + queued = inventory_core.list_pending(self.config)["entries"] + self.assertIn(lost_uuid, {item["uuid"] for item in queued}) + projected = inventory_core.recovery_list_payload(self.config)["entries"] + self.assertIn(lost_uuid, {item["uuid"] for item in projected}) + def test_list_flattens_primary_and_queued_generations(self) -> None: listed = inventory_core.list_pending(self.config) entries = listed["entries"] @@ -97,11 +266,131 @@ def test_list_flattens_primary_and_queued_generations(self) -> None: {"primary", "queued"}, {entry["queue"] for entry in entries} ) self.assertTrue(all(entry["actionable"] for entry in entries)) + self.assertTrue( + all(entry["started_at_unix_ms"] == 1_700_000_000_000 for entry in entries) + ) self.assertEqual( {"old-claude", "old-codex", "older-codex"}, {entry["old_shpool_id"] for entry in entries}, ) + def test_manifest_preserves_login_timestamp_for_future_recovery(self) -> None: + live = inventory_core.build_inventory(*inventory_fixture(1), now=1_800_000_000) + source = live["sessions"][0] + manifest = inventory_core.recovery_manifest(live) + stored = manifest["sessions"][source["shpool_id_raw"]] + self.assertEqual(source["started_at_unix_ms"], stored["started_at_unix_ms"]) + + def test_duplicate_exact_identity_prefers_primary_and_acknowledges_all_evidence(self) -> None: + document = pending_document() + duplicate = pending_session("claude", uuid_for(1), "Newer title") + document["queued_generations"].append( + { + "source_boot_id": "boot-newest", + "source_daemon_generation": {"pid": 40, "process_start_ticks": 400}, + "sessions": {"newer-claude": duplicate}, + } + ) + self.pending_path.write_text(json.dumps(document), encoding="utf-8") + entries = inventory_core.list_pending(self.config)["entries"] + claude = next(entry for entry in entries if entry["uuid"] == uuid_for(1)) + self.assertEqual("primary", claude["queue"]) + self.assertEqual(1, claude["duplicate_count"]) + self.assertEqual(2, len(claude["evidence"])) + self.assertEqual([], claude["conflict_fields"]) + self.assertTrue(claude["actionable"]) + live = inventory_core.build_inventory(*inventory_fixture(2), now=1_800_000_000) + result = inventory_core.acknowledge_pending( + self.config, + claude["source_generation_key"], + claude["old_shpool_id"], + claude["uuid"], + collector=lambda _: json.loads(json.dumps(live)), + ) + self.assertTrue(result["evidence_retained"]) + self.assertIn(uuid_for(1), {item["uuid"] for item in result["remaining"]["entries"]}) + stored = json.loads(self.pending_path.read_text()) + self.assertIn("old-claude", stored["sessions"]) + self.assertEqual(2, len(stored["queued_generations"])) + + def test_a_missing_closed_row_keeps_tombstoned_recovery_acknowledgeable( + self, + ) -> None: + lifecycle.record_close_intent( + self.state, provider="claude", uuid=uuid_for(1) + ) + before = self.pending_path.read_bytes() + payload = inventory_core.list_pending(self.config) + target = next(row for row in payload["entries"] if row["uuid"] == uuid_for(1)) + self.assertIn(f"claude:{uuid_for(1)}", " ".join(payload["diagnostics"])) + live = inventory_core.build_inventory( + *inventory_fixture(1), now=1_800_000_000 + ) + + result = inventory_core.acknowledge_pending( + self.config, + target["source_generation_key"], + target["old_shpool_id"], + target["uuid"], + collector=lambda _: json.loads(json.dumps(live)), + ) + + self.assertTrue(result["evidence_retained"]) + self.assertEqual(before, self.pending_path.read_bytes()) + + def test_duplicate_queued_identity_prefers_newest_generation(self) -> None: + duplicate_uuid = uuid_for(44) + document = pending_document() + document["sessions"] = {} + document["queued_generations"] = [ + { + "source_boot_id": "boot-older", + "source_daemon_generation": {"pid": 30, "process_start_ticks": 300}, + "sessions": {"older": pending_session("codex", duplicate_uuid, "Older title")}, + }, + { + "source_boot_id": "boot-newer", + "source_daemon_generation": {"pid": 40, "process_start_ticks": 400}, + "sessions": {"newer": pending_session("codex", duplicate_uuid, "Newer title")}, + }, + ] + self.pending_path.write_text(json.dumps(document), encoding="utf-8") + entries = inventory_core.list_pending(self.config)["entries"] + self.assertEqual(1, len(entries)) + self.assertEqual("newer", entries[0]["old_shpool_id"]) + self.assertEqual("Newer title", entries[0]["title"]) + self.assertEqual(1, entries[0]["queue_index"]) + self.assertEqual(1, entries[0]["duplicate_count"]) + + def test_conflicting_duplicate_metadata_is_visible_but_not_actionable(self) -> None: + document = pending_document() + conflicting = pending_session("claude", uuid_for(1), "Moved title") + conflicting["cwd"] = "/srv/other-project" + document["queued_generations"].append( + { + "source_boot_id": "boot-conflict", + "source_daemon_generation": {"pid": 40, "process_start_ticks": 400}, + "sessions": {"moved-claude": conflicting}, + } + ) + self.pending_path.write_text(json.dumps(document), encoding="utf-8") + before = self.pending_path.read_bytes() + target = next( + item for item in inventory_core.list_pending(self.config)["entries"] + if item["uuid"] == uuid_for(1) + ) + self.assertFalse(target["actionable"]) + self.assertIn("cwd", target["conflict_fields"]) + with self.assertRaises(inventory_core.CollectionError): + inventory_core.acknowledge_pending( + self.config, + target["source_generation_key"], + target["old_shpool_id"], + target["uuid"], + collector=lambda _: {}, + ) + self.assertEqual(before, self.pending_path.read_bytes()) + def test_wrong_provider_or_non_live_collector_leaves_pending_bytes_unchanged(self) -> None: before = self.pending_path.read_bytes() key = inventory_core.source_generation_key( @@ -167,9 +456,12 @@ def collector(_: dict) -> dict: results = [future.result(timeout=10) for future in futures] self.assertEqual(2, len(results)) remaining = inventory_core.list_pending(self.config)["entries"] - self.assertEqual(["older-codex"], [item["old_shpool_id"] for item in remaining]) + self.assertEqual( + ["old-claude", "old-codex", "older-codex"], + [item["old_shpool_id"] for item in remaining], + ) stored = json.loads(self.pending_path.read_text()) - self.assertEqual({}, stored["sessions"]) + self.assertEqual({"old-claude", "old-codex"}, set(stored["sessions"])) self.assertEqual(1, len(stored["queued_generations"])) def test_daemon_change_queues_shpool_but_not_outside_root(self) -> None: @@ -196,6 +488,59 @@ def test_boot_change_queues_both_shpool_and_exact_outside_root(self) -> None: scopes[f"outside:codex:{uuid_for(77)}"], ) + def test_generation_pending_is_reproved_before_manifest_advance(self) -> None: + self._write_old_manifest(boot_id="old-boot", include_shpool=True) + self.boot_file.write_text("new-boot\n", encoding="utf-8") + paths = inventory_core._state_paths(self.config) + manifest_before = paths["manifest"].read_bytes() + replacement = self.state / ".prior-empty-pending" + inventory_core.atomic_write_json( + replacement, + { + "schema_version": inventory_core.SCHEMA_VERSION, + "source_boot_id": "old-boot", + "source_daemon_generation": { + "pid": 10, + "process_start_ticks": 100, + }, + "sessions": {}, + "outside_agents": {}, + }, + ) + real_prove = state_io._prove_published_descriptor + attacked = False + + def replace_after_proof(descriptor: int, path: Path) -> None: + nonlocal attacked + real_prove(descriptor, path) + if path == paths["pending"] and not attacked: + attacked = True + os.replace(replacement, path) + directory = os.open(self.state, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + + with mock.patch.object( + state_io, "_prove_published_descriptor", replace_after_proof + ), self.assertRaisesRegex( + inventory_core.CollectionError, + "pending recovery evidence changed before inventory/manifest advance", + ): + inventory_core.update_recovery_state(paths, self._new_inventory()) + + self.assertTrue(attacked) + self.assertEqual(manifest_before, paths["manifest"].read_bytes()) + inventory_core.update_recovery_state(paths, self._new_inventory()) + offered = inventory_core.flatten_pending( + inventory_core._read_state_json(paths["pending"]) + )["entries"] + self.assertEqual( + {uuid_for(1), uuid_for(77)}, + {entry["uuid"] for entry in offered}, + ) + def test_daemon_change_with_outside_only_creates_no_pending_queue(self) -> None: self._write_old_manifest(boot_id="same-boot", include_shpool=False) self.boot_file.write_text("same-boot\n", encoding="utf-8") diff --git a/tests/test_release.py b/tests/test_release.py index 8c12ce7..fb3ee75 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -163,7 +163,7 @@ def test_build_is_from_exact_commit_and_immutable(self) -> None: self.assertTrue(result["ok"]) metadata = json.loads((release / "RELEASE.json").read_text()) self.assertEqual(metadata["commit"], self.sha1) - self.assertEqual(3, metadata["schema_version"]) + self.assertEqual(5, metadata["schema_version"]) self.assertEqual(file_mode(release), 0o555) self.assertTrue( (release / "lib/sessionkit_inventory/__init__.py").is_file() @@ -172,6 +172,9 @@ def test_build_is_from_exact_commit_and_immutable(self) -> None: self.assertTrue( (release / "lib/sessionkit_inventory/lifecycle.py").is_file() ) + self.assertTrue( + (release / "lib/sessionkit_inventory/projects.py").is_file() + ) self.assertTrue( (release / "lib/sessionkit_inventory/providers.py").is_file() ) @@ -193,8 +196,10 @@ def test_current_verifier_accepts_bounded_legacy_release_schema(self) -> None: legacy_repo, _ = make_git_repo(legacy_base) for relative in ( "lib/sessionkit_inventory/__init__.py", + "lib/sessionkit_inventory/colors.py", "lib/sessionkit_inventory/common.py", "lib/sessionkit_inventory/lifecycle.py", + "lib/sessionkit_inventory/projects.py", "lib/sessionkit_inventory/providers.py", "lib/sessionkit_inventory/state_io.py", ): @@ -228,8 +233,10 @@ def test_current_verifier_accepts_bounded_legacy_release_schema(self) -> None: incomplete_repo, _ = make_git_repo(incomplete_base) for relative in ( "lib/sessionkit_inventory/__init__.py", + "lib/sessionkit_inventory/colors.py", "lib/sessionkit_inventory/common.py", "lib/sessionkit_inventory/lifecycle.py", + "lib/sessionkit_inventory/projects.py", "lib/sessionkit_inventory/providers.py", "lib/sessionkit_inventory/state_io.py", "lib/session_inventory.py", @@ -268,7 +275,9 @@ def test_verifier_rejects_unsupported_release_schema_types(self) -> None: release = self.tree.build(self.repo, self.sha1) metadata_path = release / "RELEASE.json" original = metadata_path.read_bytes() - for schema in (0, 4, True, "1"): + # One below the first schema and one above the current one, plus a + # bool and a string, which json round-trips but the verifier must not. + for schema in (0, 6, True, "1"): with self.subTest(schema=repr(schema)): metadata = json.loads(original) metadata["schema_version"] = schema @@ -430,8 +439,10 @@ def test_build_rejects_partial_inventory_package(self) -> None: for index, relative in enumerate( ( "lib/sessionkit_inventory/__init__.py", + "lib/sessionkit_inventory/colors.py", "lib/sessionkit_inventory/common.py", "lib/sessionkit_inventory/lifecycle.py", + "lib/sessionkit_inventory/projects.py", "lib/sessionkit_inventory/providers.py", "lib/sessionkit_inventory/state_io.py", ), @@ -498,6 +509,13 @@ def test_bootstrap_activate_and_rollback_pin_exact_releases(self) -> None: ) ) self.assertEqual(boot["result"], "launcher-installed-and-current-switched") + floor = self.tree.state_dir.parent / "collection-sequence-floor.json" + self.assertEqual( + {"schema_version": 1, "last_collection_start": 1}, + json.loads(floor.read_text(encoding="utf-8")), + ) + self.assertEqual(0o600, file_mode(floor)) + floor_bytes = floor.read_bytes() self.assertEqual(self.tree.root.joinpath("current").resolve(), release1) self.assertEqual( (self.tree.root / "launcher").read_bytes(), @@ -533,6 +551,116 @@ def test_bootstrap_activate_and_rollback_pin_exact_releases(self) -> None: self.assertEqual(rolled["action"], "rollback") self.assertEqual(rolled["previous_commit"], sha2) self.assertEqual(run([self.tree.bin_dir / "sp", "health"], env=env).stdout, "v1:sp:health\n") + self.assertEqual(floor_bytes, floor.read_bytes()) + + def test_bootstrap_copies_legacy_counter_and_preserves_existing_floor(self) -> None: + self.tree.build(self.repo, self.sha1) + make_sentinels(self.tree.home) + state = self.tree.state_dir.parent + counter = state / "collection-sequence.json" + counter.write_text( + json.dumps({"schema_version": 1, "last_collection_start": 87}) + "\n", + encoding="utf-8", + ) + counter.chmod(0o600) + + run( + [ + RELEASE_TOOL, + "bootstrap", + *self.tree.activation_args(self.sha1, self.backup), + ] + ) + + floor = state / "collection-sequence-floor.json" + self.assertEqual(87, json.loads(floor.read_text())["last_collection_start"]) + + original = b'{ "last_collection_start" : 87, "schema_version" : 1 }\n' + floor.write_bytes(original) + floor.chmod(0o600) + sha2 = commit_runtime(self.repo, "v2") + self.tree.build(self.repo, sha2) + run( + [ + RELEASE_TOOL, + "activate", + *self.tree.activation_args(sha2, self.backup), + ] + ) + self.assertEqual(original, floor.read_bytes()) + + def test_bootstrap_refuses_underivable_floor_with_exact_remedy(self) -> None: + self.tree.build(self.repo, self.sha1) + make_sentinels(self.tree.home) + state = self.tree.state_dir.parent + inventory = state / "inventory.json" + inventory.write_text('{"sessions":[]}\n', encoding="utf-8") + inventory.chmod(0o600) + release = self.tree.release_root / self.sha1 + expected = ( + "session-kit-release: " + + release_module.collection_floor_remedy( + state, release / "tools/reset-collection-order.py" + ) + + "\n" + ) + + refused = run( + [ + RELEASE_TOOL, + "bootstrap", + *self.tree.activation_args(self.sha1, self.backup), + ], + check=False, + ) + + self.assertNotEqual(0, refused.returncode) + self.assertEqual(expected, refused.stderr) + self.assertFalse((state / "collection-sequence-floor.json").exists()) + self.assertFalse((self.tree.root / "current").exists()) + + def test_floor_seed_preserves_each_existing_publication_lock_generation( + self, + ) -> None: + for generation in ("legacy", "versioned"): + with self.subTest(generation=generation): + state = self.tree.base / f"floor-lock-{generation}" + state.mkdir(mode=0o700) + counter = state / "collection-sequence.json" + counter.write_text( + '{"schema_version":1,"last_collection_start":23}\n', + encoding="utf-8", + ) + counter.chmod(0o600) + legacy = state / "inventory.lock" + if generation == "legacy": + legacy.write_bytes(b"legacy-lock-content\n") + legacy.chmod(0o600) + else: + legacy.write_bytes(release_module.LEGACY_INVENTORY_FENCE) + legacy.chmod(0o400) + versioned = state / "inventory-v2.lock" + versioned.write_bytes(b"versioned-lock-content\n") + versioned.chmod(0o600) + legacy_before = entry_evidence(legacy) + versioned_before = ( + entry_evidence(versioned) if generation == "versioned" else None + ) + + release_module.seed_collection_floor( + state, + self.tree.base / "reset-collection-order.py", + ) + + self.assertEqual(legacy_before, entry_evidence(legacy)) + if versioned_before is not None: + self.assertEqual(versioned_before, entry_evidence(versioned)) + self.assertEqual( + 23, + json.loads( + (state / "collection-sequence-floor.json").read_text() + )["last_collection_start"], + ) def test_rollback_dry_run_refuses_new_launch_records_without_mutation(self) -> None: release1 = self.tree.build(self.repo, self.sha1) @@ -684,6 +812,127 @@ def test_rollback_launch_gate_allows_legacy_and_refuses_unsafe_pairs(self) -> No ): release_module.validate_rollback_launch_records(self.tree.home) + def _capable_target(self) -> Path: + """A release directory whose runtime names the .launch suffix.""" + target = self.tree.home / "target-fixture" + (target / "bin").mkdir(parents=True, exist_ok=True) + (target / "bin" / "session_kit_common").write_text( + "# fixture\nfor suffix in '' .account .launch .prompt; do :; done\n", + encoding="utf-8", + ) + return target + + def _legacy_target(self) -> Path: + target = self.tree.home / "legacy-target-fixture" + (target / "bin").mkdir(parents=True, exist_ok=True) + (target / "bin" / "session_kit_common").write_text( + "# fixture shared shell helpers\n", encoding="utf-8" + ) + return target + + def _current_family_records(self) -> Path: + launch_dir = self.tree.home / ".local/state/shpool-start" + launch_dir.mkdir(parents=True, mode=0o700, exist_ok=True) + launch_dir.chmod(0o700) + exact_uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + files = { + "main2": f"claude\t/srv/project\t{exact_uuid}\tfork\n", + "main2.expected": ( + "claude\t/srv/project\tfixture-boot\t1700000000000" + f"\t1001\t10010\t10\t100\t{exact_uuid}\tfork\n" + ), + "main2.launch": "claude\t/srv/project\topus\tfork\n", + "main2.account": "claude\twren\n", + "main2.prompt": "continue the research\nsecond line of the prompt\n", + } + for name, content in files.items(): + path = launch_dir / name + path.write_text(content, encoding="utf-8") + path.chmod(0o600) + return launch_dir + + def test_rollback_launch_gate_passes_current_records_to_a_capable_target( + self, + ) -> None: + """The 16 Aug dead net: current records must not block a capable target.""" + launch_dir = self._current_family_records() + legacy_start = launch_dir / "main3" + legacy_expected = launch_dir / "main3.expected" + exact_uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + legacy_start.write_text( + f"claude\t/srv/project\t{exact_uuid}\n", encoding="utf-8" + ) + legacy_expected.write_text( + "claude\t/srv/project\tfixture-boot\t1700000000000" + f"\t1001\t10010\t10\t100\t{exact_uuid}\n", + encoding="utf-8", + ) + legacy_start.chmod(0o600) + legacy_expected.chmod(0o600) + self.assertEqual( + 2, + release_module.validate_rollback_launch_records( + self.tree.home, target_release=self._capable_target() + ), + ) + + def test_rollback_launch_gate_refuses_current_records_for_legacy_target( + self, + ) -> None: + self._current_family_records() + for target in (None, self._legacy_target()): + with self.assertRaisesRegex( + release_module.ReleaseError, + "new-format retained launch pair blocks older-release rollback", + ): + release_module.validate_rollback_launch_records( + self.tree.home, target_release=target + ) + + def test_lone_launch_request_is_a_valid_current_record(self) -> None: + """A .launch request legitimately exists alone before creation.""" + launch_dir = self.tree.home / ".local/state/shpool-start" + launch_dir.mkdir(parents=True, mode=0o700, exist_ok=True) + launch_dir.chmod(0o700) + request = launch_dir / "main2.launch" + request.write_text("claude\t/srv/project\topus\t\n", encoding="utf-8") + request.chmod(0o600) + self.assertEqual( + 1, + release_module.validate_rollback_launch_records( + self.tree.home, target_release=self._capable_target() + ), + ) + with self.assertRaisesRegex( + release_module.ReleaseError, + "new-format retained launch pair blocks older-release rollback", + ): + release_module.validate_rollback_launch_records(self.tree.home) + + def test_current_record_safety_proofs_still_refuse(self) -> None: + launch_dir = self._current_family_records() + target = self._capable_target() + prompt = launch_dir / "main2.prompt" + prompt.chmod(0o644) + with self.assertRaisesRegex( + release_module.ReleaseError, + "unsafe retained launch record type, owner, mode, link count, or size", + ): + release_module.validate_rollback_launch_records( + self.tree.home, target_release=target + ) + prompt.chmod(0o600) + launch = launch_dir / "main2.launch" + launch.unlink() + os.symlink("/does/not/exist", launch) + with self.assertRaisesRegex( + release_module.ReleaseError, + "unsafe retained launch record symlink", + ): + release_module.validate_rollback_launch_records( + self.tree.home, target_release=target + ) + def test_rollback_dry_run_refuses_unsafe_existing_creation_lock_read_only( self, ) -> None: diff --git a/tests/test_release_artifact.py b/tests/test_release_artifact.py index 7780a62..24b2bd2 100644 --- a/tests/test_release_artifact.py +++ b/tests/test_release_artifact.py @@ -10,24 +10,13 @@ import tempfile import unittest -from tests.support import REPO +from tests.support import REPO, source_tree_ignore class ReleaseArtifactTests(unittest.TestCase): def make_source(self, root: Path) -> tuple[Path, str]: source = root / "source" - shutil.copytree( - REPO, - source, - ignore=shutil.ignore_patterns( - ".git", - ".claude", - ".mypy_cache", - ".ruff_cache", - "__pycache__", - "*.pyc", - ), - ) + shutil.copytree(REPO, source, ignore=source_tree_ignore()) subprocess.run(["git", "init", "-q", source], check=True) subprocess.run( ["git", "-C", source, "config", "user.name", "Session Kit Tests"], @@ -128,7 +117,14 @@ def test_artifact_is_reproducible_and_bound_to_commit(self) -> None: names = archive.getnames() self.assertIn(f"{basename}/SOURCE.json", names) self.assertIn(f"{basename}/LICENSES/Apache-2.0.txt", names) + self.assertIn(f"{basename}/bin/reset-collection-order.py", names) self.assertIn(f"{basename}/deploy/session-kit-release", names) + self.assertIn( + f"{basename}/extras/statusline-quota-refresh.example", names + ) + self.assertNotIn(f"{basename}/LICENSES/MIT-maniple.txt", names) + self.assertNotIn(f"{basename}/docs/build-tracks-2026-08-12.md", names) + self.assertNotIn(f"{basename}/tools/reset-collection-order.py", names) self.assertTrue(all(not name.startswith("/") for name in names)) self.assertTrue( all(".." not in Path(name).parts for name in names), diff --git a/tests/test_release_gc.py b/tests/test_release_gc.py new file mode 100644 index 0000000..95bc2e9 --- /dev/null +++ b/tests/test_release_gc.py @@ -0,0 +1,154 @@ +"""The release collector runs to completion, or says why it did not. + +Releases accumulate one per roll. The collector that prunes them read +`/proc/<pid>/environ` for every same-uid process and treated a refusal as +"no proof, delete nothing", but `environ` is readable only for a dumpable +process, and `systemd --user` is same-uid and never dumpable. So the scan +aborted before the deletion loop on every systemd-user host, silently, from +the day it shipped: 152 release directories and 368 MB with no log line to +notice it by. + +A process whose environment cannot be read still contributes its command line +and its working directory, which is what a release reference actually looks +like. Refusals that really do mean "no proof" now name themselves. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import tempfile +import time +import unittest + +from tests.support import REPO + +REAPER = REPO / "bin" / "shpool_reaper" +DAY = 86400.0 + + +def release_id(index: int) -> str: + return f"{index:040x}" + + +class ReleaseCollectorTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix="release-gc-") + self.base = Path(self.temp.name) + self.home = self.base / "home" + self.state = self.home / ".local" / "state" / "session-kit" + self.state.mkdir(parents=True) + self.root = self.home / ".local" / "lib" / "session-kit" + self.releases = self.root / "releases" + self.releases.mkdir(parents=True) + self.proc = self.base / "proc" + self.proc.mkdir() + self.bin = self.base / "bin" + self.bin.mkdir() + shpool = self.bin / "shpool" + shpool.write_text( + '#!/usr/bin/env bash\necho \'{"sessions":[]}\'\n', encoding="utf-8" + ) + shpool.chmod(0o755) + + # Seven releases, all past the 14-day cutoff, oldest first. + now = time.time() + self.ids = [release_id(index) for index in range(1, 8)] + for offset, name in enumerate(self.ids): + directory = self.releases / name + (directory / "bin").mkdir(parents=True) + age = now - (30 - offset) * DAY + os.utime(directory, (age, age)) + (self.root / "current").symlink_to(self.releases / self.ids[-1]) + (self.state / "install.json").write_text( + json.dumps( + { + "installed_release": self.ids[-1], + "previous_release": self.ids[-2], + } + ), + encoding="utf-8", + ) + + def tearDown(self) -> None: + self.temp.cleanup() + + def write_process( + self, pid: int, *, cmdline: bytes, environ: bytes, readable: bool = True + ) -> None: + root = self.proc / str(pid) + root.mkdir() + (root / "cmdline").write_bytes(cmdline) + environ_path = root / "environ" + environ_path.write_bytes(environ) + if not readable: + # What systemd --user looks like from here: same uid, not dumpable. + environ_path.chmod(0o000) + + def run_reaper(self, *, proc_root: Path | None = None) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment.update( + { + "HOME": str(self.home), + "SESSION_KIT_STATE_DIR": str(self.state), + "SESSION_KIT_SHPOOL_CMD": str(self.bin / "shpool"), + "SESSION_KIT_PROC_ROOT": str(proc_root or self.proc), + "SESSION_KIT_DAEMON_PID": "10", + "SESSION_KIT_REAPER_SENTINEL": str(self.base / "absent"), + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_TEST_PLATFORM": "Linux", + "PYTHONDONTWRITEBYTECODE": "1", + } + ) + return subprocess.run( + [REAPER], + cwd=REPO, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=30, + ) + + def surviving(self) -> set[str]: + return {path.name for path in self.releases.iterdir()} + + def test_a_process_with_an_unreadable_environment_does_not_stop_the_sweep( + self, + ) -> None: + self.write_process( + 2254, cmdline=b"systemd\0--user\0", environ=b"HOME=/x\0", readable=False + ) + # A standing window still running an older release keeps it. + self.write_process( + 4242, + cmdline=f"bash\0{self.releases / self.ids[3]}/bin/shpool_login\0".encode(), + environ=b"", + ) + result = self.run_reaper() + self.assertIn("release GC removed 3 stale release(s)", result.stderr) + self.assertNotIn("release GC declined", result.stderr) + survivors = self.surviving() + # current, its recorded predecessor, the newest three, and the one a + # live process references. + self.assertEqual(set(self.ids[3:]), survivors) + + def test_an_unreadable_process_table_declines_out_loud(self) -> None: + result = self.run_reaper(proc_root=self.base / "no-such-proc") + self.assertIn("release GC declined", result.stderr) + self.assertNotIn("release GC removed", result.stderr) + self.assertEqual(set(self.ids), self.surviving()) + + def test_a_missing_install_record_declines_out_loud(self) -> None: + (self.state / "install.json").unlink() + self.write_process(2254, cmdline=b"bash\0", environ=b"") + result = self.run_reaper() + self.assertIn("release GC declined: install.json", result.stderr) + self.assertEqual(set(self.ids), self.surviving()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_repair_dismissal.py b/tests/test_repair_dismissal.py new file mode 100644 index 0000000..f27bb39 --- /dev/null +++ b/tests/test_repair_dismissal.py @@ -0,0 +1,142 @@ +"""Dismissing a watchdog row acknowledges the row that was READ, or nothing. + +The `a` screen hands out `d1`, `d2`, … and the dismissal used to resolve them +to bare array positions in watchdog-repairs.json, captured when the screen was +drawn. Retirement then began deleting records from the middle of that file on +roughly every 60-second sweep, while the modal read blocks with no timeout for +as long as a person reads the screen. So the numbers on screen stopped matching +the file: `d2` typed against a drawn list acknowledged whatever had shifted +into slot 2 -- another session's warning -- and printed "Dismissed 1 repair +failure." either way. The one they wanted gone stayed; one they never saw went. + +Found in review, 2026-08-15. Before that there was no test for dismissal at +all, which is why a second concurrent writer could be added to a positionally +indexed file without anything objecting. + +These drive the real shell functions, sourcing the same two files the picker +sources, so the identity check is exercised where it actually runs. +""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import tempfile +import time +import unittest + +REPO = Path(__file__).resolve().parents[1] + + +def record(shpool_id: str, title: str, *, age_days: float = 0.0) -> dict: + return { + "at_unix_ms": int((time.time() - age_days * 86400) * 1000), + "old_shpool_id": shpool_id, + "new_shpool_id": "", + "title": title, + "provider": "codex", + "outcome": "reported", + "reason": "no output for far longer than any normal pause", + "acknowledged": False, + } + + +class RepairDismissalTests(unittest.TestCase): + def setUp(self) -> None: + scratch = tempfile.TemporaryDirectory() + self.addCleanup(scratch.cleanup) + self.base = Path(scratch.name) + self.repairs = self.base / "watchdog-repairs.json" + + def write(self, *records: dict) -> None: + self.repairs.write_text( + json.dumps({"schema_version": 1, "repairs": list(records)}, indent=2), + encoding="utf-8", + ) + + def acknowledged(self) -> list[str]: + data = json.loads(self.repairs.read_text(encoding="utf-8")) + return [ + item["title"] for item in data["repairs"] if item.get("acknowledged") + ] + + def bash(self, script: str) -> str: + body = ( + f"source {REPO}/lib/sh/shpool_login_render.sh\n" + f"source {REPO}/lib/sh/shpool_login_actions.sh\n" + f'REPAIR_FILE={self.repairs}\n' + "REPAIR_INDEX=$(picker_repair_failure_rows | tail -n +2 | " + "grep -E '^d[0-9]+\t')\n" + script + ) + result = subprocess.run( + ["bash", "-c", body], + capture_output=True, + text=True, + env={"PATH": "/usr/bin:/bin", "HOME": str(self.base)}, + timeout=120, + ) + return result.stdout + result.stderr + + def test_dismissing_acknowledges_the_row_that_was_read(self) -> None: + self.write( + record("alpha", "Blueprint Audit"), + record("bravo", "Config Sweep"), + ) + output = self.bash("dismiss_repair_failure 1") + self.assertIn("Dismissed 1 repair failure.", output) + self.assertEqual(["Blueprint Audit"], self.acknowledged()) + + def test_a_list_that_moved_dismisses_nothing_and_says_so(self) -> None: + """The blocker: retirement shifts every index after the deletion. + + The screen was drawn with Blueprint Audit at d2. A sweep then retires + the record above it, so slot 1 now holds Config Sweep. Typing d2 must + acknowledge NEITHER -- the person was reading a list that no longer + exists, and the honest answer is to say so and redraw. + """ + self.write( + record("ghost", "Ghost A", age_days=9), + record("alpha", "Blueprint Audit"), + record("bravo", "Config Sweep"), + ) + output = self.bash( + # Render first, THEN mutate: exactly the order the race takes. + "python3 - <<'EDIT'\n" + "import json, pathlib\n" + f"path = pathlib.Path('{self.repairs}')\n" + "data = json.loads(path.read_text())\n" + "data['repairs'] = data['repairs'][1:]\n" + "path.write_text(json.dumps(data))\n" + "EDIT\n" + "dismiss_repair_failure 2" + ) + self.assertIn("That list changed while it was on screen", output) + self.assertNotIn("Dismissed 1 repair failure.", output) + # The important half: nothing else was acknowledged in its place. + self.assertEqual([], self.acknowledged()) + + def test_a_row_that_vanished_entirely_dismisses_nothing(self) -> None: + self.write(record("alpha", "Blueprint Audit"), record("bravo", "Config Sweep")) + output = self.bash( + "python3 - <<'EDIT'\n" + "import json, pathlib\n" + f"path = pathlib.Path('{self.repairs}')\n" + "data = json.loads(path.read_text())\n" + "data['repairs'] = [data['repairs'][0]]\n" + "path.write_text(json.dumps(data))\n" + "EDIT\n" + "dismiss_repair_failure 2" + ) + self.assertNotIn("Dismissed 1 repair failure.", output) + self.assertEqual([], self.acknowledged()) + + def test_a_number_that_was_never_on_the_screen_is_refused(self) -> None: + self.write(record("alpha", "Blueprint Audit")) + output = self.bash("dismiss_repair_failure 7") + self.assertIn("There is no repair 7 on this screen", output) + self.assertEqual([], self.acknowledged()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_restore_identity.py b/tests/test_restore_identity.py new file mode 100644 index 0000000..bcb328b --- /dev/null +++ b/tests/test_restore_identity.py @@ -0,0 +1,507 @@ +"""A restored conversation comes back as itself: same name, same color. + +The live regression (2026-08-12) was a restored session opening nameless while +the picker still showed its name. Restore starts a NEW provider process for an +old conversation, and that process reads its name and its color from the +provider's own store once, at start -- so both have to be in that store BEFORE +the launch, and a restore that only wrote the color left the window unnamed. + +Nothing here starts a real session: the command fixture's fake shpool stands in +for the session manager, and the provider-store half is exercised directly +against a sandbox HOME, so no live session, daemon, or provider profile is +touched. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import time +import unittest + +from tests.support import REPO, run +from tests.test_commands import CommandFixture, write_executable + +SP = REPO / "bin" / "sp" +CORE = REPO / "lib" / "session_inventory.py" +UUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + + +class RestoreBringsBackNameAndColorTests(unittest.TestCase): + def setUp(self) -> None: + self.fixture = CommandFixture() + self.addCleanup(self.fixture.close) + + def restore_env(self, **extra: str) -> dict[str, str]: + env = self.fixture.env() + env.update( + { + "SESSION_KIT_BACKGROUND": "1", + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "2", + "STUB_DYNAMIC_PROVIDER": "codex", + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "STUB_DYNAMIC_UUID": UUID, + "STUB_COLOR_LOG": str(self.fixture.base / "color.log"), + } + ) + env.update(extra) + return env + + def restore(self, **extra: str) -> subprocess.CompletedProcess[str]: + return run( + [SP, "restore-exact", "codex", UUID, self.fixture.project], + env=self.restore_env(**extra), + check=False, + ) + + def test_restore_writes_both_the_name_and_the_color(self) -> None: + result = self.restore() + self.assertEqual(0, result.returncode, result.stderr) + colors = (self.fixture.base / "color.log").read_text() + names = self.fixture.name_push_log.read_text() + self.assertIn(json.dumps(["color", "propagate", "codex", UUID]), colors) + self.assertIn(json.dumps(["alias", "push", "codex", UUID]), names) + + def test_a_name_that_could_not_be_written_is_said_out_loud(self) -> None: + result = self.restore(STUB_ALIAS_PUSH_FAIL="1") + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("the window opens unnamed", result.stderr) + + def test_a_half_written_name_is_said_out_loud(self) -> None: + """Codex writes two surfaces and the status bar reads one of them.""" + result = self.restore(STUB_ALIAS_PUSH_PARTIAL="1") + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("reached only part of this conversation", result.stderr) + + def test_a_color_that_could_not_be_written_is_said_out_loud(self) -> None: + result = self.restore(STUB_COLOR_PROPAGATE_FAIL="1") + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("the window opens in its own color", result.stderr) + + def test_both_writes_happen_before_the_provider_is_launched(self) -> None: + """Order is the whole point: the provider reads these once, at start.""" + events = self.fixture.base / "restore-order.log" + result = self.restore( + STUB_COLOR_LOG=os.fspath(events), + STUB_NAME_PUSH_LOG=os.fspath(events), + FAKE_SHPOOL_LOG=os.fspath(events), + ) + self.assertEqual(0, result.returncode, result.stderr) + observed = events.read_text(encoding="utf-8").splitlines() + self.assertEqual( + ["color", "propagate", "codex", UUID], json.loads(observed[0]) + ) + self.assertEqual(["alias", "push", "codex", UUID], json.loads(observed[1])) + self.assertRegex(observed[2], r"^attach s[0-9]{8}-[0-9]{6}-[0-9]+$") + self.assertEqual(3, len(observed), observed) + + +class CreationSaysWhatHappenedTests(unittest.TestCase): + """Track C: a prebake or color failure at creation must say so. + + The restore half was pinned from the start; these two lines shipped + untested, which is how the same class of silence gets back in. + """ + + def setUp(self) -> None: + self.fixture = CommandFixture() + self.addCleanup(self.fixture.close) + + def create(self, **extra: str) -> subprocess.CompletedProcess[str]: + env = self.fixture.env() + env.update( + { + "SESSION_KIT_BACKGROUND": "1", + "SESSION_KIT_NO_PREBAKE": "1", + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "2", + "STUB_DYNAMIC_PROVIDER": "claude", + "STUB_DYNAMIC_UUID": UUID, + "STUB_DYNAMIC_CWD": str(self.fixture.project), + } + ) + env.update(extra) + return run( + [SP, "new", "claude"], + env=env, + cwd=self.fixture.project, + check=False, + ) + + def test_a_color_that_did_not_land_at_creation_is_said_out_loud(self) -> None: + result = self.create(STUB_COLOR_PROPAGATE_FAIL="1") + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("takes the kit color from its next start", result.stderr) + + def test_a_color_that_landed_says_nothing(self) -> None: + result = self.create() + self.assertEqual(0, result.returncode, result.stderr) + self.assertNotIn("could not be written", result.stderr) + + def test_a_disabled_prebake_is_quiet(self) -> None: + """Choosing the kill switch is not a failed creation step.""" + result = self.create() + self.assertEqual(0, result.returncode, result.stderr) + self.assertNotIn("first-frame color step", result.stderr) + + def test_a_failed_prebake_is_said_out_loud(self) -> None: + """Run the attempted path and make its throwaway TUI fail harmlessly.""" + fake_bin = self.fixture.base / "prebake-bin" + fake_bin.mkdir() + for command in ("claude", "script", "sleep"): + write_executable(fake_bin / command, "#!/usr/bin/env bash\nexit 1\n") + result = self.create( + SESSION_KIT_NO_PREBAKE="0", + PATH=f"{fake_bin}:{os.environ['PATH']}", + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("The first-frame color step did not finish", result.stderr) + + def create_on_account(self, profile: Path, **extra: str): + """`sp new claude --account fixture`, on a stubbed enrolled profile.""" + env = self.fixture.env() + env.update( + { + "SESSION_KIT_BACKGROUND": "1", + "SESSION_KIT_NO_PREBAKE": "1", + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "2", + "STUB_DYNAMIC_PROVIDER": "claude", + "STUB_DYNAMIC_UUID": UUID, + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "STUB_ACCOUNT_PROFILE": str(profile), + } + ) + env.update(extra) + return run( + [SP, "new", "claude", "--account", "fixture"], + env=env, + cwd=self.fixture.project, + check=False, + ) + + def test_a_session_on_an_account_also_gets_the_first_frame_step(self) -> None: + """The gap the operator hit: their session runs on an enrolled profile. + + The first-frame colour step was skipped outright whenever an account + was named, so a session on a chosen profile opened with no colour at + all while a default-profile one opened already coloured. Proven by + making the throwaway TUI fail: a step that is SKIPPED says nothing, + and a step that RUNS and fails says so. + """ + profile = self.fixture.base / "account-profile" + (profile / "projects").mkdir(parents=True) + fake_bin = self.fixture.base / "prebake-account-bin" + fake_bin.mkdir() + for command in ("claude", "script", "sleep"): + write_executable(fake_bin / command, "#!/usr/bin/env bash\nexit 1\n") + + result = self.create_on_account( + profile, + SESSION_KIT_NO_PREBAKE="0", + PATH=f"{fake_bin}:{os.environ['PATH']}", + ) + + self.assertIn("The first-frame color step did not finish", result.stderr) + + def test_an_account_session_with_the_step_off_stays_quiet(self) -> None: + """The kill switch still means the same thing on every session.""" + profile = self.fixture.base / "quiet-profile" + (profile / "projects").mkdir(parents=True) + + result = self.create_on_account(profile) + + self.assertNotIn("first-frame color step", result.stderr) + + def prebake_tui(self, directory: Path) -> Path: + """A throwaway TUI that behaves like the real one. + + It honours CLAUDE_CONFIG_DIR, answers `/color`, and -- the property + that used to cost the whole timeout -- does NOT exit when its input + closes. Only a signal ends it. + + The property this stub exists to get right: **no transcript until it + is typed at.** Real Claude Code creates the transcript file when it + writes its first record, and in a throwaway the first record is the + one `/color` itself produces -- verified against claude 2.1.233, where + a throwaway that is never typed at writes no transcript at all. This + stub used to write its transcript before reading a byte of input, + which is a shape the real binary cannot produce. That is the single + property the whole feeder depends on, so a feeder that waited for the + transcript BEFORE typing passed this test while shipping a + first-frame colour step that could never land a colour on anything. + """ + directory.mkdir(exist_ok=True) + write_executable( + directory / "claude", + "#!/usr/bin/env bash\n" + 'uuid=""\n' + 'while [[ $# -gt 0 ]]; do [[ $1 == --session-id ]] && uuid=$2; shift; done\n' + 'root=${CLAUDE_CONFIG_DIR:-$HOME/.claude}\n' + 'directory="$root/projects/-fixture"\n' + 'transcript="$directory/$uuid.jsonl"\n' + "while IFS= read -r line; do\n" + " line=${line%$'\\r'}\n" + ' case "$line" in\n' + " /color*)\n" + ' mkdir -p "$directory" || exit 1\n' + ' [[ -s "$transcript" ]] ||' + ' printf \'{"type":"attachment","sessionId":"%s"}\\n\'' + ' "$uuid" > "$transcript"\n' + " printf '" + '{"type":"agent-color","agentColor":"%s","sessionId":"%s"}\\n\' ' + '"${line#/color }" "$uuid" >> "$transcript"\n' + " ;;\n" + " esac\n" + "done\n" + "sleep 300\n", + ) + return directory + + def test_a_prebake_that_succeeds_on_an_account_leaves_no_process_behind( + self, + ) -> None: + """The happy path of the central mechanism, which nothing covered. + + Every other pre-bake test stubs `claude`, `script` and `sleep` to + `exit 1`, so they prove only that the step RUNS and FAILS. This one + proves the half that matters: the throwaway mints its conversation + INSIDE the account's own profile with the colour the kit picked, the + step reports no failure, and nothing is left running afterwards -- + the leak that put a four-day-old provider pair on the operator's box. + """ + profile = self.fixture.base / "success-profile" + (profile / "projects").mkdir(parents=True) + fake_bin = self.prebake_tui(self.fixture.base / "prebake-success-bin") + + started = time.monotonic() + result = self.create_on_account( + profile, + SESSION_KIT_NO_PREBAKE="0", + PATH=f"{fake_bin}:{os.environ['PATH']}", + ) + elapsed = time.monotonic() - started + + self.assertNotIn("The first-frame color step did not finish", result.stderr) + # What the person actually notices. The step used to sleep seven + # fixed seconds and then wait out a fourteen-second timeout killing a + # TUI that does not exit when its input closes -- about fifteen + # seconds of silence before the window opened, on every session. It + # now stops as soon as the colour record exists. + self.assertLess(elapsed, 10.0, f"creation took {elapsed:.1f}s") + minted = sorted((profile / "projects" / "-fixture").glob("*.jsonl")) + self.assertEqual(1, len(minted), f"transcripts: {minted}") + uuid = minted[0].stem + records = [ + json.loads(line) + for line in minted[0].read_text(encoding="utf-8").splitlines() + if line + ] + self.assertIn( + {"type": "agent-color", "agentColor": "orange", "sessionId": uuid}, + records, + ) + # Nothing ambient: the conversation belongs to the account profile. + self.assertEqual( + [], sorted((self.fixture.home / ".claude" / "projects").glob("*/*.jsonl")) + ) + # The window is armed to RESUME that exact conversation, which is what + # makes the colour native in its very first frame. + records = sorted( + path for path in self.fixture.start.iterdir() if path.is_file() + ) + armed = [ + path.read_text(encoding="utf-8") + for path in records + if not path.name.endswith((".expected", ".launch", ".account")) + ] + self.assertTrue(armed, f"no start record in {records}") + self.assertIn(f"\t{uuid}\tresume\n", armed[0]) + # And the throwaway is gone -- this is the leak that put a four-day-old + # provider pair on the operator's box. + self.assertEqual([], self.processes_carrying(uuid)) + # The fake shpool never really opens the pre-baked conversation, so + # the post-launch proof cannot pass in this fixture. That single + # complaint is the fixture's limit; anything else would be real. + self.assertIn("could not confirm that Claude started", result.stderr) + + def processes_carrying(self, uuid: str) -> list[str]: + """Every live process whose argv still names this conversation.""" + found = [] + for entry in Path("/proc").iterdir(): + if not entry.name.isdigit(): + continue + try: + argv = (entry / "cmdline").read_bytes() + except OSError: + continue + if uuid.encode() in argv: + found.append(argv.replace(b"\0", b" ").decode(errors="replace")) + return found + + +class ProviderStoreRoundTripTests(unittest.TestCase): + """The half a fake shpool cannot prove: what the restored window reads.""" + + def sandbox(self, base: Path) -> tuple[dict[str, str], Path, Path]: + home = base / "home" + (home / ".claude" / "sessions").mkdir(parents=True, mode=0o700) + project = home / ".claude" / "projects" / "-srv-project" + project.mkdir(parents=True, mode=0o700) + transcript = project / f"{UUID}.jsonl" + transcript.write_text( + json.dumps({"type": "user", "message": {"content": "hello"}}) + "\n", + encoding="utf-8", + ) + config = base / "session-kit.json" + config.write_text( + json.dumps({"schema_version": 1, "aliases": {}}), encoding="utf-8" + ) + config.chmod(0o600) + state = base / "state" + state.mkdir(mode=0o700) + state.chmod(0o700) + env = { + **os.environ, + "HOME": os.fspath(home), + "SESSION_KIT_CONFIG": os.fspath(config), + "SESSION_KIT_STATE_DIR": os.fspath(state), + "SESSION_KIT_CODEX_AUTOTITLE": "0", + } + return env, home, transcript + + def core(self, env: dict[str, str], *arguments: str, check: bool = True): + return run([CORE, *arguments], env=env, check=check) + + def records(self, transcript: Path, kind: str) -> list[dict]: + found = [] + for line in transcript.read_text(encoding="utf-8").splitlines(): + try: + item = json.loads(line) + except ValueError: + continue + if isinstance(item, dict) and item.get("type") == kind: + found.append(item) + return found + + def test_a_named_and_colored_conversation_survives_a_wiped_store(self) -> None: + with self.subTest("round trip"): + import tempfile + + with tempfile.TemporaryDirectory(prefix=".restore-id-", dir=REPO) as raw: + env, home, transcript = self.sandbox(Path(raw)) + self.core(env, "alias", "set", "claude", UUID, "Log Review") + self.core(env, "color", "set", "claude", UUID, "cyan") + + # The state a restore finds after the provider replaced its own + # store contents: the kit still holds the name and the color, + # the provider surfaces no longer carry either. + (home / ".claude" / "sessions" / f"{UUID}.nameintent").unlink() + transcript.write_text( + json.dumps({"type": "user", "message": {"content": "hello"}}) + + "\n", + encoding="utf-8", + ) + + self.core(env, "alias", "push", "claude", UUID) + self.core(env, "color", "propagate", "claude", UUID) + + intent = home / ".claude" / "sessions" / f"{UUID}.nameintent" + self.assertTrue(intent.is_file()) + self.assertEqual("Log Review", intent.read_text().strip()) + self.assertEqual( + ["Log Review"], + [item["agentName"] for item in self.records(transcript, "agent-name")], + ) + self.assertEqual( + ["cyan"], + [ + item["agentColor"] + for item in self.records(transcript, "agent-color") + ], + ) + + def test_push_says_nothing_when_nothing_named_the_conversation(self) -> None: + import tempfile + + with tempfile.TemporaryDirectory(prefix=".restore-none-", dir=REPO) as raw: + env, _, _ = self.sandbox(Path(raw)) + result = self.core(env, "alias", "push", "claude", UUID) + payload = json.loads(result.stdout) + self.assertEqual("", payload["title"]) + self.assertEqual([], payload["provider_title_pushes"]) + + def test_a_partial_push_is_not_reported_as_success(self) -> None: + """Index written, database title not: exit 3, not 0.""" + import tempfile + + with tempfile.TemporaryDirectory(prefix=".restore-partial-", dir=REPO) as raw: + base = Path(raw) + env, home, _ = self.sandbox(base) + codex_uuid = UUID + (home / ".codex").mkdir(parents=True, mode=0o700) + # A thread store with no row for this conversation: the index append + # lands, the title update finds nothing to update. + import sqlite3 + + database = home / ".codex" / "state_5.sqlite" + connection = sqlite3.connect(database) + connection.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT," + " first_user_message TEXT, updated_at REAL)" + ) + connection.commit() + connection.close() + self.core(env, "alias", "set", "codex", codex_uuid, "Log Review") + result = self.core(env, "alias", "push", "codex", codex_uuid, check=False) + self.assertEqual(3, result.returncode, result.stdout) + payload = json.loads(result.stdout) + self.assertEqual("Log Review", payload["title"]) + self.assertTrue(payload["provider_title_pushes"]) + self.assertTrue(payload["provider_title_warnings"]) + + def test_a_name_typed_in_the_window_is_never_overwritten(self) -> None: + """The automatic tier is not safe to re-assert over a human rename. + + A person renames in the window; no inventory build has adopted it yet. + Pushing the kit's derived title would replace their name in the + provider's own store -- and the evidence adoption needs to recover it. + """ + import tempfile + + with tempfile.TemporaryDirectory(prefix=".restore-human-", dir=REPO) as raw: + env, home, _ = self.sandbox(Path(raw)) + # The kit holds a derived title; the person has since renamed the + # conversation in the window, and the ownership record says so. + config = json.loads(Path(env["SESSION_KIT_CONFIG"]).read_text()) + config["automatic_titles"] = {f"claude:{UUID}": "Auto Name"} + config["name_ownership"] = { + f"claude:{UUID}": {"owner": "human", "at": "2026-08-13T00:00:00Z"} + } + Path(env["SESSION_KIT_CONFIG"]).write_text(json.dumps(config)) + result = self.core(env, "alias", "push", "claude", UUID) + self.assertEqual("", json.loads(result.stdout)["title"]) + self.assertFalse( + (home / ".claude" / "sessions" / f"{UUID}.nameintent").exists() + ) + + def test_push_fails_loudly_when_a_held_name_reaches_no_surface(self) -> None: + import tempfile + + with tempfile.TemporaryDirectory(prefix=".restore-fail-", dir=REPO) as raw: + base = Path(raw) + env, home, _ = self.sandbox(base) + self.core(env, "alias", "set", "claude", UUID, "Log Review") + # A provider profile that has gone: no sessions directory, no + # transcripts. The kit still holds the name, and a restore into + # this state produces a nameless window -- which the caller must + # be able to report. + for path in (home / ".claude" / "sessions", home / ".claude" / "projects"): + subprocess.run(["rm", "-rf", os.fspath(path)], check=True) + result = self.core(env, "alias", "push", "claude", UUID, check=False) + self.assertEqual(1, result.returncode, result.stdout) + self.assertEqual("Log Review", json.loads(result.stdout)["title"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sandbox_guard.py b/tests/test_sandbox_guard.py new file mode 100644 index 0000000..1cfb88d --- /dev/null +++ b/tests/test_sandbox_guard.py @@ -0,0 +1,379 @@ +"""The suite may not reach the machine's real session manager. Proof. + +Every test here fails on a tree without ``tests/sandbox_guard.py`` armed, and +that is the point: the first one drives the real provider-exit path from the +real bashrc and measures where ``shpool`` resolved. Without the guard it +resolves to the real binary and the fixture's ``shpool detach`` -- carrying +``SHPOOL_SESSION_NAME=main2`` -- goes to a live daemon on its default socket. + +Read ``tests/sandbox_guard.py`` for why PATH is the only lever: the hand-back +in ``bashrc/shpool.bashrc`` runs ``command shpool detach``, hardcoded on +purpose, so ``SESSION_KIT_SHPOOL_CMD`` cannot redirect it. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess +import unittest + +from tests import sandbox_guard +from tests.support import REPO +from tests.test_lifecycle_shell import BASHRC, ProviderExitShellHarness + + +def bashrc_path_line() -> str: + """The bashrc's own PATH prepend, quoted rather than modelled. + + A test that rebuilt this line by hand would prove something about the copy. + Reading it out of the file under test keeps the measurement honest, and + :meth:`PathLineTests.test_the_bashrc_still_prepends_the_home_directories` + fails if the line ever moves. + """ + for line in BASHRC.read_text(encoding="utf-8").splitlines(): + if line.startswith("export PATH="): + return line + raise AssertionError(f"no `export PATH=` line in {BASHRC}") + + +def inside_the_test_sandbox(path: str) -> bool: + real = os.path.realpath(path) + root = os.path.realpath(os.fspath(REPO)) + return real == root or real.startswith(root + os.sep) + + +class PathLineTests(unittest.TestCase): + def test_the_bashrc_still_prepends_the_home_directories(self) -> None: + # If this line stops prepending $HOME directories, PATH stops being a + # lever a fixture can use and the guard needs rethinking, not patching. + line = bashrc_path_line() + self.assertIn('$HOME/.cargo/bin', line) + self.assertIn('$HOME/.local/bin', line) + self.assertTrue(line.rstrip('"').endswith("$PATH"), line) + + +class RealSessionManagerIsOutOfReachTests(ProviderExitShellHarness): + """The differential. Fails on an unguarded tree, passes on a guarded one.""" + + def resolved_shpool(self) -> str: + """Where ``shpool`` resolves for the session shell this fixture builds. + + Same environment the harness hands the bashrc, same PATH prepend the + bashrc performs, same lookup ``command shpool`` performs. What comes + back is the file the hand-back would have executed. + """ + completed = subprocess.run( + [ + "bash", + "--noprofile", + "--norc", + "-c", + f"{bashrc_path_line()}\ncommand -v shpool || true", + ], + env=self.environment(), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=20, + ) + return completed.stdout.strip() + + def test_the_session_shell_resolves_shpool_inside_the_sandbox(self) -> None: + resolved = self.resolved_shpool() + self.assertTrue( + resolved and inside_the_test_sandbox(resolved), + "this fixture's session shell would run a shpool outside the test " + f"sandbox: {resolved!r}. That binary talks to the live daemon on " + "its default socket, and the fixture carries SHPOOL_SESSION_NAME=" + "main2.", + ) + + def test_the_hand_back_to_the_picker_is_refused_not_delivered(self) -> None: + """The real path, end to end: crash, refused reopen, hand back. + + This is the shape of ``test_a_reopen_that_is_refused_lands_in_a_shell`` + in tests/test_lifecycle_shell.py -- the one path that reaches + ``__sk_detach_to_picker`` -- with the shpool call read back afterwards. + """ + # This differential predates the older lifecycle harness's private + # recording stub. Remove that fixture-local first choice so this test + # reaches the global refusing guard it exists to prove. + (self.bin / "shpool").unlink() + log = sandbox_guard.refusal_log() + before = log.stat().st_size if log.exists() else 0 + + self.crashing_provider() + completed = self.launch("exit\n") + + self.assertEqual(0, completed.returncode, completed.stderr) + # The fallback line only prints when the detach FAILED, which is what a + # refusal looks like from the shell's side. On an unguarded tree this + # line prints only because no live session happens to be called main2: + # detach a session that does exist and it succeeds, and an operator's + # window is thrown back to the picker mid-work. + self.assertIn("Shell opened", completed.stdout) + + refused: list[str] = [] + if log.exists(): + with log.open(encoding="utf-8") as handle: + handle.seek(before) + refused = [line for line in handle.read().splitlines() if line] + self.assertIn( + "shpool detach", + refused, + "the hand-back's `command shpool detach` was not intercepted; " + f"the guard recorded {refused!r} for this test. It went somewhere " + "else, and the only somewhere else is the real binary.", + ) + + +class HandBuiltEnvironmentsTests(unittest.TestCase): + """A suite that builds its own PATH cannot step outside the guard.""" + + def setUp(self) -> None: + self.real = shutil.which("shpool", path=sandbox_guard.INHERITED_PATH) + if self.real is None: + self.skipTest("no real shpool on this machine's inherited PATH") + self.real_dir = os.path.dirname(self.real) + + def hand_built(self) -> dict[str, str]: + # The shape several suites already use: a directory of their own, then + # a hard-coded tail. Here the tail is the directory the real binary + # actually lives in, which is the worst case the guard has to survive. + return { + "PATH": f"{self.real_dir}:/usr/bin:/bin", + "HOME": os.environ.get("HOME", "/tmp"), + "PYTHONDONTWRITEBYTECODE": "1", + } + + def test_subprocess_with_a_hand_built_path_lands_on_the_stub(self) -> None: + completed = subprocess.run( + ["bash", "--noprofile", "--norc", "-c", "command -v shpool || true"], + env=self.hand_built(), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual( + os.fspath(sandbox_guard.guard_dir() / "shpool"), + completed.stdout.strip(), + "a hand-built PATH reached past the guard", + ) + + def test_os_execve_with_a_hand_built_path_lands_on_the_stub(self) -> None: + # Nine suites fork and exec with a hand-built environment rather than + # going through subprocess. The fork shares this interpreter, so the + # patched os.execve reaches the child. + read_end, write_end = os.pipe() + pid = os.fork() + if pid == 0: # pragma: no cover - the child never returns + try: + os.dup2(write_end, 1) + os.close(read_end) + os.execve( + "/bin/bash", + ["bash", "--noprofile", "--norc", "-c", "command -v shpool"], + self.hand_built(), + ) + finally: + os._exit(127) + os.close(write_end) + with os.fdopen(read_end, encoding="utf-8") as handle: + output = handle.read().strip() + os.waitpid(pid, 0) + self.assertEqual( + os.fspath(sandbox_guard.guard_dir() / "shpool"), + output, + "an os.execve with a hand-built PATH reached past the guard", + ) + + def test_asking_for_absence_gives_absence_not_the_real_binary(self) -> None: + # tests/test_cli_help.py proves `sp help` answers on a machine with no + # session manager. The guard's stub would make that pass for the wrong + # reason, so the guard honours a request for absence -- and answers it + # by dropping the real binary's directory too. This PATH starts with + # that directory, which is the case that has to come back empty. + completed = subprocess.run( + ["bash", "--noprofile", "--norc", "-c", "command -v shpool || true"], + env=sandbox_guard.without_shpool(self.hand_built()), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual( + "", + completed.stdout.strip(), + "asking for no session manager produced one", + ) + + def test_the_stub_refuses_loudly_and_nonzero(self) -> None: + completed = subprocess.run( + ["shpool", "detach"], + env=self.hand_built(), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(sandbox_guard.REFUSAL_EXIT_CODE, completed.returncode) + self.assertIn("refused `shpool detach`", completed.stderr) + self.assertIn("sandboxed", completed.stderr) + + +class StateHomePinTests(unittest.TestCase): + """`__sk_state_root` is ${XDG_STATE_HOME:-$HOME/.local/state}.""" + + def test_an_inherited_state_home_is_replaced_by_the_fixture_one(self) -> None: + # The escape: a fixture copies os.environ, overrides HOME, and carries + # the operator's XDG_STATE_HOME into the child, which then writes every + # provider-bounce and session-color record outside the fixture. + home = "/nonexistent-fixture-home" + environment = { + "HOME": home, + "XDG_STATE_HOME": sandbox_guard._INHERITED_STATE_HOME or "", + } + if not environment["XDG_STATE_HOME"]: + environment["XDG_STATE_HOME"] = "/nonexistent-real-state" + sandbox_guard._INHERITED_STATE_HOME = "/nonexistent-real-state" + self.addCleanup( + setattr, sandbox_guard, "_INHERITED_STATE_HOME", None + ) + guarded = sandbox_guard.guarded_environment(environment) + self.assertEqual( + os.path.join(home, ".local", "state"), guarded["XDG_STATE_HOME"] + ) + + def test_an_absent_state_home_is_pinned_to_the_fixture_home(self) -> None: + home = "/nonexistent-fixture-home" + guarded = sandbox_guard.guarded_environment({"HOME": home}) + self.assertEqual( + os.path.join(home, ".local", "state"), guarded["XDG_STATE_HOME"] + ) + + def test_a_state_home_the_test_chose_is_left_alone(self) -> None: + chosen = "/nonexistent-fixture-home/elsewhere/state" + guarded = sandbox_guard.guarded_environment( + {"HOME": "/nonexistent-fixture-home", "XDG_STATE_HOME": chosen} + ) + self.assertEqual(chosen, guarded["XDG_STATE_HOME"]) + + def test_the_process_no_longer_carries_an_inherited_state_home(self) -> None: + if sandbox_guard._INHERITED_STATE_HOME is None: + self.assertNotIn("XDG_STATE_HOME", os.environ) + else: + self.assertNotEqual( + sandbox_guard._INHERITED_STATE_HOME, + os.environ.get("XDG_STATE_HOME"), + ) + + +class FixtureStubsStillWinTests(unittest.TestCase): + """The guard must not shadow a stub a fixture installed on purpose.""" + + def test_a_recording_stub_in_the_sandbox_takes_precedence(self) -> None: + import tempfile + + with tempfile.TemporaryDirectory(prefix=".sandbox-guard-", dir=REPO) as raw: + base = Path(raw) + binaries = base / "home" / ".local" / "bin" + log = base / "shpool.log" + stub = sandbox_guard.install_recording_shpool(binaries, log) + completed = subprocess.run( + ["bash", "--noprofile", "--norc", "-c", "command -v shpool"], + env={ + "PATH": f"{binaries}:{os.environ['PATH']}", + "SHPOOL_LOG": os.fspath(log), + }, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(os.fspath(stub), completed.stdout.strip()) + + subprocess.run( + ["shpool", "list", "--json"], + env={ + "PATH": f"{binaries}:{os.environ['PATH']}", + "SHPOOL_LOG": os.fspath(log), + }, + text=True, + stdout=subprocess.PIPE, + check=False, + ) + self.assertEqual(["list --json"], sandbox_guard.recorded_shpool_calls(log)) + self.assertEqual( + [], sandbox_guard.unexpected_shpool_calls(log, ("list --json",)) + ) + self.assertEqual( + ["list --json"], + sandbox_guard.unexpected_shpool_calls(log, ("detach",)), + ) + + +class PrivateDaemonOptOutTests(unittest.TestCase): + """The one legitimate escape, and it has to be spelled out to be taken.""" + + def test_a_private_basename_is_allowed(self) -> None: + self.assertEqual( + "sk-exitproof-pool", + sandbox_guard.private_daemon_name("sk-exitproof-pool"), + ) + + def test_the_guarded_basename_is_refused(self) -> None: + with self.assertRaises(AssertionError) as raised: + sandbox_guard.private_daemon_name("shpool") + self.assertIn("own basename", str(raised.exception)) + + def test_there_is_no_environment_switch_that_disables_the_guard(self) -> None: + # A switch is the accidental escape this module exists to prevent, so + # its absence is a contract, not an omission. The one marker the guard + # does read (ABSENT_MARKER) makes it stricter, and the test above + # proves it cannot produce the real binary. + source = (REPO / "tests" / "sandbox_guard.py").read_text(encoding="utf-8") + for name in ('environ.get("SESSION_KIT_TEST_REAL', "DISABLE_SANDBOX_GUARD"): + self.assertNotIn(name, source) + + def test_the_only_marker_the_guard_reads_can_only_remove_access(self) -> None: + import tempfile + from unittest import mock + + markers = [ + name + for name in dir(sandbox_guard) + if name.endswith("_MARKER") and not name.startswith("_") + ] + self.assertEqual(["ABSENT_MARKER"], markers) + with tempfile.TemporaryDirectory() as raw: + real_dir = os.path.realpath(raw) + fake_real = Path(real_dir) / "shpool" + fake_real.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + fake_real.chmod(0o755) + sandbox_guard._holder_cache.pop(real_dir, None) + try: + # Temporary directories are normally fixture-owned sandboxes. + # For this property test, this one stands in for a host PATH + # directory so the removal proof does not depend on the host + # having shpool installed. + with mock.patch.object( + sandbox_guard, + "_sandbox_roots", + return_value=(os.path.realpath(os.fspath(REPO)),), + ): + guarded = sandbox_guard.guarded_environment( + sandbox_guard.without_shpool( + {"PATH": f"{real_dir}:/usr/bin:/bin"} + ) + ) + self.assertNotIn(real_dir, guarded["PATH"].split(os.pathsep)) + finally: + sandbox_guard._holder_cache.pop(real_dir, None) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_sandbox_sweep.py b/tests/test_sandbox_sweep.py new file mode 100644 index 0000000..87d4842 --- /dev/null +++ b/tests/test_sandbox_sweep.py @@ -0,0 +1,320 @@ +"""The sweep has to know every name the suite can leave behind. + +`tests/sweep_sandboxes.py` deletes directories and kills processes, so it is +told what a sandbox looks like by two things and nothing else: a set of +prefixes read out of the test sources, and the random characters tempfile puts +after one. It used to be told by a list six entries long, written by hand +against a suite that hands tempfile over two hundred prefixes. The list was not +wrong when it was written. It went wrong the next time a fixture picked a name, +and it went wrong quietly: on 2026-08-19 the checkout held four stale +sandboxes and the list named two, so `.codex-fallback-` and `.watchdog-` stayed +where they were, and so would anything still running inside them. + +This file is the part that cannot go quietly stale. It walks the test sources +itself, with its own parse rather than the sweep's, and fails when a fixture +starts making a sandbox under a name the sweep would not recognise. The other +tests hold the safety end: what the sweep must refuse to remove even when a +prefix says otherwise. +""" + +from __future__ import annotations + +import ast +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +from tests.support import REPO + +from tests import sweep_sandboxes + + +TESTS = REPO / "tests" +# tempfile draws eight of these after the prefix. Any run of them is enough to +# assert on; the sweep does not depend on the count. +A_RANDOM_PART = "a1b2c3d4" + + +def sandbox_calls() -> list[tuple[Path, ast.Module, ast.Call]]: + """Every call in the suite that makes a directory tempfile names. + + Deliberately a second, independent walk. A guard that asked the sweep what + it found would agree with the sweep by construction and prove nothing. Each + call is returned with the tree it came from, because the enclosing function + is found by identity and only holds within one parse. + """ + found = [] + for path in sorted(TESTS.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + function = node.func + name = "" + if isinstance(function, ast.Attribute): + name = function.attr + elif isinstance(function, ast.Name): + name = function.id + if name in {"TemporaryDirectory", "mkdtemp"}: + found.append((path, tree, node)) + return found + + +def prefix_expression(node: ast.Call) -> ast.expr | None: + for entry in node.keywords: + if entry.arg == "prefix": + return entry.value + # suffix comes first in both signatures, so a positional prefix is second. + return node.args[1] if len(node.args) > 1 else None + + +def enclosing_parameters(tree: ast.Module, node: ast.Call) -> list[str]: + """The parameter names of the function `node` sits in.""" + for parent in ast.walk(tree): + if not isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if any(child is node for child in ast.walk(parent)): + return [argument.arg for argument in parent.args.args] + return [] + + +class TheSweepKnowsEveryNameTheSuiteCanMakeTests(unittest.TestCase): + # Derived once: it parses every module under tests/, and nothing here + # changes what it reads. + prefixes: frozenset[str] + + @classmethod + def setUpClass(cls) -> None: + cls.prefixes = sweep_sandboxes.sandbox_prefixes() + + def test_every_literal_prefix_in_the_suite_reached_the_sweep(self) -> None: + missing = [] + for path, _tree, node in sandbox_calls(): + expression = prefix_expression(node) + if not isinstance(expression, ast.Constant): + continue + if not isinstance(expression.value, str) or not expression.value: + continue + if expression.value not in self.prefixes: + missing.append( + f"{path.relative_to(REPO)}:{node.lineno} {expression.value!r}" + ) + self.assertEqual( + [], + missing, + "tests/sweep_sandboxes.py cannot see these sandbox prefixes, so an " + "interrupted run leaves them and anything running inside them:\n " + + "\n ".join(missing), + ) + + def test_no_fixture_hides_its_prefix_behind_an_expression(self) -> None: + """A name the sources do not spell out is a name the sweep cannot learn. + + One forwarding helper is allowed and used -- `sandbox(prefix)` in + tests/test_worktree_isolation.py -- because its callers still spell the + literal. Anything else (an f-string, a module constant, a value built at + runtime) has to change, not be added to a list here. + + A call with no prefix at all is fine only while it also has no `dir=`: + tempfile then works in the system temp directory, outside the checkout + and outside this sweep. Ask for a directory inside the checkout and the + name has to be one the sweep can recognise, because `tmpXXXXXXXX` is not + a name anything can safely claim at a repository root. + """ + unreadable = [] + for path, tree, node in sandbox_calls(): + where = f"{path.relative_to(REPO)}:{node.lineno}" + expression = prefix_expression(node) + if expression is None: + if any(entry.arg == "dir" for entry in node.keywords): + unreadable.append(f"{where} (asks for dir= with no prefix)") + continue + if isinstance(expression, ast.Constant): + continue + parameters = enclosing_parameters(tree, node) + if isinstance(expression, ast.Name) and expression.id in parameters: + continue + unreadable.append(f"{where} (prefix is {ast.unparse(expression)})") + self.assertEqual( + [], + unreadable, + "these sandboxes are named by something no parse can read; give " + "tempfile a string literal, or route the call through a helper that " + "takes the literal from its callers:\n " + "\n ".join(unreadable), + ) + + def test_the_derived_set_is_the_whole_suite_and_not_a_remnant(self) -> None: + """A scan that quietly returned almost nothing would look like success. + + The floor is far below the count today; it exists to fail loudly if the + parse ever stops finding call sites, which is precisely the shape of the + bug that made the hand-written list useless. + """ + self.assertGreater(len(self.prefixes), 100) + for known in (".session-kit-test-", ".sandbox-guard-", ".watchdog-"): + self.assertIn(known, self.prefixes) + + def test_a_prefix_only_matches_with_tempfiles_random_part_attached(self) -> None: + self.assertTrue( + sweep_sandboxes.is_a_sandbox_name( + ".watchdog-" + A_RANDOM_PART, self.prefixes + ) + ) + # The bare prefix, a hand-written suffix, and a path separator are all + # names tempfile cannot produce. + for name in (".watchdog-", ".watchdog-NOTES", ".watchdog-my-notes"): + self.assertFalse(sweep_sandboxes.is_a_sandbox_name(name, self.prefixes)) + + +class TheSweepRefusesToTouchTheRepositoryTests(unittest.TestCase): + def tracked_root_entries(self) -> set[str]: + listed = subprocess.run( + ["git", "ls-files"], + cwd=REPO, + text=True, + stdout=subprocess.PIPE, + check=True, + ).stdout.split() + return {name.split("/")[0] for name in listed} + + def test_no_derived_prefix_can_claim_source(self) -> None: + prefixes = sweep_sandboxes.sandbox_prefixes() + claimed = [ + f"{entry} claimed by {prefix!r}" + for entry in self.tracked_root_entries() | {".git"} + for prefix in prefixes + if entry.startswith(prefix) + ] + self.assertEqual([], claimed, "\n ".join(claimed)) + + def test_protected_names_survive_a_prefix_that_would_claim_them(self) -> None: + """The keep-list is checked before any prefix, so widening cannot reach it. + + `.gi` is not a prefix any fixture uses; it is the shortest thing that + would swallow `.git` and `.gitignore` by name shape alone, which is what + makes it the right probe for whether the keep-list is load-bearing. + """ + poisoned = frozenset({".gi", ".shellcheckr", ".githu"}) + for protected in sorted(sweep_sandboxes.NEVER_REMOVE): + self.assertFalse( + sweep_sandboxes.is_a_sandbox_name(protected, poisoned), + f"{protected} was not protected", + ) + # The probe is only meaningful if it really would have matched. + self.assertTrue(sweep_sandboxes.is_a_sandbox_name(".gitxyz", poisoned)) + + def test_the_keep_list_is_not_the_copy_ignore_rule(self) -> None: + """Two rules, opposite requirements for `.git`, kept apart on purpose. + + tests.support.SOURCE_ROOT_DOTTED omits `.git` because a COPY of the tree + must not carry history. This sweep DELETES, so it has to protect the one + entry that rule drops. Sharing a constant would put those a single edit + from each other. + """ + from tests.support import SOURCE_ROOT_DOTTED + + self.assertIn(".git", sweep_sandboxes.NEVER_REMOVE) + self.assertNotIn(".git", SOURCE_ROOT_DOTTED) + self.assertIsNot(sweep_sandboxes.NEVER_REMOVE, SOURCE_ROOT_DOTTED) + + +# A checkout the sweep can be pointed at without risking this one: the same +# layout (a `tests/` directory beside the root it will scan), a fixture module +# naming a prefix that appears nowhere in the real suite, and decoys. `.gi` is +# in there so the copied sweep really does derive a prefix that would swallow +# `.git`, and the keep-list is the only thing that stops it. +FIXTURE_MODULE = """ +import tempfile + +from somewhere import REPO + + +def build(): + return tempfile.TemporaryDirectory(prefix=".brand-new-fixture-", dir=REPO) + + +def poison(): + return tempfile.mkdtemp(prefix=".gi", dir=REPO) +""" + +THREE_HOURS = 3 * 3600 + + +class TheSweepLearnsANewPrefixWithoutBeingToldTests(unittest.TestCase): + """End to end, against a copy of the sweep in a checkout of its own.""" + + def setUp(self) -> None: + # Same filesystem as the code under test, like every other fixture here. + self.temporary = tempfile.TemporaryDirectory(prefix=".sweep-fixture-", dir=REPO) + self.root = Path(self.temporary.name) + (self.root / "tests").mkdir() + (self.root / "tests" / "sweep_sandboxes.py").write_text( + (REPO / "tests" / "sweep_sandboxes.py").read_text(encoding="utf-8"), + encoding="utf-8", + ) + (self.root / "tests" / "test_fixture.py").write_text( + FIXTURE_MODULE, encoding="utf-8" + ) + self.addCleanup(self.temporary.cleanup) + + def make(self, name: str, *, age: int = 0) -> Path: + path = self.root / name + path.mkdir() + (path / "contents").write_text("evidence\n", encoding="utf-8") + if age: + stamp = path.stat().st_mtime - age + os.utime(path, (stamp, stamp)) + return path + + def sweep(self, *arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + "python3", + os.fspath(self.root / "tests" / "sweep_sandboxes.py"), + *arguments, + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + + def test_a_prefix_no_one_added_to_the_sweep_is_still_swept(self) -> None: + stale = self.make(".brand-new-fixture-7x2qab19", age=THREE_HOURS) + fresh = self.make(".brand-new-fixture-0000zzzz") + unknown = self.make(".not-a-fixture-7x2qab19", age=THREE_HOURS) + + listed = self.sweep("--list") + + self.assertIn(".brand-new-fixture-7x2qab19", listed.stdout) + self.assertNotIn(".not-a-fixture", listed.stdout) + self.assertTrue(stale.is_dir(), "--list must remove nothing") + + removed = self.sweep() + + self.assertIn( + "removed stale sandbox .brand-new-fixture-7x2qab19", removed.stdout + ) + self.assertFalse(stale.exists()) + self.assertTrue(fresh.is_dir(), "a sandbox inside the grace period is live") + self.assertTrue(unknown.is_dir(), "an unknown name is somebody else's") + + def test_the_repository_survives_a_prefix_that_would_claim_it(self) -> None: + git_dir = self.make(".git", age=THREE_HOURS) + ignore = self.make(".gitignore", age=THREE_HOURS) + claimed = self.make(".gitxyz1234", age=THREE_HOURS) + + removed = self.sweep() + + self.assertTrue(git_dir.is_dir()) + self.assertTrue(ignore.is_dir()) + # The decoy proves the fixture's `.gi` prefix really did reach the + # sweep, so the two survivals above are the keep-list and not a miss. + self.assertFalse(claimed.exists()) + self.assertIn(".gitxyz1234", removed.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_self_name_exit_status.py b/tests/test_self_name_exit_status.py new file mode 100644 index 0000000..142ba73 --- /dev/null +++ b/tests/test_self_name_exit_status.py @@ -0,0 +1,81 @@ +"""`sp self-name` must not report success for a name that did not take. + +A root agent is told to name itself on its first substantive turn and to say +`name failed` when that does not work. The command used to `return 0` whatever +came back, so a self-name that never landed was indistinguishable from one that +did. Observed live on 2026-08-17: the call met the collector jam, was killed +while waiting, and the session ran for three hours unnamed with nothing in its +exit status to act on. +""" + +from __future__ import annotations + +import unittest +from unittest import mock + +import sys + +from tests.support import REPO + +sys.path.insert(0, str(REPO / "lib")) + +import session_inventory # noqa: E402 + + +CALLER = {"provider": "claude", "uuid": "2ace3498-903c-4ba6-b0f1-8e87edb6adac"} +KEY = "claude:2ace3498-903c-4ba6-b0f1-8e87edb6adac" +TITLE = "Session Kit README Remodel" + + +def result(*, stored=TITLE, state="ready"): + aliases = {KEY: stored} if stored is not None else {} + return { + "caller": dict(CALLER), + "title": TITLE, + "aliases": aliases, + "automatic_name_state": state, + } + + +class SelfNameExitStatusTests(unittest.TestCase): + def run_with(self, payload): + with ( + mock.patch.object( + session_inventory, "self_name_automatic_title", return_value=payload + ), + mock.patch.object(session_inventory, "_json_print"), + ): + return session_inventory._self_name_command({}, TITLE) + + def test_a_name_that_landed_and_shows_is_success(self) -> None: + self.assertEqual(0, self.run_with(result())) + + def test_a_name_absent_from_the_written_document_fails(self) -> None: + """The exact silent failure: the call returned, the name did not land.""" + + self.assertEqual(1, self.run_with(result(stored=None))) + + def test_a_name_written_for_a_different_title_fails(self) -> None: + self.assertEqual(1, self.run_with(result(stored="Something Else"))) + + def test_a_name_recorded_but_not_showing_is_not_success(self) -> None: + """`pending` means a retry is queued; the caller is not named yet.""" + + self.assertEqual(1, self.run_with(result(state="pending"))) + + def test_the_payload_is_printed_even_when_the_status_is_a_refusal(self) -> None: + printed = [] + with ( + mock.patch.object( + session_inventory, + "self_name_automatic_title", + return_value=result(stored=None), + ), + mock.patch.object(session_inventory, "_json_print", printed.append), + ): + session_inventory._self_name_command({}, TITLE) + self.assertEqual(1, len(printed)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_session_colors.py b/tests/test_session_colors.py index f4b1122..bec495c 100644 --- a/tests/test_session_colors.py +++ b/tests/test_session_colors.py @@ -9,12 +9,16 @@ import json import os from pathlib import Path +import re +import subprocess import sys import tempfile +import threading +import time import unittest from unittest import mock -from tests.support import REPO +from tests.support import REPO, THEME_COLORS CORE_PATH = REPO / "lib" / "session_inventory.py" if "session_inventory" in sys.modules: @@ -39,14 +43,19 @@ def test_session_color_is_stable_and_override_wins(self) -> None: first = inventory_core.session_color("codex", exact) second = inventory_core.session_color("codex", exact) self.assertEqual(first, second) - self.assertIn(first, inventory_core.SESSION_COLORS) - self.assertNotEqual( + self.assertIn(first, inventory_core.CODEX_SESSION_COLORS) + self.assertIn( inventory_core.session_color("claude", exact), - None, + inventory_core.CLAUDE_SESSION_COLORS, ) - override = {"codex:" + exact: "pink"} + wanted = next( + color + for color in inventory_core.CODEX_SESSION_COLORS + if color != first + ) + override = {"codex:" + exact: wanted} self.assertEqual( - "pink", inventory_core.session_color("codex", exact, override) + wanted, inventory_core.session_color("codex", exact, override) ) self.assertIsNone(inventory_core.session_color("shell", exact)) self.assertIsNone(inventory_core.session_color("codex", "bad-uuid")) @@ -55,14 +64,14 @@ def test_valid_colors_rejects_malformed_entries(self) -> None: exact = uuid_for(62) cleaned = inventory_core._valid_colors( { - f"codex:{exact}": "pink", + f"codex:{exact}": "sand", f"claude:{exact}": "plaid", "codex:not-a-uuid": "red", "unknown:" + exact: "blue", 42: "green", } ) - self.assertEqual({f"codex:{exact}": "pink"}, cleaned) + self.assertEqual({f"codex:{exact}": "sand"}, cleaned) def test_color_command_set_effective_delete_and_claude_push(self) -> None: with tempfile.TemporaryDirectory(prefix=".colors-", dir=REPO) as raw: @@ -152,9 +161,86 @@ def test_color_command_set_effective_delete_and_claude_push(self) -> None: )["agentColor"], ) + def color_records(self, transcript: Path, uuid: str) -> list[str]: + """Every agent-color value in a transcript, in order.""" + values: list[str] = [] + for line in transcript.read_text(encoding="utf-8").splitlines(): + try: + item = json.loads(line) + except ValueError: + continue + if item.get("type") == "agent-color" and item.get("sessionId") == uuid: + values.append(item["agentColor"]) + return values + + def transcript_fixture(self, base: str, number: int) -> tuple[Path, str, dict]: + home = Path(base) / "home" + project_dir = home / ".claude" / "projects" / "-srv-project" + project_dir.mkdir(parents=True, mode=0o700) + exact = uuid_for(number) + transcript = project_dir / f"{exact}.jsonl" + transcript.write_text( + '{"type":"user","sessionId":"%s"}\n' % exact, encoding="utf-8" + ) + return transcript, exact, {"HOME": os.fspath(home)} + + def test_the_color_is_written_once_not_once_per_attach(self) -> None: + """One record per conversation, not one per pass. + + The push runs on every attach (`sk_push_session_color`), so a session + a person opened eight times carried eight identical records and grew + one more on every reattach. Claude honours the last record either way, + so the repeats never changed what was shown -- they only grew a file + the kit does not own and cannot compact. + """ + with tempfile.TemporaryDirectory(prefix=".colors-", dir=REPO) as raw: + transcript, exact, environ = self.transcript_fixture(raw, 81) + + for _ in range(8): + result = inventory_core.propagate_provider_color( + "claude", exact, "blue", environ=environ + ) + + self.assertEqual(["blue"], self.color_records(transcript, exact)) + self.assertEqual( + ["claude-transcript-color-current"], + result["provider_color_pushes"], + ) + + def test_a_real_color_change_is_still_written(self) -> None: + """Idempotence must not freeze the colour: a change is a change.""" + with tempfile.TemporaryDirectory(prefix=".colors-", dir=REPO) as raw: + transcript, exact, environ = self.transcript_fixture(raw, 82) + + for color in ("blue", "pink", "pink"): + inventory_core.propagate_provider_color( + "claude", exact, color, environ=environ + ) + + # The change appends; the repeat of the new colour does not. The + # earlier record stays -- appending is the only safe edit to a + # transcript a live provider is also writing to. + self.assertEqual(["blue", "pink"], self.color_records(transcript, exact)) + + def test_another_conversations_record_never_counts_as_this_ones(self) -> None: + """Records carry a sessionId, so the match has to use it.""" + with tempfile.TemporaryDirectory(prefix=".colors-", dir=REPO) as raw: + transcript, exact, environ = self.transcript_fixture(raw, 83) + with open(transcript, "a", encoding="utf-8") as handle: + handle.write( + '{"type":"agent-color","agentColor":"blue","sessionId":"%s"}\n' + % uuid_for(84) + ) + + inventory_core.propagate_provider_color( + "claude", exact, "blue", environ=environ + ) + + self.assertEqual(["blue"], self.color_records(transcript, exact)) + def test_codex_color_push_is_a_clean_no_op(self) -> None: result = inventory_core.propagate_provider_color( - "codex", uuid_for(64), "pink", environ={"HOME": "/nonexistent"} + "codex", uuid_for(64), "sand", environ={"HOME": "/nonexistent"} ) self.assertEqual([], result["provider_color_pushes"]) self.assertEqual([], result["provider_color_warnings"]) @@ -177,9 +263,9 @@ def test_color_push_fails_open_without_transcript(self) -> None: def test_inventory_sessions_carry_display_color(self) -> None: exact = uuid_for(66) - overrides = {"codex:" + exact: "cyan"} + overrides = {"codex:" + exact: "sea"} self.assertEqual( - "cyan", + "sea", inventory_core.session_color("codex", exact, overrides), ) self.assertEqual( @@ -193,10 +279,125 @@ def test_launch_color_is_deterministic_and_valid(self) -> None: first = inventory_core.launch_color_for("s20260731-172651-3345413") second = inventory_core.launch_color_for("s20260731-172651-3345413") self.assertEqual(first, second) - self.assertIn(first, inventory_core.SESSION_COLORS) + # A launch color is picked before Codex has booted far enough to have a + # conversation ID, so it can only ever come from the Codex palette. + self.assertIn(first, inventory_core.CODEX_SESSION_COLORS) self.assertIsNone(inventory_core.record_launch_color({}, "../evil")) self.assertIsNone(inventory_core.record_launch_color({}, "")) + def test_launch_reservations_serialize_and_repeat_only_when_full(self) -> None: + with tempfile.TemporaryDirectory(prefix=".color-race-", dir=REPO) as raw: + state = Path(raw) + state.chmod(0o700) + config = {"state_dir": state} + barrier = threading.Barrier(2) + colors: list[str | None] = [] + + def reserve(name: str) -> None: + barrier.wait() + colors.append(inventory_core.record_launch_color(config, name)) + + threads = [ + threading.Thread(target=reserve, args=("new-one",)), + threading.Thread(target=reserve, args=("new-two",)), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=3) + self.assertTrue(all(not thread.is_alive() for thread in threads)) + self.assertEqual(2, len(set(colors))) + first = inventory_core.record_launch_color(config, "repeat", {"lime"}) + repeated = inventory_core.record_launch_color( + config, "repeat", inventory_core.CODEX_SESSION_COLORS + ) + self.assertEqual(first, repeated) + preferred = inventory_core.launch_color_for("full-palette") + self.assertEqual( + preferred, + inventory_core.launch_color_for( + "full-palette", inventory_core.CODEX_SESSION_COLORS + ), + ) + # Claude colors are not Codex colors, so a full Claude palette + # leaves every Codex launch color free. + self.assertEqual( + preferred, + inventory_core.launch_color_for( + "full-palette", inventory_core.CLAUDE_SESSION_COLORS + ), + ) + + def test_conversation_pick_persists_the_prebaked_claude_override(self) -> None: + with tempfile.TemporaryDirectory(prefix=".conversation-color-", dir=REPO) as raw: + base = Path(raw) + state = base / "state" + state.mkdir(mode=0o700) + config_file = base / "inventory.json" + config_file.write_text('{"schema_version":1,"aliases":{}}\n') + config_file.chmod(0o600) + config = {"state_dir": state} + exact = uuid_for(72) + live = {"source": "live", "stale": False, "sessions": [], "outside_agents": []} + with ( + mock.patch.dict(os.environ, {"SESSION_KIT_CONFIG": str(config_file)}), + mock.patch.object(inventory_core, "snapshot", return_value=live), + contextlib.redirect_stdout(io.StringIO()) as output, + ): + code = inventory_core._color_command( + argparse.Namespace( + color_action="conversation-pick", + provider="claude", + uuid=exact, + ), + config, + ) + payload = json.loads(output.getvalue()) + document = json.loads(config_file.read_text()) + self.assertEqual(0, code) + self.assertEqual(payload["color"], document["colors"][f"claude:{exact}"]) + + with ( + mock.patch.dict(os.environ, {"SESSION_KIT_CONFIG": str(config_file)}), + contextlib.redirect_stdout(io.StringIO()), + ): + mismatch = inventory_core._color_command( + argparse.Namespace( + color_action="conversation-release", + provider="claude", + uuid=exact, + color=next( + item + for item in inventory_core.CLAUDE_SESSION_COLORS + if item != payload["color"] + ), + ), + config, + ) + self.assertEqual(1, mismatch) + self.assertEqual( + payload["color"], + json.loads(config_file.read_text())["colors"][f"claude:{exact}"], + ) + with ( + mock.patch.dict(os.environ, {"SESSION_KIT_CONFIG": str(config_file)}), + contextlib.redirect_stdout(io.StringIO()), + ): + released = inventory_core._color_command( + argparse.Namespace( + color_action="conversation-release", + provider="claude", + uuid=exact, + color=payload["color"], + ), + config, + ) + self.assertEqual(0, released) + self.assertNotIn( + f"claude:{exact}", + json.loads(config_file.read_text()).get("colors", {}), + ) + def test_launch_color_marker_is_adopted_into_override(self) -> None: with tempfile.TemporaryDirectory(prefix=".launch-", dir=REPO) as raw: base = Path(raw) @@ -223,7 +424,7 @@ def test_launch_color_marker_is_adopted_into_override(self) -> None: } with mock.patch.dict(os.environ, environment, clear=False): color = inventory_core.record_launch_color(config, shpool_id) - self.assertIn(color, inventory_core.SESSION_COLORS) + self.assertIn(color, inventory_core.CODEX_SESSION_COLORS) marker = state / "launch-color" / shpool_id self.assertEqual(color, marker.read_text().strip()) sessions = [ @@ -248,11 +449,1197 @@ def test_launch_color_marker_is_adopted_into_override(self) -> None: kept = inventory_core._adopt_launch_colors( config, sessions, - {f"codex:{exact}": "purple"}, + {f"codex:{exact}": "sand"}, ) - self.assertEqual("purple", kept[f"codex:{exact}"]) + self.assertEqual("sand", kept[f"codex:{exact}"]) self.assertFalse(marker.exists()) + # A newer explicit choice published after the collector read + # its empty override map also wins at the lock boundary. + inventory_core.mutate_canonical_color( + config, "codex", exact, "lime" + ) + inventory_core.record_launch_color(config, shpool_id) + raced = inventory_core._adopt_launch_colors(config, sessions, {}) + self.assertEqual("lime", raced[f"codex:{exact}"]) + + +class PaletteSplitTests(unittest.TestCase): + """The two palettes, and why a caller must say which provider it means.""" + + def test_claude_palette_is_exactly_what_the_provider_accepts(self) -> None: + # Measured against Claude Code 2.1.223: twenty-two names probed with + # known-good and known-bad controls, every other name rejected, and + # gray/grey resolving to `default`, which is no color. Changing this + # tuple means re-probing the provider, not editing a preference. + self.assertEqual( + ("red", "blue", "green", "yellow", "purple", "orange", "pink", "cyan"), + inventory_core.CLAUDE_SESSION_COLORS, + ) + + def test_the_two_palettes_share_no_name(self) -> None: + claude = set(inventory_core.CLAUDE_SESSION_COLORS) + codex = set(inventory_core.CODEX_SESSION_COLORS) + self.assertEqual(set(), claude & codex) + self.assertEqual( + len(claude) + len(codex), len(set(inventory_core.SESSION_COLORS)) + ) + self.assertEqual( + inventory_core.CLAUDE_SESSION_COLORS + inventory_core.CODEX_SESSION_COLORS, + inventory_core.SESSION_COLORS, + ) + + def test_every_codex_color_ships_the_theme_that_renders_it(self) -> None: + # A Codex name without its theme file leaves the window untinted, so + # the palette and config/codex-themes have to agree exactly. + themes = REPO / "config" / "codex-themes" + for color in inventory_core.CODEX_SESSION_COLORS: + self.assertTrue((themes / f"sk-{color}.tmTheme").is_file(), color) + # The eight Claude-named themes stay shipped so a rollback to a release + # that still assigns them finds them. + for color in inventory_core.CLAUDE_SESSION_COLORS: + self.assertTrue((themes / f"sk-{color}.tmTheme").is_file(), color) + + def test_a_provider_without_a_palette_is_handed_nothing(self) -> None: + # Empty rather than a default: a caller that arrives without a real + # provider has no color to assign, and a populated palette would let it + # assign one the provider cannot display. + self.assertEqual( + inventory_core.CLAUDE_SESSION_COLORS, + inventory_core.palette_for_provider("claude"), + ) + self.assertEqual( + inventory_core.CODEX_SESSION_COLORS, + inventory_core.palette_for_provider("codex"), + ) + self.assertEqual((), inventory_core.palette_for_provider("shell")) + self.assertEqual((), inventory_core.palette_for_provider("")) + self.assertEqual((), inventory_core.palette_for_provider("CLAUDE")) + + +class FirstFreeColorTests(unittest.TestCase): + """Keep the identity-hash color unless a live same-provider session has it.""" + + PALETTE = ("one", "two", "three") + + def free(self, preferred: str, occupied: object = (), palette: object = None): + return inventory_core.first_free_color( + preferred, + occupied, + palette=self.PALETTE if palette is None else palette, + ) + + def test_a_free_preference_is_kept(self) -> None: + self.assertEqual("two", self.free("two")) + self.assertEqual("two", self.free("two", {"one", "three"})) + + def test_a_taken_preference_takes_the_next_free_name_in_order(self) -> None: + self.assertEqual("two", self.free("one", {"one"})) + self.assertEqual("three", self.free("one", {"one", "two"})) + + def test_the_search_wraps_past_the_end_of_the_palette(self) -> None: + self.assertEqual("one", self.free("three", {"three"})) + self.assertEqual("two", self.free("three", {"three", "one"})) + + def test_occupancy_outside_the_palette_is_ignored(self) -> None: + # A Claude session holding `pink` must not push a Codex session off + # `lime`; only same-palette names count as taken. + self.assertEqual("one", self.free("one", {"pink", "lime", ""})) + + def test_a_preference_outside_the_palette_is_returned_unchanged(self) -> None: + self.assertEqual("plaid", self.free("plaid", {"one"})) + self.assertEqual("lime", self.free("lime", (), palette=())) + + def test_one_short_of_full_always_finds_the_single_free_name(self) -> None: + # The off-by-one boundary: with exactly one name left, every possible + # preference has to land on it. + for palette in ( + self.PALETTE, + inventory_core.CLAUDE_SESSION_COLORS, + inventory_core.CODEX_SESSION_COLORS, + ): + for free_name in palette: + occupied = set(palette) - {free_name} + for preferred in palette: + self.assertEqual( + free_name, + inventory_core.first_free_color( + preferred, occupied, palette=palette + ), + (palette, free_name, preferred), + ) + + def test_a_full_palette_repeats_the_preference_instead_of_looping(self) -> None: + # The exhaustion boundary. There is no free color to give, so the + # answer is the identity-hash color and the repeat is allowed. It must + # terminate, and it must stay a function of identity rather than of + # arrival order, so a session that has to share shares with the same + # partner every time. + for palette in ( + self.PALETTE, + inventory_core.CLAUDE_SESSION_COLORS, + inventory_core.CODEX_SESSION_COLORS, + ): + occupied = set(palette) + for preferred in palette: + self.assertEqual( + preferred, + inventory_core.first_free_color( + preferred, occupied, palette=palette + ), + (palette, preferred), + ) + + def test_a_whole_round_of_one_preference_uses_every_name_exactly_once( + self, + ) -> None: + # The measured failure this rule exists to fix: eight Claude sessions + # landed on seven colors, two on pink and blue unused. Every session + # here wants the same color, and the palette still comes out fully and + # evenly used before anything repeats. + for palette in ( + inventory_core.CLAUDE_SESSION_COLORS, + inventory_core.CODEX_SESSION_COLORS, + ): + taken: set[str] = set() + for _ in palette: + taken.add( + inventory_core.first_free_color( + palette[0], taken, palette=palette + ) + ) + self.assertEqual(set(palette), taken) + self.assertEqual(len(palette), len(taken)) + # One more than the palette holds returns rather than hangs. + self.assertEqual( + palette[0], + inventory_core.first_free_color(palette[0], taken, palette=palette), + ) + + +class StoredOverrideMigrationTests(unittest.TestCase): + """A stored color outside the in-force palette migrates with no code.""" + + def test_an_override_the_provider_cannot_show_is_ignored_not_corrected( + self, + ) -> None: + exact = uuid_for(80) + # `pink` was a legal Codex color before the split and is Claude-only + # now. Sessions carrying one do not need a migration pass: the override + # simply stops matching the palette, and the identity hash applies. + stale = {"codex:" + exact: "pink"} + migrated = inventory_core.session_color("codex", exact, stale) + self.assertEqual(inventory_core.session_color("codex", exact), migrated) + self.assertIn(migrated, inventory_core.CODEX_SESSION_COLORS) + self.assertEqual({}, inventory_core._valid_colors(stale)) + + def test_a_stale_document_reads_back_clean_from_disk(self) -> None: + with tempfile.TemporaryDirectory(prefix=".stale-colors-", dir=REPO) as raw: + base = Path(raw) + state = base / "state" + state.mkdir(mode=0o700) + config_file = base / "inventory.json" + codex_uuid = uuid_for(81) + claude_uuid = uuid_for(82) + keep = uuid_for(83) + config_file.write_text( + json.dumps( + { + "schema_version": 1, + "aliases": {}, + "colors": { + # Written by a release that shared one palette. + f"codex:{codex_uuid}": "pink", + f"claude:{claude_uuid}": "lime", + f"codex:{keep}": "sand", + }, + } + ), + encoding="utf-8", + ) + config_file.chmod(0o600) + config = {"state_dir": state} + with mock.patch.dict( + os.environ, {"SESSION_KIT_CONFIG": os.fspath(config_file)} + ): + colors = inventory_core.canonical_colors(config) + self.assertEqual({f"codex:{keep}": "sand"}, colors) + self.assertEqual( + inventory_core.session_color("codex", codex_uuid), + inventory_core.session_color("codex", codex_uuid, colors), + ) + self.assertEqual( + inventory_core.session_color("claude", claude_uuid), + inventory_core.session_color("claude", claude_uuid, colors), + ) + self.assertEqual( + "sand", inventory_core.session_color("codex", keep, colors) + ) + + def test_setting_a_color_the_provider_cannot_show_is_refused(self) -> None: + with tempfile.TemporaryDirectory(prefix=".reject-color-", dir=REPO) as raw: + base = Path(raw) + state = base / "state" + state.mkdir(mode=0o700) + config_file = base / "inventory.json" + config_file.write_text('{"schema_version":1,"aliases":{}}\n') + config_file.chmod(0o600) + config = {"state_dir": state} + exact = uuid_for(84) + with mock.patch.dict( + os.environ, {"SESSION_KIT_CONFIG": os.fspath(config_file)} + ): + # Storing it would look like it worked and then read back as + # the hash color, so the write has to fail instead. + with self.assertRaises(inventory_core.CollectionError): + inventory_core.mutate_canonical_color( + config, "claude", exact, "lime" + ) + with self.assertRaises(inventory_core.CollectionError): + inventory_core.mutate_canonical_color( + config, "codex", exact, "pink" + ) + self.assertEqual( + {f"claude:{exact}": "cyan"}, + inventory_core.mutate_canonical_color( + config, "claude", exact, "cyan" + ), + ) + + +def colliding_uuids(provider: str, count: int) -> tuple[str, list[str]]: + """Exact UUIDs whose identity hashes genuinely land on one color. + + Searched rather than hardcoded so the fixture stays true if the palette + order ever changes; the hash is deterministic, so the answer is stable. + """ + groups: dict[str, list[str]] = {} + for number in range(1, 20_000): + exact = uuid_for(number) + color = inventory_core.session_color(provider, exact) + assert color is not None + groups.setdefault(color, []).append(exact) + if len(groups[color]) >= count: + return color, groups[color][:count] + raise AssertionError(f"no {count} {provider} UUIDs share a color") + + +class ReconcileTests(unittest.TestCase): + """One pass that separates sessions already sharing a color.""" + + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".reconcile-", dir=REPO) + self.addCleanup(self.temp.cleanup) + base = Path(self.temp.name) + state = base / "state" + state.mkdir(mode=0o700) + self.config_file = base / "inventory.json" + self.config_file.write_text( + json.dumps({"schema_version": 1, "aliases": {}}), encoding="utf-8" + ) + self.config_file.chmod(0o600) + self.config = {"state_dir": state, "max_proc_nodes": 8192} + patcher = mock.patch.dict( + os.environ, {"SESSION_KIT_CONFIG": os.fspath(self.config_file)} + ) + patcher.start() + self.addCleanup(patcher.stop) + + def seed(self, colors: dict[str, str]) -> None: + document = json.loads(self.config_file.read_text(encoding="utf-8")) + document["colors"] = colors + self.config_file.write_text(json.dumps(document), encoding="utf-8") + + def rows(self, provider: str, uuids: list[str]) -> list[dict[str, object]]: + return [ + {"provider": provider, "identity": {"uuid": exact}} for exact in uuids + ] + + def effective(self, provider: str, uuids: list[str]) -> list[str]: + stored = inventory_core.canonical_colors(self.config) + return [ + inventory_core.session_color(provider, exact, stored) for exact in uuids + ] + + def test_it_separates_sessions_that_share_an_identity_hash_color(self) -> None: + shared, uuids = colliding_uuids("claude", 3) + rows = self.rows("claude", uuids) + self.assertEqual([shared] * 3, self.effective("claude", uuids)) + + result = inventory_core.reconcile_session_colors(self.config, rows) + + after = self.effective("claude", uuids) + self.assertEqual(3, len(set(after)), after) + for color in after: + self.assertIn(color, inventory_core.CLAUDE_SESSION_COLORS) + # The first row in settle order keeps the color it already showed; only + # the rows that had to move are recorded. + self.assertEqual(shared, after[0]) + self.assertEqual(2, len(result["moved"])) + self.assertNotIn(f"claude:{uuids[0]}", result["moved"]) + + def test_running_it_twice_does_not_shuffle_anything(self) -> None: + _, uuids = colliding_uuids("claude", 3) + rows = self.rows("claude", uuids) + inventory_core.reconcile_session_colors(self.config, rows) + settled = self.effective("claude", uuids) + written = self.config_file.read_bytes() + + again = inventory_core.reconcile_session_colors(self.config, rows) + + self.assertEqual({}, again["moved"]) + self.assertEqual([], again["dropped"]) + self.assertEqual(settled, self.effective("claude", uuids)) + self.assertEqual(written, self.config_file.read_bytes()) + + def test_it_recollects_rows_after_taking_the_publishing_lock(self) -> None: + _, uuids = colliding_uuids("claude", 2) + stale_rows = self.rows("claude", uuids) + calls: list[str] = [] + + def current_rows() -> list[dict[str, object]]: + calls.append("under-lock") + return self.rows("claude", [uuids[0]]) + + result = inventory_core.reconcile_session_colors( + self.config, + stale_rows, + revalidate_sessions=current_rows, + ) + + self.assertEqual(["under-lock"], calls) + self.assertEqual({}, result["moved"]) + self.assertEqual({}, result["colors"]) + + def test_a_reversed_arrival_order_settles_the_same_way(self) -> None: + # Settle order is the identity, not the order the snapshot happened to + # list rows in, or a second pass over a reordered inventory would move + # sessions that were already correct. + _, uuids = colliding_uuids("codex", 3) + inventory_core.reconcile_session_colors( + self.config, self.rows("codex", uuids) + ) + settled = self.effective("codex", uuids) + written = self.config_file.read_bytes() + + again = inventory_core.reconcile_session_colors( + self.config, self.rows("codex", list(reversed(uuids))) + ) + + self.assertEqual({}, again["moved"]) + self.assertEqual(settled, self.effective("codex", uuids)) + self.assertEqual(written, self.config_file.read_bytes()) + + def test_more_sessions_than_colors_uses_every_color_then_repeats(self) -> None: + palette = inventory_core.CODEX_SESSION_COLORS + _, uuids = colliding_uuids("codex", len(palette) + 2) + rows = self.rows("codex", uuids) + + result = inventory_core.reconcile_session_colors(self.config, rows) + + after = self.effective("codex", uuids) + # The exhaustion boundary: every color is used exactly once before any + # repeats, and the two rows past the end fall back to a repeat rather + # than to no color or to a name outside the palette. + self.assertEqual(set(palette), set(after)) + self.assertEqual(len(palette), len(set(after))) + self.assertEqual(len(palette) + 2, len(after)) + for color in after: + self.assertIn(color, palette) + self.assertLessEqual(len(result["moved"]), len(uuids)) + + # And it still settles: a second pass over a full palette must not + # start rotating the repeats around. + written = self.config_file.read_bytes() + again = inventory_core.reconcile_session_colors(self.config, rows) + self.assertEqual({}, again["moved"]) + self.assertEqual(after, self.effective("codex", uuids)) + self.assertEqual(written, self.config_file.read_bytes()) + + def test_a_session_keeping_its_hash_color_is_not_pinned(self) -> None: + exact = uuid_for(200) + rows = self.rows("claude", [exact]) + + result = inventory_core.reconcile_session_colors(self.config, rows) + + # No override is written for a row that never had to move, so the + # stored set stays a list of deviations rather than a copy of the + # inventory. + self.assertEqual({}, result["moved"]) + self.assertEqual({}, result["colors"]) + self.assertNotIn("colors", json.loads(self.config_file.read_text())) + + def test_neither_provider_can_take_a_color_from_the_other(self) -> None: + claude_uuid = uuid_for(201) + codex_uuid = uuid_for(202) + rows = [ + *self.rows("claude", [claude_uuid]), + *self.rows("codex", [codex_uuid]), + ] + + result = inventory_core.reconcile_session_colors(self.config, rows) + + self.assertEqual({}, result["moved"]) + self.assertIn( + inventory_core.session_color("claude", claude_uuid), + inventory_core.CLAUDE_SESSION_COLORS, + ) + self.assertIn( + inventory_core.session_color("codex", codex_uuid), + inventory_core.CODEX_SESSION_COLORS, + ) + + def test_it_clears_stored_colors_outside_the_in_force_palette(self) -> None: + codex_uuid = uuid_for(203) + claude_uuid = uuid_for(204) + keep = uuid_for(205) + self.seed( + { + f"codex:{codex_uuid}": "pink", + f"claude:{claude_uuid}": "lime", + f"codex:{keep}": "sand", + } + ) + rows = self.rows("codex", [codex_uuid]) + + result = inventory_core.reconcile_session_colors(self.config, rows) + + self.assertEqual( + [f"claude:{claude_uuid}", f"codex:{codex_uuid}"], result["dropped"] + ) + self.assertEqual({f"codex:{keep}": "sand"}, result["colors"]) + self.assertEqual( + inventory_core.session_color("codex", codex_uuid), + self.effective("codex", [codex_uuid])[0], + ) + again = inventory_core.reconcile_session_colors(self.config, rows) + self.assertEqual([], again["dropped"]) + + def test_rows_without_an_exact_identity_are_skipped(self) -> None: + exact = uuid_for(206) + rows = [ + {"provider": "shell", "identity": {"uuid": exact}}, + {"provider": "claude", "identity": {"uuid": "not-a-uuid"}}, + {"provider": "claude"}, + {"provider": "claude", "identity": None}, + "not a row", + ] + + result = inventory_core.reconcile_session_colors(self.config, rows) + + self.assertEqual({}, result["moved"]) + self.assertEqual({}, result["colors"]) + + def test_the_command_refuses_a_stale_inventory_and_pushes_what_it_moves( + self, + ) -> None: + _, uuids = colliding_uuids("claude", 2) + rows = self.rows("claude", uuids) + stale = {"source": "cache", "stale": True, "sessions": rows} + with ( + mock.patch.object(inventory_core, "snapshot", return_value=stale), + contextlib.redirect_stdout(io.StringIO()) as quiet, + contextlib.redirect_stderr(io.StringIO()) as complaint, + ): + code = inventory_core._color_command( + argparse.Namespace(color_action="reconcile"), dict(self.config) + ) + self.assertEqual(1, code) + self.assertEqual("", quiet.getvalue()) + self.assertIn("stale inventory", complaint.getvalue()) + self.assertNotIn("colors", json.loads(self.config_file.read_text())) + + live = {"source": "live", "stale": False, "sessions": rows} + pushed: list[tuple[str, str, str]] = [] + + def record(provider: str, uuid: str, color: str, **_: object) -> dict: + pushed.append((provider, uuid, color)) + return { + "provider_color_pushes": ["claude-transcript-color"], + "provider_color_warnings": [], + } + + with ( + mock.patch.object(inventory_core, "snapshot", return_value=live), + mock.patch.object( + inventory_core, "propagate_provider_color", side_effect=record + ), + contextlib.redirect_stdout(io.StringIO()) as output, + ): + code = inventory_core._color_command( + argparse.Namespace(color_action="reconcile"), dict(self.config) + ) + payload = json.loads(output.getvalue()) + self.assertEqual(0, code) + self.assertEqual(1, len(payload["moved"])) + # Only what actually moved is pushed, so an open window shows the new + # color at its next start or resume. + self.assertEqual(1, len(pushed)) + self.assertEqual(["claude-transcript-color"], payload["provider_color_pushes"]) + self.assertEqual([], payload["provider_color_warnings"]) + + +def quoted_names(text: str) -> tuple[str, ...]: + return tuple(re.findall(r'"([a-z-]+)"', text)) + + +class PaletteSurfaceTests(unittest.TestCase): + """Every visual surface uses the canonical palette.""" + + def section(self, relative: str, pattern: str) -> str: + text = (REPO / relative).read_text(encoding="utf-8") + match = re.search(pattern, text, re.DOTALL) + self.assertIsNotNone(match, f"{relative}: {pattern} did not match") + assert match is not None + return match.group(1) + + def test_picker_renderer_tints_every_color(self) -> None: + source = (REPO / "lib/sh/shpool_login_render.sh").read_text( + encoding="utf-8" + ) + self.assertIn( + "from sessionkit_inventory.render import render_picker_page", source + ) + self.assertNotIn("SESSION_PALETTE =", source) + + def test_dashboard_renderer_tints_every_color(self) -> None: + body = self.section( + "lib/sessionkit_inventory/render.py", r"SESSION_PALETTE = \{(.*?)\n\}" + ) + self.assertEqual(inventory_core.SESSION_COLORS, quoted_names(body)) + + def test_installer_installs_a_theme_for_every_color(self) -> None: + body = self.section("bin/session-kit", r"codex_theme_names=\((.*?)\)") + self.assertEqual(inventory_core.SESSION_COLORS, tuple(body.split())) + + def test_the_shared_test_theme_list_is_the_palette(self) -> None: + # tests/test_install.py checks lifecycle theme targets against this + # list, so it has to stay the palette rather than drift beside it. + self.assertEqual(inventory_core.SESSION_COLORS, THEME_COLORS) + + def test_codex_launch_accepts_every_codex_color_and_no_claude_color(self) -> None: + body = self.section( + "bashrc/shpool.bashrc", + r'case "\$__sk_theme_color" in\s*(.*?)\)\s*' + r'__sk_codex_home=', + ) + accepted = tuple(body.split("|")) + self.assertEqual(inventory_core.CODEX_SESSION_COLORS, accepted) + self.assertEqual( + set(), set(inventory_core.CLAUDE_SESSION_COLORS) & set(accepted) + ) + + def test_doctor_checks_a_theme_for_every_color(self) -> None: + body = self.section("lib/sh/session_kit_doctor.sh", r"theme_names = \((.*?)\)") + self.assertEqual(inventory_core.SESSION_COLORS, quoted_names(body)) + + def test_lifecycle_expects_a_theme_for_every_color(self) -> None: + body = self.section( + "lib/sh/session_kit_lifecycle.sh", + r"expected_themes = \[.*?for color in \((.*?)\)", + ) + self.assertEqual(inventory_core.SESSION_COLORS, quoted_names(body)) + + def test_sp_usage_offers_each_palette_under_its_own_provider(self) -> None: + text = (REPO / "bin" / "sp").read_text(encoding="utf-8") + claude = re.search(r"^\s*Claude:\s*(\S+)$", text, re.MULTILINE) + codex = re.search(r"^\s*Codex:\s*(\S+)$", text, re.MULTILINE) + self.assertIsNotNone(claude) + self.assertIsNotNone(codex) + assert claude is not None and codex is not None + self.assertEqual( + inventory_core.CLAUDE_SESSION_COLORS, tuple(claude.group(1).split("|")) + ) + self.assertEqual( + inventory_core.CODEX_SESSION_COLORS, tuple(codex.group(1).split("|")) + ) + + def test_the_claude_status_line_covers_the_claude_palette(self) -> None: + # This surface runs inside a Claude Code window and looks the row up by + # Claude Code's own session_id, so it can only ever be handed a Claude + # color. It must cover those eight and needs none of the Codex names. + text = (REPO / "config" / "claude" / "statusline.sh").read_text( + encoding="utf-8" + ) + cases = tuple(re.findall(r"^\s*([a-z]+>?\))\s*tint=", text, re.MULTILINE)) + named = tuple(case[:-1] for case in cases if case != "*)") + self.assertEqual(inventory_core.CLAUDE_SESSION_COLORS, named) + for color in inventory_core.CODEX_SESSION_COLORS: + self.assertNotIn(f" {color})", text) + + def test_claude_status_line_hashes_cache_keys_with_sha256sum(self) -> None: + text = (REPO / "config" / "claude" / "statusline.sh").read_text( + encoding="utf-8" + ) + self.assertIn("sha256sum", text) + self.assertIn("shasum -a 256", text) + self.assertNotRegex(text, r"python3\s+-c") + + def test_claude_status_line_uses_unique_quota_cache_tempfiles(self) -> None: + text = (REPO / "config" / "claude" / "statusline.sh").read_text( + encoding="utf-8" + ) + self.assertRegex(text, r'mktemp\s+"\$QDIR/quota_headers\.tmp\.[X]+"') + self.assertNotIn('"$QCACHE.tmp"', text) + + def test_shipped_quota_refresher_example_states_the_status_line_contract( + self, + ) -> None: + example = REPO / "extras/statusline-quota-refresh.example" + text = example.read_text(encoding="utf-8") + self.assertTrue(example.stat().st_mode & 0o111) + self.assertIn("TODO: Call the quota endpoint", text) + for field in ( + "x-probe-account", + "anthropic-ratelimit-unified-5h-utilization", + "anthropic-ratelimit-unified-5h-reset", + "anthropic-ratelimit-unified-7d-utilization", + "anthropic-ratelimit-unified-7d-reset", + ): + self.assertIn(field, text) + self.assertIn("$HOME/.claude/cache/quota_headers", text) + self.assertNotRegex(text, r"https?://") + + documentation = (REPO / "config/claude/README.md").read_text( + encoding="utf-8" + ) + self.assertIn("extension point", documentation) + self.assertRegex(documentation, r"at most once every 180\s+seconds") + self.assertIn("`HOME` set to an isolated probe directory", documentation) + + def test_claude_status_line_reports_the_session_profile_account(self) -> None: + with tempfile.TemporaryDirectory(prefix="session-kit-statusline.") as raw: + home = Path(raw) + profile = home / "profile" + profile.mkdir() + (home / ".claude.json").write_text( + json.dumps({"oauthAccount": {"emailAddress": "ambient@example.test"}}), + encoding="utf-8", + ) + (profile / ".claude.json").write_text( + json.dumps({"oauthAccount": {"emailAddress": "profile@example.test"}}), + encoding="utf-8", + ) + payload = { + "model": {"display_name": "fixture"}, + "workspace": {"current_dir": "/tmp/project"}, + "context_window": {"used_percentage": 12}, + "session_id": "", + } + environment = os.environ.copy() + environment.update( + { + "HOME": str(home), + "CLAUDE_CONFIG_DIR": str(profile), + "USER": "fixture", + } + ) + result = subprocess.run( + [str(REPO / "config/claude/statusline.sh")], + input=json.dumps(payload), + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("profile@example.test", result.stdout) + self.assertNotIn("ambient@example.test", result.stdout) + + def run_status_line(self, home: Path, sid: str) -> str: + payload = { + "model": {"display_name": "fixture"}, + "workspace": {"current_dir": "/tmp/project"}, + "context_window": {"used_percentage": 12}, + "session_id": sid, + } + environment = os.environ.copy() + environment.update({"HOME": str(home), "USER": "fixture"}) + environment.pop("CLAUDE_CONFIG_DIR", None) + result = subprocess.run( + [str(REPO / "config/claude/statusline.sh")], + input=json.dumps(payload), + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(0, result.returncode, result.stderr) + return result.stdout + + def test_a_pushed_title_reaches_the_status_bar_with_no_restart(self) -> None: + """End to end, on the surface the person actually watches. + + The status line re-runs every two seconds and reads the name straight + from the intent file, so a title written to a LIVE session shows up on + the next refresh -- no restart, no reattach. Before the push the slot + is empty, which is exactly what a person sees today. + """ + with tempfile.TemporaryDirectory(prefix="session-kit-statusline.") as raw: + home = Path(raw) + (home / ".claude" / "sessions").mkdir(parents=True, mode=0o700) + project = home / ".claude" / "projects" / "-srv-app" + project.mkdir(parents=True, mode=0o700) + (home / ".claude.json").write_text( + json.dumps({"oauthAccount": {"emailAddress": "fixture@example.test"}}), + encoding="utf-8", + ) + exact = uuid_for(92) + (project / f"{exact}.jsonl").write_text( + '{"type":"user","sessionId":"%s"}\n' % exact, encoding="utf-8" + ) + state = home / ".local/state/session-kit" + state.mkdir(parents=True, mode=0o700) + (state / "inventory.json").write_text( + json.dumps( + { + "sessions": [ + { + "identity": {"uuid": exact}, + "display_color": "blue", + } + ] + } + ), + encoding="utf-8", + ) + + before = self.run_status_line(home, exact) + self.assertNotIn("Count the great lakes", before) + + inventory_core.propagate_provider_title( + "claude", + exact, + "Count the great lakes", + environ={"HOME": os.fspath(home)}, + ) + after = self.run_status_line(home, exact) + + # The name leads the line, tinted with the session's own colour -- + # blue is 97;166;240 in the kit's calibrated palette. + self.assertIn("Count the great lakes", after) + self.assertIn("\033[38;2;97;166;240m", after) + self.assertTrue( + after.startswith("\033[38;2;97;166;240mCount the great lakes"), + after[:80], + ) + + def test_an_account_sessions_derived_title_reaches_the_status_bar(self) -> None: + """The whole chain for the session the operator was looking at. + + Their session ran on an enrolled profile and had a title the PROVIDER + derived. The kit read the provider's evidence from the default root + only, found nothing, and therefore pushed nothing -- so the status bar + name slot stayed empty while `sp detail` happily printed the title. + Reading the profile is what turns the rest of the chain on. + """ + with tempfile.TemporaryDirectory(prefix="session-kit-statusline.") as raw: + home = Path(raw) + (home / ".claude" / "sessions").mkdir(parents=True, mode=0o700) + (home / ".claude" / "projects").mkdir(parents=True, mode=0o700) + (home / ".claude.json").write_text( + json.dumps({"oauthAccount": {"emailAddress": "fixture@example.test"}}), + encoding="utf-8", + ) + profile = home / ".local/share/session-kit/accounts/claude/primary" + project = profile / "projects" / "-srv-app" + project.mkdir(parents=True, mode=0o700) + exact = uuid_for(93) + (project / f"{exact}.jsonl").write_text( + json.dumps( + { + "type": "ai-title", + "aiTitle": "Count the great lakes", + "sessionId": exact, + } + ) + + "\n", + encoding="utf-8", + ) + state = home / ".local/state/session-kit" + state.mkdir(parents=True, mode=0o700) + (state / "inventory.json").write_text( + json.dumps( + { + "sessions": [ + {"identity": {"uuid": exact}, "display_color": "blue"} + ] + } + ), + encoding="utf-8", + ) + environ = {"HOME": os.fspath(home)} + + # What the kit can see of the provider's own title... + signals = inventory_core.read_claude_transcript_signals(exact, home) + # ...is what it pushes, and the push is what the bar reads. + if signals["ai_title"]: + inventory_core.propagate_provider_title( + "claude", exact, signals["ai_title"], environ=environ + ) + + self.assertEqual("Count the great lakes", signals["ai_title"]) + self.assertIn( + "Count the great lakes", self.run_status_line(home, exact) + ) + + def test_claude_quota_caches_are_isolated_by_profile_and_alias(self) -> None: + with tempfile.TemporaryDirectory(prefix="session-kit-quota.") as raw: + home = Path(raw) + claude_home = home / ".claude" + claude_home.mkdir() + refresh_log = home / "refresh.log" + refresher = claude_home / "statusline-quota-refresh.sh" + refresher.write_text( + "#!/usr/bin/env bash\n" + "set -eu\n" + "mkdir -p \"$HOME/.claude/cache\"\n" + "email=$(jq -r '.oauthAccount.emailAddress' \"$HOME/.claude.json\")\n" + "printf '%s|%s\\n' \"$email\" \"${CLAUDE_CONFIG_DIR:-default}\" >> \"$REFRESH_LOG\"\n" + "printf 'x-probe-account: %s\\nanthropic-ratelimit-unified-5h-utilization: 0.25\\nanthropic-ratelimit-unified-5h-reset: 4102444800\\n' \"$email\" > \"$HOME/.claude/cache/quota_headers\"\n", + encoding="utf-8", + ) + refresher.chmod(0o700) + profiles = [] + aliases = ("a" * 32 + "-one", "a" * 32 + "-two") + for alias in aliases: + profile = home / alias + profile.mkdir() + (profile / ".claude.json").write_text( + json.dumps( + {"oauthAccount": {"emailAddress": f"{alias}@example.test"}} + ), + encoding="utf-8", + ) + profiles.append((alias, profile)) + payload = { + "model": {"display_name": "fixture"}, + "workspace": {"current_dir": "/tmp/project"}, + "context_window": {"used_percentage": 12}, + "session_id": "", + } + + def render(alias: str, profile: Path) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment.update( + { + "HOME": str(home), + "CLAUDE_CONFIG_DIR": str(profile), + "SESSION_KIT_ACCOUNT_ALIAS": alias, + "REFRESH_LOG": str(refresh_log), + "USER": "fixture", + } + ) + return subprocess.run( + [str(REPO / "config/claude/statusline.sh")], + input=json.dumps(payload), + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + for alias, profile in profiles: + result = render(alias, profile) + self.assertEqual(0, result.returncode, result.stderr) + cache_root = claude_home / "cache/session-kit-quota" + for _ in range(100): + caches = list(cache_root.glob("*/quota_headers")) + if len(caches) == 2: + break + time.sleep(0.02) + self.assertEqual(2, len(caches)) + cache_keys = {path.parent.name for path in caches} + for alias in aliases: + self.assertTrue( + any( + re.fullmatch(re.escape(alias) + r"-[0-9a-f]{64}", key) + for key in cache_keys + ), + cache_keys, + ) + self.assertEqual( + {f"{alias}@example.test" for alias in aliases}, + { + re.search(r"x-probe-account: (.+)", path.read_text()).group(1) + for path in caches + }, + ) + first_refreshes = refresh_log.read_text(encoding="utf-8").splitlines() + self.assertEqual(2, len(first_refreshes)) + + for alias, profile in profiles: + result = render(alias, profile) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn(f"{alias}@example.test", result.stdout) + self.assertIn("5h", result.stdout) + time.sleep(0.05) + self.assertEqual( + first_refreshes, + refresh_log.read_text(encoding="utf-8").splitlines(), + ) + + +class TranscriptWritesSurviveTornFilesTests(unittest.TestCase): + """Colour and name writes against transcripts a live provider owns. + + Two failures live here and both are silent, which is what makes them + expensive: one unreadable byte used to end the naming pass for EVERY + session, and an append onto a half-written record used to destroy that + record, lose the colour, and still report success. + """ + + def fixture(self, base: str, number: int) -> tuple[Path, str, dict]: + home = Path(base) / "home" + project = home / ".claude" / "projects" / "-srv-project" + project.mkdir(parents=True, mode=0o700) + exact = uuid_for(number) + transcript = project / f"{exact}.jsonl" + transcript.write_bytes(b'{"type":"user","sessionId":"%s"}\n' % exact.encode()) + return transcript, exact, {"HOME": os.fspath(home)} + + def records(self, transcript: Path, uuid: str, kind: str, field: str) -> list: + """Every value of one record kind, read the way a torn file allows.""" + values = [] + for raw in transcript.read_bytes().split(b"\n"): + try: + item = json.loads(raw.decode("utf-8", "strict")) + except (UnicodeDecodeError, ValueError): + continue + if item.get("type") == kind and item.get("sessionId") == uuid: + values.append(item[field]) + return values + + def age(self, transcript: Path, seconds: float) -> None: + stamp = time.time() - seconds + os.utime(transcript, (stamp, stamp)) + + def test_one_unreadable_byte_does_not_stop_the_colour(self) -> None: + """A killed writer leaves half a character; that is not an answer. + + Text mode raises UnicodeDecodeError from the iteration itself, and a + UnicodeDecodeError is a ValueError, not an OSError -- so it escaped + the guard, its caller, and the hydration loop that walks every + session, and from there on nobody got a colour or a name. + """ + with tempfile.TemporaryDirectory(prefix=".colors-", dir=REPO) as raw: + transcript, exact, environ = self.fixture(raw, 91) + with open(transcript, "ab") as handle: + handle.write(b'{"type":"user","text":"caf\xc3') + self.age(transcript, 600) + + result = inventory_core.propagate_provider_color( + "claude", exact, "blue", environ=environ + ) + + self.assertEqual( + ["blue"], self.records(transcript, exact, "agent-color", "agentColor") + ) + self.assertIn("claude-transcript-color", result["provider_color_pushes"]) + + def test_one_unreadable_byte_does_not_stop_the_other_sessions(self) -> None: + """The blast radius: one torn file, every other session still named. + + `bin/shpool_status` runs this pass with its output and its errors + discarded, so anything raised here is invisible -- the symptom is + that sessions quietly stop getting names, which is the same silence + the operator already complained about. + """ + names = sys.modules["sessionkit_inventory.names"] + with tempfile.TemporaryDirectory(prefix=".colors-", dir=REPO) as raw: + torn, exact_torn, environ = self.fixture(raw, 92) + healthy_uuid = uuid_for(93) + healthy = torn.parent / f"{healthy_uuid}.jsonl" + healthy.write_bytes( + b'{"type":"user","sessionId":"%s"}\n' % healthy_uuid.encode() + ) + with open(torn, "ab") as handle: + handle.write(b'{"type":"user","text":"caf\xc3') + self.age(torn, 600) + live = { + "sessions": [ + {"provider": "claude", "identity": {"uuid": exact_torn}}, + {"provider": "claude", "identity": {"uuid": healthy_uuid}}, + ] + } + + hydrated = names.claude_pending_native_hydrations( + None, + environ, + canonical_colors=lambda settings: list(THEME_COLORS), + guard_inventory=lambda *a, **k: True, + load_config=lambda *a, **k: {}, + transcript_signals=inventory_core.read_claude_transcript_signals, + session_color=lambda provider, uuid, colors: "blue", + snapshot_inventory=lambda *a, **k: live, + propagate_color=inventory_core.propagate_provider_color, + propagate_title=inventory_core.propagate_provider_title, + reconcile_pending_titles=lambda *a, **k: [], + human_named=lambda env: frozenset(), + adopt_native=lambda *a, **k: "", + ) + + self.assertEqual( + ["blue"], + self.records(healthy, healthy_uuid, "agent-color", "agentColor"), + ) + self.assertEqual( + ["blue"], self.records(torn, exact_torn, "agent-color", "agentColor") + ) + self.assertTrue(hydrated) + + def test_a_record_being_written_right_now_is_never_written_onto(self) -> None: + """A file that does not end in a newline has a writer mid-record. + + Concatenating there makes one invalid line out of two records: the + provider's own event is destroyed AND the colour is lost, and the old + code called that a successful push, so the window kept its old colour + while the kit said it had changed it. + """ + with tempfile.TemporaryDirectory(prefix=".colors-", dir=REPO) as raw: + transcript, exact, environ = self.fixture(raw, 94) + with open(transcript, "ab") as handle: + handle.write( + b'{"type":"agent-color","agentColor":"pink","sessionId":"%s"}\n' + % exact.encode() + ) + handle.write(b'{"type":"assistant","message":{"content":"half a rec') + + result = inventory_core.propagate_provider_color( + "claude", exact, "blue", environ=environ + ) + + tail = transcript.read_bytes().split(b"\n")[-1] + self.assertNotIn(b"agent-color", tail) + self.assertNotIn( + "claude-transcript-color", result["provider_color_pushes"] + ) + self.assertTrue(result["provider_color_warnings"]) + self.assertEqual( + ["pink"], self.records(transcript, exact, "agent-color", "agentColor") + ) + + def test_a_provider_that_starts_a_record_mid_write_never_costs_the_colour( + self, + ) -> None: + """The window that matters: the instant between the check and the write. + + Checking that the file ends in a newline and then appending is two + steps, and a live provider can begin a record between them. Then the + colour lands INSIDE the provider's record, one invalid line is made + out of two records, and the old code reported a successful push -- + the worst outcome available here, because it damages a transcript a + live session is writing and then says everything is fine. + + A fixture that finishes the partial write BEFORE calling the code + cannot catch this; it never races that window. This one holds the + writer open across it by starting the provider's record from inside + the tail check itself, which is exactly where the real one can land. + """ + with tempfile.TemporaryDirectory(prefix=".colors-", dir=REPO) as raw: + transcript, exact, environ = self.fixture(raw, 98) + with open(transcript, "ab") as handle: + handle.write( + b'{"type":"agent-color","agentColor":"pink","sessionId":"%s"}\n' + % exact.encode() + ) + real_pread = os.pread + started = [] + + def racing_pread(fd, length, offset): + answer = real_pread(fd, length, offset) + if not started: + # The check has just seen a newline. The provider begins + # its record NOW, before the append happens. + started.append(True) + with open(transcript, "ab") as writer: + writer.write(b'{"type":"assistant","message":"split') + return answer + + with mock.patch.object(os, "pread", racing_pread): + result = inventory_core.propagate_provider_color( + "claude", exact, "blue", environ=environ + ) + # ...and finishes it after the append, the way a real one would. + with open(transcript, "ab") as writer: + writer.write(b' provider record"}\n') + + self.assertTrue(started, "the race was never entered") + # The colour is a whole, readable record -- not bytes buried in + # the middle of an invalid line. + self.assertEqual( + ["pink", "blue"], + self.records(transcript, exact, "agent-color", "agentColor"), + ) + # And what was reported matches what is in the file. + self.assertIn("claude-transcript-color", result["provider_color_pushes"]) + # The damage that could not be prevented is said out loud. + self.assertTrue(result["provider_color_warnings"]) + + def test_wreckage_nobody_is_writing_is_closed_and_the_colour_lands(self) -> None: + """Refusing forever would mean a killed session never gets a colour. + + A transcript nothing has touched for a long time is not being written + to: the half-line is wreckage from a kill, so one write closes it and + adds the record, and the file is never left worse than it was found. + """ + with tempfile.TemporaryDirectory(prefix=".colors-", dir=REPO) as raw: + transcript, exact, environ = self.fixture(raw, 95) + with open(transcript, "ab") as handle: + handle.write(b'{"type":"assistant","message":{"content":"half a rec') + self.age(transcript, 600) + + inventory_core.propagate_provider_color( + "claude", exact, "blue", environ=environ + ) + + lines = [line for line in transcript.read_bytes().split(b"\n") if line] + self.assertEqual( + ["blue"], self.records(transcript, exact, "agent-color", "agentColor") + ) + # The wreckage is still its own line; our record did not join it. + self.assertTrue(lines[-1].startswith(b'{"type":"agent-color"')) + + def test_the_name_is_written_once_not_once_per_pass(self) -> None: + """The colour got the "already right, leave it alone" rule and the + name did not. This runs on every pass too, so an unguarded append + grew one more identical record every time.""" + with tempfile.TemporaryDirectory(prefix=".colors-", dir=REPO) as raw: + transcript, exact, environ = self.fixture(raw, 96) + (Path(environ["HOME"]) / ".claude" / "sessions").mkdir(parents=True) + + for _ in range(8): + result = inventory_core.propagate_provider_title( + "claude", exact, "Steady Name", environ=environ + ) + + self.assertEqual( + ["Steady Name"], + self.records(transcript, exact, "agent-name", "agentName"), + ) + self.assertIn( + "claude-transcript-name-current", result["provider_title_pushes"] + ) + + def test_a_real_name_change_is_still_written(self) -> None: + """Idempotence must not freeze the name: a change is a change.""" + with tempfile.TemporaryDirectory(prefix=".colors-", dir=REPO) as raw: + transcript, exact, environ = self.fixture(raw, 97) + (Path(environ["HOME"]) / ".claude" / "sessions").mkdir(parents=True) + + for title in ("First", "Second", "Second"): + inventory_core.propagate_provider_title( + "claude", exact, title, environ=environ + ) + + self.assertEqual( + ["First", "Second"], + self.records(transcript, exact, "agent-name", "agentName"), + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_session_origin.py b/tests/test_session_origin.py new file mode 100644 index 0000000..c79644f --- /dev/null +++ b/tests/test_session_origin.py @@ -0,0 +1,1249 @@ +"""Who asked for a session, stamped at the moment it is created. + +A machine starts sessions all day (drills, automations, watchdog restores), +and every one of them lands in the list a person opens to find their own work. +The picker's default view is the person's own screen (D17), and it can only be +that if creation says who asked: nothing infers origin later from a directory, +a title, or a guess. + +An unstamped session belongs to the person. A tool that forgets to say it is a +machine therefore shows up in the human list and gets noticed, rather than +hiding by accident. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import unittest +from unittest import mock + +from tests.support import REPO, run +from tests.test_commands import CommandFixture + +sys.path.insert(0, os.fspath(REPO / "lib")) + +from sessionkit_inventory import closed_sessions # noqa: E402 +from sessionkit_inventory import collector # noqa: E402 +from sessionkit_inventory import origins # noqa: E402 +from sessionkit_inventory.processes import ProcessTable # noqa: E402 + +SP = REPO / "bin" / "sp" +CORE = REPO / "lib" / "session_inventory.py" + + +class OriginStoreTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".origins-", dir=REPO) + self.state = Path(self.temp.name) + self.instance_environment = { + "SESSION_KIT_ORIGIN_SHELL_PID": "20", + "SESSION_KIT_ORIGIN_SHELL_START_TICKS": "200", + "SESSION_KIT_ORIGIN_STARTED_AT_UNIX_MS": "2", + } + self.instance_patch = mock.patch.dict( + os.environ, self.instance_environment + ) + self.instance_patch.start() + + def tearDown(self) -> None: + self.instance_patch.stop() + self.temp.cleanup() + + def test_an_unstamped_session_belongs_to_the_person(self) -> None: + self.assertEqual( + origins.HUMAN, + origins.origin_for("s1", origins.load_origins(self.state)), + ) + + def test_a_stamp_survives_a_reread_and_names_only_its_own_session(self) -> None: + origins.record_origin(self.state, shpool_id="s1", origin=origins.MACHINE) + stored = origins.load_origins(self.state) + self.assertEqual(origins.MACHINE, origins.origin_for("s1", stored)) + self.assertEqual(origins.HUMAN, origins.origin_for("s2", stored)) + self.assertEqual( + 0o600, os.stat(origins.origins_path(self.state)).st_mode & 0o777 + ) + + def test_the_writer_refuses_a_name_only_schema_2_stamp(self) -> None: + with mock.patch.dict(os.environ): + for variable in self.instance_environment: + os.environ.pop(variable, None) + with self.assertRaises(Exception): + origins.record_origin( + self.state, shpool_id="unbound", origin=origins.MACHINE + ) + self.assertFalse(origins.origins_path(self.state).exists()) + + def test_the_writer_binds_a_machine_stamp_to_its_exact_instance(self) -> None: + origins.record_origin( + self.state, shpool_id="main2", origin=origins.MACHINE + ) + stored = origins.load_origins(self.state) + reused = { + "shpool_id_raw": "main2", + "started_at_unix_ms": 3, + "shpool_shell": {"pid": 21, "process_start_ticks": 201}, + "machine_driven": False, + } + + origins.apply_session_origins( + {"sessions": [reused]}, state_dir=self.state + ) + + self.assertEqual(origins.HUMAN, reused["origin"]) + self.assertNotIn("origin_recorded", reused) + self.assertEqual( + { + "shell_pid": 20, + "shell_start_ticks": 200, + "started_at_unix_ms": 2, + }, + stored["sessions"]["main2"].get("instance"), + ) + + def test_the_writer_binds_a_human_stamp_to_its_exact_instance(self) -> None: + origins.record_origin(self.state, shpool_id="main2", origin=origins.HUMAN) + reused = { + "shpool_id_raw": "main2", + "started_at_unix_ms": 3, + "shpool_shell": {"pid": 21, "process_start_ticks": 201}, + "machine_driven": True, + } + + origins.apply_session_origins( + {"sessions": [reused]}, state_dir=self.state + ) + + self.assertEqual(origins.MACHINE, reused["origin"]) + self.assertNotIn("origin_recorded", reused) + + def test_an_unknown_origin_is_refused_rather_than_stored(self) -> None: + with self.assertRaises(Exception): + origins.record_origin(self.state, shpool_id="s1", origin="robot") + self.assertEqual({}, origins.load_origins(self.state)["sessions"]) + + def stamp(self, shpool_id: str, origin: str, *, age_ms: int = 0) -> None: + """Stamp a session, optionally as of some moment in the past.""" + origins.record_origin( + self.state, + shpool_id=shpool_id, + origin=origin, + now_unix_ms=int(time.time() * 1000) - age_ms, + ) + + def test_a_stamp_is_forgotten_once_its_session_is_gone(self) -> None: + old = origins.ORIGIN_PRUNE_GRACE_MS * 2 + self.stamp("s1", origins.MACHINE, age_ms=old) + self.stamp("s2", origins.MACHINE, age_ms=old) + captured = origins.capture_origin_generations(self.state) + self.assertEqual( + 1, + origins.prune_origins( + self.state, + ["s1"], + retire_generations=captured, + ), + ) + self.assertEqual(["s1"], sorted(origins.load_origins(self.state)["sessions"])) + + def test_a_stamp_survives_a_collection_that_landed_before_attach(self) -> None: + """A stale manager reading cannot prune a newly captured stamp. + + Creation attaches, captures the exact shell generation, and then + stamps it. A collector using a reading taken just before attach can + reach pruning afterward under a different lock. The grace keeps that + older reading from deleting the new instance-bound stamp. + """ + self.stamp("about-to-attach", origins.HUMAN) + captured = origins.capture_origin_generations(self.state) + + self.assertEqual( + 0, + origins.prune_origins( + self.state, + ["some-other-session"], + retire_generations=captured, + ), + ) + + self.assertEqual( + origins.HUMAN, + origins.recorded_origin( + "about-to-attach", origins.load_origins(self.state) + ), + ) + + def test_a_newer_stamp_is_unreachable_to_a_stale_clock(self) -> None: + self.stamp("already-seen", origins.MACHINE, age_ms=10_000) + captured = origins.capture_origin_generations(self.state) + self.stamp("written-after-capture", origins.HUMAN) + + moved_clock = int(time.time() * 1000) + origins.ORIGIN_PRUNE_GRACE_MS * 2 + self.assertEqual( + 1, + origins.prune_origins( + self.state, + ["still-live"], + now_unix_ms=moved_clock, + retire_generations=captured, + ), + ) + self.assertEqual( + origins.HUMAN, + origins.recorded_origin( + "written-after-capture", origins.load_origins(self.state) + ), + ) + + def test_a_listing_with_no_sessions_never_empties_the_store(self) -> None: + """One empty listing must not un-stamp the whole machine. + + A listing that names nothing is what a collection run against a + stand-in session manager produces -- the state directory is shared, + the session manager is not -- and what a daemon restart produces for a + moment. Read as proof, it deletes every stamp on the box in one pass, + and no live session is ever stamped again. + + Age is not a second opinion. The operator's own sessions run for days, + so a rule that forgets stamps "too old to be anybody's live session" + is a rule that deletes theirs -- it reads an age as evidence about a + session when it is only a fact about the stamp. The old stamp below is + a day and an hour old, which is an ordinary age for a session on this + estate. + """ + self.stamp("person", origins.HUMAN) + self.stamp("agents", origins.MACHINE) + self.stamp("person-since-yesterday", origins.HUMAN, age_ms=25 * 60 * 60 * 1000) + + self.assertEqual(0, origins.prune_origins(self.state, [])) + + self.assertEqual( + ["agents", "person", "person-since-yesterday"], + sorted(origins.load_origins(self.state)["sessions"]), + ) + + def test_a_store_that_will_not_read_is_never_written_over(self) -> None: + """A reading failure must not become permanent data loss. + + A prune rewrites the store wholesale. If a row in it did not read, + the rewrite drops that row for good -- and the session behind it is + live, so it loses its provenance and falls to the driver. + """ + path = origins.origins_path(self.state) + old = origins.ORIGIN_PRUNE_GRACE_MS * 2 + self.stamp("readable", origins.MACHINE, age_ms=old) + document = json.loads(path.read_text(encoding="utf-8")) + document["sessions"]["unreadable"] = {"origin": "yes-please", "at_unix_ms": 1} + path.write_text(json.dumps(document), encoding="utf-8") + before = path.read_text(encoding="utf-8") + + self.assertEqual(0, origins.prune_origins(self.state, [])) + + self.assertEqual(before, path.read_text(encoding="utf-8")) + + def test_a_store_that_will_not_read_never_hands_a_row_to_the_driver(self) -> None: + """"Never stamped" is a claim about the store, so it needs the store. + + An unreadable store answers "no stamp here" for every session on the + machine, including the ones stamped as the person's. Read as silence, + it licenses the driver -- so one unreadable file moves their own + sessions behind the machine count, with nothing said anywhere. + """ + self.stamp("person", origins.HUMAN) + # The private-state reader refuses anything but a mode-0600 file, which + # is what a stray chmod or a restore from a backup leaves behind. + origins.origins_path(self.state).chmod(0o644) + inventory = { + "sessions": [ + {"shpool_id_raw": "person", "machine_driven": True}, + {"shpool_id_raw": "legacy", "machine_driven": True}, + ] + } + + origins.apply_session_origins(inventory, state_dir=self.state) + + self.assertEqual( + [origins.HUMAN, origins.HUMAN], + [row["origin"] for row in inventory["sessions"]], + ) + + def test_a_row_carries_the_stamp_itself_beside_what_it_is(self) -> None: + """Two fields, because the durable writers must read the stamp alone. + + `origin` is what the row IS -- for an unstamped session, whatever + collection inferred a moment ago. Anything that writes provenance down + for good (a repair that re-declares an origin, a close that fills the + ledger) reads `origin_recorded`, which exists only where somebody + actually said. + """ + self.stamp("stamped", origins.MACHINE) + inventory = { + "sessions": [ + { + "shpool_id_raw": "stamped", + "started_at_unix_ms": 2, + "shpool_shell": {"pid": 20, "process_start_ticks": 200}, + }, + {"shpool_id_raw": "inferred", "machine_driven": True}, + ] + } + + origins.apply_session_origins(inventory, state_dir=self.state) + + stamped, inferred = inventory["sessions"] + self.assertEqual(origins.MACHINE, stamped["origin"]) + self.assertEqual(origins.MACHINE, stamped["origin_recorded"]) + self.assertEqual(origins.MACHINE, inferred["origin"]) + self.assertNotIn("origin_recorded", inferred) + + def test_a_legacy_machine_stamp_does_not_bind_a_reused_name(self) -> None: + path = origins.origins_path(self.state) + path.write_text( + json.dumps( + { + "schema_version": 1, + "sessions": { + "main2": {"origin": origins.MACHINE, "at_unix_ms": 1} + }, + } + ) + + "\n", + encoding="utf-8", + ) + path.chmod(0o600) + inventory = { + "sessions": [ + { + "shpool_id_raw": "main2", + "started_at_unix_ms": 2, + "shpool_shell": {"pid": 20, "process_start_ticks": 200}, + } + ] + } + + origins.apply_session_origins(inventory, state_dir=self.state) + + self.assertEqual(origins.HUMAN, inventory["sessions"][0]["origin"]) + self.assertNotIn("origin_recorded", inventory["sessions"][0]) + + def test_a_legacy_human_stamp_does_not_bind_a_reused_name(self) -> None: + path = origins.origins_path(self.state) + path.write_text( + json.dumps( + { + "schema_version": 1, + "sessions": { + "main2": {"origin": origins.HUMAN, "at_unix_ms": 1} + }, + } + ) + + "\n", + encoding="utf-8", + ) + path.chmod(0o600) + inventory = { + "sessions": [ + { + "shpool_id_raw": "main2", + "machine_driven": True, + "started_at_unix_ms": 2, + "shpool_shell": {"pid": 20, "process_start_ticks": 200}, + } + ] + } + + origins.apply_session_origins(inventory, state_dir=self.state) + + self.assertEqual(origins.MACHINE, inventory["sessions"][0]["origin"]) + self.assertNotIn("origin_recorded", inventory["sessions"][0]) + + def test_an_instance_bound_stamp_requires_the_exact_shell(self) -> None: + path = origins.origins_path(self.state) + path.write_text( + json.dumps( + { + "schema_version": origins.ORIGIN_SCHEMA_VERSION, + "sessions": { + "main2": { + "origin": origins.MACHINE, + "at_unix_ms": 1, + "record_generation": "a" * 64, + "instance": { + "shell_pid": 20, + "shell_start_ticks": 200, + "started_at_unix_ms": 2, + }, + } + }, + } + ) + + "\n", + encoding="utf-8", + ) + path.chmod(0o600) + exact = { + "shpool_id_raw": "main2", + "started_at_unix_ms": 2, + "shpool_shell": {"pid": 20, "process_start_ticks": 200}, + } + reused = { + "shpool_id_raw": "main2", + "started_at_unix_ms": 3, + "shpool_shell": {"pid": 21, "process_start_ticks": 201}, + } + inventory = {"sessions": [exact, reused]} + + origins.apply_session_origins(inventory, state_dir=self.state) + + self.assertEqual(origins.MACHINE, exact["origin"]) + self.assertEqual(origins.MACHINE, exact["origin_recorded"]) + self.assertEqual(origins.HUMAN, reused["origin"]) + self.assertNotIn("origin_recorded", reused) + + def test_schema_2_name_only_stamps_classify_nothing_and_remain_prunable( + self, + ) -> None: + path = origins.origins_path(self.state) + path.write_text( + json.dumps( + { + "schema_version": origins.ORIGIN_SCHEMA_VERSION, + "sessions": { + "old-machine": { + "origin": origins.MACHINE, + "at_unix_ms": 1, + "record_generation": "a" * 64, + }, + "old-human": { + "origin": origins.HUMAN, + "at_unix_ms": 1, + "record_generation": "b" * 64, + }, + }, + } + ) + + "\n", + encoding="utf-8", + ) + path.chmod(0o600) + old_machine_name = { + "shpool_id_raw": "old-machine", + "started_at_unix_ms": 3, + "shpool_shell": {"pid": 21, "process_start_ticks": 201}, + } + old_human_name = { + "shpool_id_raw": "old-human", + "started_at_unix_ms": 4, + "shpool_shell": {"pid": 22, "process_start_ticks": 202}, + "machine_driven": True, + } + + inventory = {"sessions": [old_machine_name, old_human_name]} + origins.apply_session_origins(inventory, state_dir=self.state) + + self.assertEqual(origins.HUMAN, old_machine_name["origin"]) + self.assertEqual(origins.MACHINE, old_human_name["origin"]) + self.assertNotIn("origin_recorded", old_machine_name) + self.assertNotIn("origin_recorded", old_human_name) + captured = origins.capture_origin_generations(self.state) + self.assertEqual( + 2, + origins.prune_origins( + self.state, + ["some-live-session"], + now_unix_ms=origins.ORIGIN_PRUNE_GRACE_MS * 2, + retire_generations=captured, + ), + ) + self.assertEqual({}, origins.load_origins(self.state)["sessions"]) + + def test_a_bounce_ends_when_the_window_is_SEEN_not_when_a_shell_says_so( + self, + ) -> None: + """The relaunching shell cannot report its own success. + + It blocks on the provider it just started, so any moment it picks to + drop the marker is a moment before the replacement window exists -- + and in that interval the session is an App Server and a broker with no + window, which is the machine shape. So the shell leaves the marker and + collection ends the bounce when it SEES the window. A session whose + replacement never appeared keeps its marker and stays visible. + """ + markers = self.state / "provider-bounce" + markers.mkdir(parents=True) + # All three have been TAKEN by their session shell -- the state a + # sighting is allowed to end. The shell renames rather than empties, so + # the receipt is a second name. + taken = { + name: markers / f"{name}{origins.TAKEN_GENERATION_SEPARATOR}generation" + for name in ("window-is-back", "still-restarting", "no-app-server") + } + for marker in taken.values(): + marker.write_text("", encoding="utf-8") + captured = origins.capture_bounce_receipts(self.state) + inventory = { + "sessions": [ + {"shpool_id_raw": "window-is-back", "app_server_window": True}, + {"shpool_id_raw": "still-restarting", "machine_driven": True}, + {"shpool_id_raw": "no-app-server"}, + ] + } + + origins.apply_session_origins( + inventory, + state_dir=self.state, + settle_bounce_receipts=captured, + ) + + self.assertFalse(taken["window-is-back"].exists()) + self.assertTrue(taken["still-restarting"].exists()) + self.assertTrue(taken["no-app-server"].exists()) + # And the session still mid-restart is still the person's. + self.assertEqual( + [origins.HUMAN, origins.HUMAN, origins.HUMAN], + [row["origin"] for row in inventory["sessions"]], + ) + + def test_a_bounce_nobody_has_performed_yet_is_never_cleared(self) -> None: + """A pending instruction beside a visible window is the OLD window. + + The picker writes the conversation ID, re-proves the session on a fresh + collection, and only then asks the window to exit -- so every bounce + spends the whole interval between request and exit as a live window + sitting next to an instruction nobody has carried out. Deleting it there + does not end a bounce, it cancels one, and the cost is not a hidden row: + the session shell reaches the marker, finds no instruction, takes the + ordinary provider exit instead of relaunching, and the session the + person was sitting in closes. + + The shell's receipt is the second NAME, and only that name is deleted -- + so the two sides never touch the same path, and no read here can race a + picker's write there. + """ + markers = self.state / "provider-bounce" + markers.mkdir(parents=True) + requested = markers / "bounce-requested" + taken = markers / ( + f"bounce-taken{origins.TAKEN_GENERATION_SEPARATOR}generation" + ) + requested.write_text("11111111-2222-4333-8444-555555555555\n", encoding="utf-8") + taken.write_text("", encoding="utf-8") + inventory = { + "sessions": [ + {"shpool_id_raw": "bounce-requested", "app_server_window": True}, + {"shpool_id_raw": "bounce-taken", "app_server_window": True}, + ] + } + + origins.apply_session_origins( + inventory, + state_dir=self.state, + settle_bounce_receipts=origins.capture_bounce_receipts(self.state), + ) + + self.assertTrue( + requested.is_file(), + "a bounce nobody performed was cancelled, which closes the session", + ) + self.assertEqual( + "11111111-2222-4333-8444-555555555555", + requested.read_text(encoding="utf-8").strip(), + ) + # The taken one still goes: that is the whole point of the sighting. + self.assertFalse(taken.exists()) + # And a session with a pending instruction still reads as bouncing, so + # the row it belongs to is never folded while it waits. + self.assertTrue(origins._provider_is_bouncing(self.state, "bounce-requested")) + + def test_a_taken_marker_belongs_to_its_session_and_is_not_swept(self) -> None: + """The receipt is the same session's state under a second name. + + Swept as a stranger, a bounce in flight under a LIVE session would be + disarmed the moment it passed the grace -- and a disarmed bounce is a + person's window in the machine list for as long as its replacement + takes. Once the session really is gone, both names go. + """ + markers = self.state / "provider-bounce" + markers.mkdir(parents=True) + live = markers / ( + f"still-here{origins.TAKEN_GENERATION_SEPARATOR}generation" + ) + dead = markers / f"gone{origins.TAKEN_GENERATION_SEPARATOR}generation" + live_reservation = markers / ( + f"{origins.BOUNCE_GENERATION_RESERVATION_PREFIX}still-here" + f"{origins.TAKEN_GENERATION_SEPARATOR}generation" + ) + dead_reservation = markers / ( + f"{origins.BOUNCE_GENERATION_RESERVATION_PREFIX}gone" + f"{origins.TAKEN_GENERATION_SEPARATOR}generation" + ) + for marker in (live, dead, live_reservation, dead_reservation): + marker.write_text("", encoding="utf-8") + old = time.time() - (origins.ORIGIN_PRUNE_GRACE_MS / 1000) * 2 + for marker in (live, dead, live_reservation, dead_reservation): + os.utime(marker, (old, old)) + + captured = origins.capture_bounce_cleanup_generations(self.state) + removed = origins.prune_bounce_markers( + self.state, + ["still-here"], + retire_generations=captured, + ) + + self.assertEqual(2, removed) + self.assertTrue(live.is_file(), "a bounce in flight was disarmed") + self.assertTrue(live_reservation.is_file()) + self.assertFalse(dead.exists()) + self.assertFalse(dead_reservation.exists()) + + def test_a_read_only_collection_deletes_no_marker(self) -> None: + """`--no-write` promised not to create state, so it may not delete it. + + Every `sp` guard snapshot comes through that branch, unlocked and + concurrent with the picker that is writing bounce instructions. Rows are + still classified there; only the deleting is left to a collection that + is allowed to write. + """ + markers = self.state / "provider-bounce" + markers.mkdir(parents=True) + taken = markers / f"settled{origins.TAKEN_GENERATION_SEPARATOR}generation" + taken.write_text("", encoding="utf-8") + inventory = { + "sessions": [{"shpool_id_raw": "settled", "app_server_window": True}] + } + + origins.apply_session_origins( + inventory, state_dir=self.state, clear_settled_bounces=False + ) + + self.assertTrue(taken.is_file()) + self.assertEqual(origins.HUMAN, inventory["sessions"][0]["origin"]) + + def test_a_stale_sighting_cannot_retire_a_newer_receipt(self) -> None: + """The review ordering, including its complete restart-gap reading.""" + markers = self.state / "provider-bounce" + markers.mkdir(parents=True) + pending = markers / "person-session" + newer = markers / ( + f"person-session{origins.TAKEN_GENERATION_SEPARATOR}newgeneration" + ) + + # Nothing was receipted when this collector began its process reading. + stale_generations = origins.capture_bounce_receipts(self.state) + stale_inventory = { + "sessions": [ + {"shpool_id_raw": "person-session", "app_server_window": True} + ] + } + + # The picker requests the next bounce and the provider takes it while + # that older process reading is still in flight. + pending.write_text( + "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee\n", + encoding="utf-8", + ) + os.replace(pending, newer) + origins.apply_session_origins( + stale_inventory, + state_dir=self.state, + settle_bounce_receipts=stale_generations, + ) + self.assertTrue(newer.is_file(), "stale evidence retired a newer receipt") + + socket = "/tmp/session-kit-stale-generation.sock" + table = ProcessTable( + { + 2001: { + "pid": 2001, + "ppid": 1001, + "cmdline": [ + "codex", + "app-server", + "--listen", + f"unix://{socket}", + ], + }, + 2100: { + "pid": 2100, + "ppid": 1001, + "cmdline": ["python3", "provider_broker.py", "--socket", socket], + }, + } + ) + verdict = collector._codex_app_server_driver( + 2001, table[2001]["cmdline"], table, table, 120 + ) + self.assertEqual("program", verdict, "the gap control was widened away") + gap = { + "sessions": [ + { + "shpool_id_raw": "person-session", + "machine_driven": verdict == "program", + } + ] + } + origins.apply_session_origins(gap, state_dir=self.state) + self.assertEqual(origins.HUMAN, gap["sessions"][0]["origin"]) + + # A fresh positive sighting captures this exact generation and settles + # it. This is generation binding, not a blanket refusal to settle. + fresh_generations = origins.capture_bounce_receipts(self.state) + origins.apply_session_origins( + { + "sessions": [ + {"shpool_id_raw": "person-session", "app_server_window": True} + ] + }, + state_dir=self.state, + settle_bounce_receipts=fresh_generations, + ) + self.assertFalse(newer.exists()) + origins.apply_session_origins(gap, state_dir=self.state) + self.assertEqual(origins.MACHINE, gap["sessions"][0]["origin"]) + + def test_a_captured_generation_cannot_reach_its_replacement(self) -> None: + markers = self.state / "provider-bounce" + markers.mkdir(parents=True) + old = markers / f"same{origins.TAKEN_GENERATION_SEPARATOR}oldgeneration" + new = markers / f"same{origins.TAKEN_GENERATION_SEPARATOR}newgeneration" + old.write_text("", encoding="utf-8") + captured = origins.capture_bounce_receipts(self.state) + old.unlink() + new.write_text("", encoding="utf-8") + + origins.apply_session_origins( + {"sessions": [{"shpool_id_raw": "same", "app_server_window": True}]}, + state_dir=self.state, + settle_bounce_receipts=captured, + ) + + self.assertTrue(new.is_file()) + + def test_an_already_live_shells_reusable_receipt_is_never_settled(self) -> None: + markers = self.state / "provider-bounce" + markers.mkdir(parents=True) + legacy = markers / f"legacy{origins.TAKEN_SUFFIX}" + legacy.write_text("", encoding="utf-8") + + origins.apply_session_origins( + { + "sessions": [ + {"shpool_id_raw": "legacy", "app_server_window": True} + ] + }, + state_dir=self.state, + settle_bounce_receipts=origins.capture_bounce_receipts(self.state), + ) + + self.assertTrue(legacy.is_file()) + + def test_a_bounce_marker_outliving_its_session_is_swept(self) -> None: + """The marker is removed by the shell that relaunches the provider. + + One still there when its session is gone means that shell never got + there -- killed, or the box went down mid-bounce -- and nothing will + ever consume it; left behind, it suppresses classification for that id + forever. A marker for a LIVE session is never touched: taking one away + mid-bounce is what puts a person's window in the machine list while it + restarts. + """ + markers = self.state / "provider-bounce" + markers.mkdir(parents=True) + stale = markers / "gone" + live = markers / "still-here" + fresh = markers / "just-armed" + for index, marker in enumerate((stale, live, fresh), start=1): + marker.write_text( + f"uuid\npadding\ngeneration{index}\n", + encoding="utf-8", + ) + old = time.time() - (origins.ORIGIN_PRUNE_GRACE_MS / 1000) * 2 + os.utime(stale, (old, old)) + os.utime(live, (old, old)) + + captured = origins.capture_bounce_cleanup_generations(self.state) + self.assertEqual( + 0, + origins.prune_bounce_markers( + self.state, + [], + retire_generations=captured, + ), + ) + self.assertEqual( + 1, + origins.prune_bounce_markers( + self.state, + ["still-here"], + retire_generations=captured, + ), + ) + + self.assertFalse(stale.exists()) + self.assertTrue(live.exists()) + self.assertTrue(fresh.exists()) + + def test_session_gone_sweep_cannot_reach_a_newer_bounce_request(self) -> None: + markers = self.state / "provider-bounce" + markers.mkdir(parents=True) + request = markers / "gone" + request.write_text("uuid\npadding\noldgeneration\n", encoding="utf-8") + captured = origins.capture_bounce_cleanup_generations(self.state) + request.write_text("uuid\npadding\nnewgeneration\n", encoding="utf-8") + old = time.time() - (origins.ORIGIN_PRUNE_GRACE_MS / 1000) * 2 + os.utime(request, (old, old)) + + self.assertEqual( + 0, + origins.prune_bounce_markers( + self.state, + ["still-here"], + now_unix_ms=int(time.time() * 1000), + retire_generations=captured, + ), + ) + self.assertIn("newgeneration", request.read_text(encoding="utf-8")) + + def test_every_row_carries_an_origin_before_anything_filters(self) -> None: + origins.record_origin(self.state, shpool_id="s2", origin=origins.MACHINE) + inventory = { + "sessions": [ + {"shpool_id_raw": "s1"}, + { + "shpool_id_raw": "s2", + "started_at_unix_ms": 2, + "shpool_shell": {"pid": 20, "process_start_ticks": 200}, + }, + {"shpool_id_raw": None}, + ] + } + origins.apply_session_origins(inventory, state_dir=self.state) + self.assertEqual( + [origins.HUMAN, origins.MACHINE, origins.HUMAN], + [row["origin"] for row in inventory["sessions"]], + ) + + def test_a_stamp_outranks_the_driver_verdict_in_both_directions(self) -> None: + """Provenance is the answer wherever it exists. + + The driver verdict reads the present -- who holds a socket right now. + The stamp records who CREATED the session, which is what the operator asked + about. So a session they created stays theirs even on a refresh where no + window happens to hold it (a provider bounce is exactly that), and a + session an agent created folds even while a window is attached. + + The third row is a control, and it is what makes this test able to + fail: without it, "person" reads human whether the stamp outranked a + driver verdict or no driver verdict was ever consulted. The control is + the same verdict on an unstamped row, so a run where the driver did + nothing at all fails here instead of passing quietly. + """ + origins.record_origin(self.state, shpool_id="person", origin=origins.HUMAN) + origins.record_origin(self.state, shpool_id="agents", origin=origins.MACHINE) + inventory = { + "sessions": [ + { + "shpool_id_raw": "person", + "machine_driven": True, + "started_at_unix_ms": 2, + "shpool_shell": {"pid": 20, "process_start_ticks": 200}, + }, + { + "shpool_id_raw": "agents", + "started_at_unix_ms": 2, + "shpool_shell": {"pid": 20, "process_start_ticks": 200}, + }, + {"shpool_id_raw": "unstamped", "machine_driven": True}, + ] + } + + origins.apply_session_origins(inventory, state_dir=self.state) + + self.assertEqual( + [origins.HUMAN, origins.MACHINE, origins.MACHINE], + [row["origin"] for row in inventory["sessions"]], + ) + + def test_only_a_never_stamped_session_is_decided_by_the_driver(self) -> None: + """The legacy case the second signal exists for.""" + inventory = { + "sessions": [ + {"shpool_id_raw": "legacy-worker", "machine_driven": True}, + {"shpool_id_raw": "legacy-window"}, + ] + } + + origins.apply_session_origins(inventory, state_dir=self.state) + + self.assertEqual( + [origins.MACHINE, origins.HUMAN], + [row["origin"] for row in inventory["sessions"]], + ) + + def test_a_bouncing_provider_never_folds_an_unstamped_row(self) -> None: + """The kit removes the window itself when it repaints a title. + + During that relaunch an unstamped session a person is sitting in has + a server and a broker and no window -- the worker shape exactly. The + kit knows it asked for the restart, so it does not read the verdict. + """ + bounces = self.state / "provider-bounce" + bounces.mkdir(parents=True) + (bounces / "bouncing").write_text("uuid\n", encoding="utf-8") + inventory = { + "sessions": [ + {"shpool_id_raw": "bouncing", "machine_driven": True}, + {"shpool_id_raw": "not-bouncing", "machine_driven": True}, + ] + } + + origins.apply_session_origins(inventory, state_dir=self.state) + + self.assertEqual( + [origins.HUMAN, origins.MACHINE], + [row["origin"] for row in inventory["sessions"]], + ) + + def test_the_core_records_and_lists_one_origin(self) -> None: + env = { + **os.environ, + "SESSION_KIT_STATE_DIR": str(self.state), + "HOME": str(self.state), + } + recorded = subprocess.run( + [sys.executable, str(CORE), "origin", "record", "s9", "machine"], + env=env, + capture_output=True, + text=True, + ) + self.assertEqual(0, recorded.returncode, recorded.stderr) + self.assertEqual("machine", json.loads(recorded.stdout)["origin"]) + listed = subprocess.run( + [sys.executable, str(CORE), "origin", "list"], + env=env, + capture_output=True, + text=True, + ) + self.assertEqual(0, listed.returncode, listed.stderr) + self.assertEqual( + "machine", json.loads(listed.stdout)["sessions"]["s9"]["origin"] + ) + self.assertEqual( + { + "shell_pid": 20, + "shell_start_ticks": 200, + "started_at_unix_ms": 2, + }, + json.loads(listed.stdout)["sessions"]["s9"]["instance"], + ) + refused = subprocess.run( + [sys.executable, str(CORE), "origin", "record", "s9", "robot"], + env=env, + capture_output=True, + text=True, + ) + self.assertNotEqual(0, refused.returncode) + + +class OriginAtCreationTests(unittest.TestCase): + def setUp(self) -> None: + self.fixture = CommandFixture() + + def tearDown(self) -> None: + self.fixture.close() + + def stamps(self) -> list[list[str]]: + if not self.fixture.origin_log.exists(): + return [] + return [ + json.loads(line) + for line in self.fixture.origin_log.read_text(encoding="utf-8").splitlines() + if line + ] + + def new_session(self, *argv: str, env: dict[str, str] | None = None) -> None: + merged = { + **self.fixture.env(), + "STUB_DYNAMIC_PROVIDER": "shell", + "STUB_DYNAMIC_CWD": str(self.fixture.project), + **(env or {}), + } + run([SP, "new", "shell", "fixture", *argv], env=merged) + + def test_a_new_session_is_stamped_as_a_persons_by_default(self) -> None: + self.new_session() + self.assertEqual([["human"]], [stamp[1:] for stamp in self.stamps()]) + + def test_the_flag_stamps_a_machine_session(self) -> None: + self.new_session("--origin", "machine") + self.assertEqual([["machine"]], [stamp[1:] for stamp in self.stamps()]) + + def test_creation_stamps_the_confirmed_shell_instance(self) -> None: + self.new_session("--origin", "machine") + created = json.loads(self.fixture.shpool_state.read_text(encoding="utf-8"))[ + "sessions" + ][0] + stamped = json.loads( + self.fixture.origin_instance_log.read_text(encoding="utf-8") + ) + + self.assertEqual( + { + "shell_pid": "1001", + "shell_start_ticks": "10001", + "started_at_unix_ms": str(created["started_at_unix_ms"]), + }, + stamped, + ) + + def test_the_environment_stamps_everything_a_run_starts(self) -> None: + self.new_session(env={"SESSION_KIT_ORIGIN": "machine"}) + self.assertEqual([["machine"]], [stamp[1:] for stamp in self.stamps()]) + + def test_the_flag_outranks_the_environment(self) -> None: + self.new_session("--origin", "human", env={"SESSION_KIT_ORIGIN": "machine"}) + self.assertEqual([["human"]], [stamp[1:] for stamp in self.stamps()]) + + def test_an_unknown_environment_value_is_a_persons_session(self) -> None: + self.new_session(env={"SESSION_KIT_ORIGIN": "robot"}) + self.assertEqual([["human"]], [stamp[1:] for stamp in self.stamps()]) + + def test_a_session_that_creates_a_session_stamps_a_machine(self) -> None: + """The operator's rule: only sessions THEY created belong in their list. + + Sessions 87 and 88 were their examples -- an agent started them, and + both came back as their own rows because the kit asked the caller to + volunteer that it was a machine and no agent ever does. Every managed + session exports SHPOOL_SESSION_NAME into what it runs; the login shell + the picker runs in does not. So the caller is readable without its + cooperation, whatever provider or verb it uses. + """ + self.new_session(env={"SHPOOL_SESSION_NAME": "s20200102-050607-2000000"}) + self.assertEqual([["machine"]], [stamp[1:] for stamp in self.stamps()]) + + def test_the_pickers_own_creations_are_still_the_persons(self) -> None: + """The other half of the same rule, and the one that must never break. + + The picker runs in the login shell, which exports no session name. + If this ever reads machine, every session the operator starts leaves their list. + """ + for label, env in ( + ("no session name at all", {}), + ("an empty session name", {"SHPOOL_SESSION_NAME": ""}), + ("a name that is not a session ID", {"SHPOOL_SESSION_NAME": "workbench"}), + ): + with self.subTest(label=label): + self.fixture.close() + self.fixture = CommandFixture() + self.new_session(env=env) + self.assertEqual( + [["human"]], [stamp[1:] for stamp in self.stamps()] + ) + + def test_a_declaration_still_outranks_the_caller(self) -> None: + """An operator override, and the repair paths' carried origin.""" + inside = {"SHPOOL_SESSION_NAME": "s20200102-050607-2000000"} + self.new_session("--origin", "human", env=inside) + self.assertEqual([["human"]], [stamp[1:] for stamp in self.stamps()]) + + self.fixture.close() + self.fixture = CommandFixture() + self.new_session(env={**inside, "SESSION_KIT_ORIGIN": "human"}) + self.assertEqual([["human"]], [stamp[1:] for stamp in self.stamps()]) + + def restore(self, provider: str, uuid: str, env: dict[str, str] | None = None): + merged = { + **self.fixture.env(), + "SESSION_KIT_BACKGROUND": "1", + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "1", + "STUB_DYNAMIC_PROVIDER": provider, + "STUB_DYNAMIC_UUID": uuid, + "STUB_DYNAMIC_CWD": str(self.fixture.project), + **(env or {}), + } + return run( + [SP, "restore-exact", provider, uuid, str(self.fixture.project)], + env=merged, + check=False, + ) + + def record_close(self, provider: str, uuid: str, origin: str) -> None: + closed_sessions.record_close( + provider=provider, + uuid=uuid, + cwd=str(self.fixture.project), + origin=origin, + environ=self.fixture.env(), + ) + + def test_restore_brings_a_machine_session_back_as_a_machine(self) -> None: + """The recovery sweep recreated 87-90, and lost what they were. + + It runs from the login shell, where nothing declares a machine, so + every restored agent session came back as one of the operator's rows. A restore + resumes a session that already had an origin, so the record of what it + was outranks any reading of who is running the restore. + """ + uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + self.record_close("codex", uuid, "machine") + self.restore("codex", uuid) + self.assertEqual("machine", self.stamps()[-1][1]) + + def test_restore_keeps_a_persons_session_the_persons(self) -> None: + """The safe direction, at the same door: only a recorded machine + moves a restored row out of the list the operator reads.""" + for label, recorded in ( + ("closed as a person's", "human"), + ("no closed record at all", None), + ): + with self.subTest(label=label): + self.fixture.close() + self.fixture = CommandFixture() + uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + if recorded is not None: + self.record_close("codex", uuid, recorded) + self.restore("codex", uuid) + self.assertEqual("human", self.stamps()[-1][1]) + + def test_a_declared_origin_still_outranks_the_closed_record(self) -> None: + """The picker's repair paths declare one, and it must keep winning.""" + uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + self.record_close("codex", uuid, "machine") + self.restore("codex", uuid, env={"SESSION_KIT_ORIGIN": "human"}) + self.assertEqual("human", self.stamps()[-1][1]) + + def test_the_record_wins_in_both_directions_wherever_the_restore_runs( + self, + ) -> None: + """A restore is not a creation, so the caller is not the question. + + The record says what this conversation WAS. Reading only its machine + half left the other direction to the caller -- and the caller reading + answers "machine" for anything running inside a managed session. So a + conversation recorded as the operator's, restored by a sweep or by them + typing `sp restore-exact` in one of their own windows, came back a + machine's and left the list they were looking at. Both cells of the same + table, run from inside a session, are asserted here. + """ + inside = {"SHPOOL_SESSION_NAME": "s20200102-050607-2000000"} + for label, recorded, expected in ( + ("recorded as the person's", "human", "human"), + ("recorded as an agent's", "machine", "machine"), + ): + with self.subTest(label=label): + self.fixture.close() + self.fixture = CommandFixture() + uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + self.record_close("codex", uuid, recorded) + self.restore("codex", uuid, env=inside) + self.assertEqual(expected, self.stamps()[-1][1]) + + def test_an_unrecorded_conversation_is_the_persons_wherever_it_is_restored( + self, + ) -> None: + """A restore creates nothing, so the caller is not its creator. + + The question "who started this?" has an answer for a NEW session and + the caller's context answers it. For a restore the conversation + already had an owner, and if no record survives, nobody knows who -- + which is unknown, which is the person's. Reading the caller there + answered a question nobody asked and froze the answer as a permanent + stamp, so a repair, a model change, or the verb typed in one of their + own windows took a session out of their list for good. An agent that + wants its restore marked still says so. + """ + for label, declared, expected in ( + ("nothing declared", None, "human"), + ("an agent declaring itself", "machine", "machine"), + ): + with self.subTest(label=label): + self.fixture.close() + self.fixture = CommandFixture() + env = {"SHPOOL_SESSION_NAME": "s20200102-050607-2000000"} + if declared: + env["SESSION_KIT_ORIGIN"] = declared + self.restore("codex", "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", env=env) + self.assertEqual(expected, self.stamps()[-1][1]) + + def test_a_ledger_that_will_not_parse_never_hides_the_persons_session( + self, + ) -> None: + """Opening a file is not reading it. + + A ledger truncated mid-write, or with one line this module would skip, + opens perfectly and yields no rows. Read as "no record", that silence + was answered from the caller's context, so one interrupted write took + a recorded conversation of theirs out of their list on the way back in -- + exit 0, no warning. A ledger that will not parse is unproven state, + and unproven state leaves the row visible. + """ + uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + self.record_close("codex", uuid, "human") + ledger = closed_sessions.ledger_path(self.fixture.env()) + ledger.write_text( + '{"provider": "codex", "uuid": "%s", "closed_at_unix' % uuid, + encoding="utf-8", + ) + + self.assertFalse(closed_sessions.ledger_is_readable(self.fixture.env())) + + self.restore( + "codex", uuid, env={"SHPOOL_SESSION_NAME": "s20200102-050607-2000000"} + ) + self.assertEqual("human", self.stamps()[-1][1]) + + def test_a_ledger_that_will_not_read_never_hides_the_persons_session( + self, + ) -> None: + """An unreadable file is not a record, and not the absence of one. + + The ledger is the thing that says a conversation was an agent's. Read + as "no record", its failure hands the answer to the caller -- and from + inside a session the caller reads machine, so an unreadable file takes + one of their conversations out of their list on the way back in. + """ + uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + self.record_close("codex", uuid, "human") + ledger = closed_sessions.ledger_path(self.fixture.env()) + ledger.chmod(0o000) + try: + self.restore( + "codex", + uuid, + env={"SHPOOL_SESSION_NAME": "s20200102-050607-2000000"}, + ) + finally: + ledger.chmod(0o600) + + self.assertEqual("human", self.stamps()[-1][1]) + + def test_an_unknown_origin_refuses_the_creation(self) -> None: + refused = run( + [SP, "new", "shell", "fixture", "--origin", "robot"], + env=self.fixture.env(), + check=False, + ) + self.assertEqual(2, refused.returncode) + self.assertEqual( + "session-kit: --origin requires human or machine\n", refused.stderr + ) + self.assertEqual([], self.stamps()) + + def test_the_stamp_names_the_session_that_was_attached(self) -> None: + """The captured manager name and the stamped manager name agree.""" + self.new_session("--origin", "machine") + log = self.fixture.shpool_log.read_text(encoding="utf-8") + self.assertTrue(log.startswith("attach "), log) + stamped = self.stamps() + self.assertEqual(1, len(stamped)) + self.assertEqual(log.split()[1], stamped[0][0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_snapshot_contract.py b/tests/test_snapshot_contract.py new file mode 100644 index 0000000..002f2b2 --- /dev/null +++ b/tests/test_snapshot_contract.py @@ -0,0 +1,478 @@ +"""The machine snapshot is a contract: additive only, and one `needs you`. + +The login picker parses this document live. A renamed or dropped field blinds +the screen a person is looking at, so these tests hold the existing shape exactly +and allow only additions. They also hold the fold that makes the contract worth +having: a session the fleet flagged as stalled comes back `needs_you`, with its +reasons, so no screen has to do that arithmetic itself and no two screens can +disagree about what needs a person. + +`lib/sessionkit_inventory/SNAPSHOT.md` is the written form of the same contract; +one test reads it, so the document cannot fall behind the code. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import time +import unittest +from unittest import mock + +from tests.support import REPO + + +sys.path.insert(0, os.fspath(REPO / "lib")) + +CORE_PATH = REPO / "lib" / "session_inventory.py" +CORE_SPEC = importlib.util.spec_from_file_location("session_inventory", CORE_PATH) +assert CORE_SPEC is not None and CORE_SPEC.loader is not None +inventory_core = importlib.util.module_from_spec(CORE_SPEC) +sys.modules[CORE_SPEC.name] = inventory_core +CORE_SPEC.loader.exec_module(inventory_core) + +from sessionkit_inventory import labels # noqa: E402 +from sessionkit_inventory import render # noqa: E402 + + +SNAPSHOT_DOC = REPO / "lib" / "sessionkit_inventory" / "SNAPSHOT.md" +NOW_MS = 1_800_000_000_000 + +# The fields the login picker reads out of this document today. It is bash and +# Python-in-a-heredoc reading a JSON file; nothing type-checks it at the seam, +# so the list lives here and a removal fails loudly instead of silently. +PICKER_FIELDS = ( + "terminal_number", + "row", + "shpool_id", + "shpool_id_raw", + "display_shpool_id", + "availability", + "provider", + "display_provider", + "identity", + "title", + "display_title", + "native_title", + "cwd", + "account_alias", + "agent_status", + "needs_you", + "setup_incomplete", + "started_at_unix_ms", + "process_age_seconds", + "recent_output_at_unix_ms", + "recent_output_age_seconds", + "subagents", + "active_subagent_count", + "recovery", + "diagnostics", +) + +PUBLISHED_FIELDS = ( + "account_alias", + "age_seconds", + "attached", + "display_model", + "launch_model", + "model", + "model_source", + "model_state", + "needs_you", + "needs_you_reasons", + "number", + "origin", + "state", + "subagent_count", +) + + +def collected_row( + shpool_id: str = "s20260812-101010-1", + *, + number: int | None = 3, + uuid: str = "11111111-2222-4333-8444-555555555555", + **extra: object, +) -> dict: + """A row shaped the way the collectors write one.""" + item = { + "row": 1, + "terminal_number": number, + "shpool_id": shpool_id, + "shpool_id_raw": shpool_id, + "display_shpool_id": shpool_id, + "mutation_allowed": True, + "mutation_rejection_reason": None, + "shpool_status": "Disconnected", + "availability": "ready", + "provider": "claude", + "display_provider": "claude", + "setup_incomplete": False, + "identity": { + "uuid": uuid, + "pid": 4242, + "process_start_ticks": 99, + "provenance": "transcript", + "confidence": "exact", + }, + "title": "Nightly index rebuild", + "title_source": "automatic", + "display_title": "Nightly index rebuild", + "native_title": "Nightly index rebuild", + "cwd": "/srv/project", + "started_at_unix_ms": NOW_MS - 7_200_000, + "process_age_seconds": 600, + "recent_output_at_unix_ms": NOW_MS - 30_000, + "recent_output_age_seconds": 30, + "agent_status": "running", + "needs_you": False, + "subagents": [{"status": "running"}, {"status": "idle"}], + "active_subagent_count": 1, + "recovery": {"available": True, "provider": "claude", "uuid": uuid}, + "diagnostics": [], + "shpool_shell": {"pid": 41, "process_start_ticks": 9}, + "account_alias": "work", + "account_email": "person@example.invalid", + "account_plan": "Max", + "display_color": "cyan", + } + item.update(extra) + return item + + +def document(*rows: dict) -> dict: + return { + "schema_version": 1, + "generated_at": "2026-08-12T00:00:00Z", + "source": "live", + "stale": False, + "warnings": [], + "daemon_generation": {"boot_id": "b", "pid": 7, "process_start_ticks": 70}, + "sessions": list(rows), + "outside_agents": [], + } + + +class FleetStallsTests(unittest.TestCase): + def setUp(self) -> None: + self.fleet = Path(tempfile.mkdtemp(prefix="session-kit-fleet.", dir=REPO.parent)) + self.addCleanup(self._remove) + patcher = mock.patch.dict( + os.environ, {"SESSION_KIT_FLEET_DIR": os.fspath(self.fleet)} + ) + patcher.start() + self.addCleanup(patcher.stop) + + def _remove(self) -> None: + for path in sorted(self.fleet.rglob("*"), reverse=True): + path.unlink() + self.fleet.rmdir() + + def write_stalls(self, payload: object) -> None: + (self.fleet / "stalls.json").write_text( + payload if isinstance(payload, str) else json.dumps(payload), + encoding="utf-8", + ) + + def test_no_file_is_no_flags(self) -> None: + self.assertEqual({}, inventory_core.read_fleet_stalls()) + + def test_the_three_degrees_of_needs_you_are_returned(self) -> None: + now = time.time() + self.write_stalls( + { + "generated_at": int(now), + "stalled": [ + {"key": "a", "reason": "unsurfaced", "since": now - 100}, + {"key": "b", "reason": "unanswered", "since": now - 100}, + {"key": "c", "reason": "silent", "since": now - 100}, + ], + } + ) + self.assertEqual( + {"a": ["unsurfaced"], "b": ["unanswered"], "c": ["silent"]}, + inventory_core.read_fleet_stalls(now=now), + ) + + def test_an_orphan_is_a_dead_session_not_a_persons_turn(self) -> None: + now = time.time() + self.write_stalls( + { + "generated_at": int(now), + "stalled": [{"key": "a", "reason": "orphan", "since": now}], + } + ) + self.assertEqual({}, inventory_core.read_fleet_stalls(now=now)) + + def test_a_flagger_that_stopped_running_is_no_evidence(self) -> None: + now = time.time() + self.write_stalls( + { + "generated_at": int(now) - inventory_core.FLEET_STALLS_FRESH_SECONDS, + "stalled": [{"key": "a", "reason": "silent", "since": now}], + } + ) + self.assertEqual({}, inventory_core.read_fleet_stalls(now=now)) + + def test_a_corrupt_or_oversized_file_hides_nothing_else(self) -> None: + self.write_stalls("{not json") + self.assertEqual({}, inventory_core.read_fleet_stalls()) + self.write_stalls([1, 2, 3]) + self.assertEqual({}, inventory_core.read_fleet_stalls()) + now = time.time() + self.write_stalls( + { + "generated_at": int(now), + "stalled": [{"key": "a", "reason": "silent"}] + + [{"padding": "x" * 64}] * 20_000, + } + ) + self.assertGreater( + (self.fleet / "stalls.json").stat().st_size, + inventory_core.FLEET_STALLS_MAX_BYTES, + ) + self.assertEqual({}, inventory_core.read_fleet_stalls(now=now)) + + def test_only_the_first_records_are_read(self) -> None: + now = time.time() + self.write_stalls( + { + "generated_at": int(now), + "stalled": [ + {"key": f"k{index}", "reason": "silent"} + for index in range(inventory_core.FLEET_STALLS_MAX_RECORDS + 5) + ], + } + ) + flags = inventory_core.read_fleet_stalls(now=now) + self.assertEqual(inventory_core.FLEET_STALLS_MAX_RECORDS, len(flags)) + + def test_a_flagged_session_needs_you_with_its_reasons(self) -> None: + now = time.time() + uuid = "11111111-2222-4333-8444-555555555555" + self.write_stalls( + { + "generated_at": int(now), + "stalled": [{"key": uuid, "reason": "unsurfaced", "since": now}], + } + ) + published = inventory_core.publish_view_fields( + document(collected_row(uuid=uuid)), now_ms=NOW_MS + ) + item = published["sessions"][0] + self.assertIs(True, item["needs_you"]) + self.assertEqual(["unsurfaced"], item["needs_you_reasons"]) + self.assertEqual(labels.NEEDS_YOU, item["state"]) + + def test_a_flag_finds_a_session_by_any_identifier_the_flagger_could_use( + self, + ) -> None: + for key in ( + "11111111-2222-4333-8444-555555555555", + "s20260812-101010-1", + "Nightly index rebuild", + ): + with self.subTest(key=key): + published = inventory_core.publish_view_fields( + document(collected_row()), + stalls={key: ["silent"]}, + now_ms=NOW_MS, + ) + self.assertIs(True, published["sessions"][0]["needs_you"]) + + def test_a_flag_for_another_machine_touches_nothing(self) -> None: + published = inventory_core.publish_view_fields( + document(collected_row()), + stalls={"someone-elses-uuid": ["silent"]}, + now_ms=NOW_MS, + ) + item = published["sessions"][0] + self.assertIs(False, item["needs_you"]) + self.assertEqual([], item["needs_you_reasons"]) + + +class PublishedViewTests(unittest.TestCase): + def published(self, *rows: dict, **kwargs: object) -> dict: + return inventory_core.publish_view_fields( + document(*rows), stalls={}, now_ms=NOW_MS, **kwargs + )["sessions"][0] + + def test_nothing_the_collectors_wrote_is_renamed_or_dropped(self) -> None: + original = collected_row() + before = json.loads(json.dumps(original)) + item = self.published(original) + for field, value in before.items(): + with self.subTest(field=field): + self.assertIn(field, item) + self.assertEqual(value, item[field]) + + def test_every_field_the_picker_reads_survives(self) -> None: + item = self.published(collected_row()) + for field in PICKER_FIELDS: + with self.subTest(field=field): + self.assertIn(field, item) + + def test_the_published_fields_are_all_present(self) -> None: + item = self.published(collected_row()) + for field in PUBLISHED_FIELDS: + with self.subTest(field=field): + self.assertIn(field, item) + self.assertEqual(3, item["number"]) + self.assertIs(False, item["attached"]) + self.assertEqual(labels.WORKING, item["state"]) + self.assertEqual(7200, item["age_seconds"]) + self.assertEqual(1, item["subagent_count"]) + self.assertEqual("work", item["account_alias"]) + self.assertIsNone(item["model"]) + self.assertIsNone(item["origin"]) + self.assertEqual([], item["needs_you_reasons"]) + + def test_an_unnumbered_session_publishes_no_number(self) -> None: + item = self.published(collected_row(number=None)) + self.assertIsNone(item["number"]) + item = self.published(collected_row(number=0)) + self.assertIsNone(item["number"]) + item = self.published(collected_row(number=True)) + self.assertIsNone(item["number"]) + + def test_an_attached_session_is_open_elsewhere(self) -> None: + item = self.published(collected_row(availability="attached")) + self.assertIs(True, item["attached"]) + + def test_model_and_origin_pass_through_when_a_collector_records_them(self) -> None: + item = self.published(collected_row(model="opus-5", origin="machine")) + self.assertEqual("opus-5", item["model"]) + self.assertEqual("machine", item["origin"]) + blank = self.published(collected_row(model=" ", origin="")) + self.assertIsNone(blank["model"]) + self.assertIsNone(blank["origin"]) + + def test_a_missing_account_alias_is_null_not_a_word(self) -> None: + item = self.published(collected_row(account_alias="")) + self.assertIsNone(item["account_alias"]) + + def test_state_is_the_one_state_word(self) -> None: + cases = ( + ({"needs_you": True}, labels.NEEDS_YOU), + ({"setup_incomplete": True}, labels.SETUP_INCOMPLETE), + ({"agent_status": "running"}, labels.WORKING), + ({"agent_status": "reply optional"}, labels.WORKING), + ({"agent_status": "needs your reply"}, labels.NEEDS_YOU), + ({"agent_status": "state unavailable"}, labels.STATUS_UNAVAILABLE), + ( + { + "agent_status": "running", + "recent_output_age_seconds": render.stall_threshold_seconds(), + }, + labels.WAITING_ON_YOU, + ), + ({"agent_status": "idle"}, labels.WAITING_ON_YOU), + ) + for overrides, expected in cases: + with self.subTest(overrides=overrides): + self.assertEqual(expected, self.published(collected_row(**overrides))["state"]) + + def test_age_is_null_when_the_open_time_is_unknown(self) -> None: + self.assertIsNone(self.published(collected_row(started_at_unix_ms=None))["age_seconds"]) + self.assertIsNone(self.published(collected_row(started_at_unix_ms=0))["age_seconds"]) + + def test_publishing_twice_changes_nothing(self) -> None: + first = inventory_core.publish_view_fields( + document(collected_row()), stalls={}, now_ms=NOW_MS + ) + again = inventory_core.publish_view_fields( + json.loads(json.dumps(first)), stalls={}, now_ms=NOW_MS + ) + self.assertEqual(first, again) + + def test_outside_agents_are_left_alone(self) -> None: + doc = document(collected_row()) + doc["outside_agents"] = [{"provider": "claude", "title": "outside"}] + published = inventory_core.publish_view_fields(doc, stalls={}, now_ms=NOW_MS) + self.assertEqual([{"provider": "claude", "title": "outside"}], + published["outside_agents"]) + + def test_a_malformed_document_is_returned_unharmed(self) -> None: + self.assertEqual({}, inventory_core.publish_view_fields({}, stalls={})) + broken = {"sessions": ["not a row", None, 7]} + self.assertEqual(broken, inventory_core.publish_view_fields(broken, stalls={})) + + def test_publishing_does_not_change_what_sp_list_prints(self) -> None: + """The human list is unchanged by the machine fields beside it.""" + os.environ["COLUMNS"] = "200" + try: + before = render.render_inventory( + document(collected_row()), False, color_enabled=lambda: False + ) + after = render.render_inventory( + inventory_core.publish_view_fields( + document(collected_row()), stalls={}, now_ms=NOW_MS + ), + False, + color_enabled=lambda: False, + ) + finally: + os.environ.pop("COLUMNS", None) + self.assertEqual(before, after) + + +class ServingBoundaryTests(unittest.TestCase): + """Every document that leaves for a screen is published, not just the ones + a caller remembered to publish.""" + + def test_the_command_line_publishes_what_it_hands_out(self) -> None: + with tempfile.TemporaryDirectory(prefix=".snapshot-input-", dir=REPO) as raw: + source = Path(raw) / "inventory.json" + source.write_text(json.dumps(document(collected_row())), encoding="utf-8") + fleet = Path(raw) / "fleet" + fleet.mkdir() + environment = dict(os.environ) + environment["SESSION_KIT_FLEET_DIR"] = os.fspath(fleet) + environment["PYTHONDONTWRITEBYTECODE"] = "1" + result = subprocess.run( + [sys.executable, os.fspath(CORE_PATH), "lookup", "--input", + os.fspath(source), "3"], + capture_output=True, + text=True, + env=environment, + cwd=REPO, + ) + self.assertEqual(0, result.returncode, result.stderr) + item = json.loads(result.stdout) + for field in PUBLISHED_FIELDS: + with self.subTest(field=field): + self.assertIn(field, item) + self.assertEqual(labels.WORKING, item["state"]) + self.assertEqual(3, item["number"]) + + +class SnapshotDocumentTests(unittest.TestCase): + def test_the_document_describes_every_published_field(self) -> None: + text = SNAPSHOT_DOC.read_text(encoding="utf-8") + section = text.split("### Published view")[1].split("`project` is")[0] + documented = set(re.findall(r"^\| `([a-z_]+)`", section, re.M)) + self.assertEqual(set(PUBLISHED_FIELDS), documented) + + def test_the_document_names_the_additive_rule_and_the_stall_reasons(self) -> None: + text = SNAPSHOT_DOC.read_text(encoding="utf-8") + self.assertIn("Additive only", text) + for reason in labels.STALL_REASONS_NEEDS_YOU: + self.assertIn(reason, text) + self.assertIn("SESSION_KIT_FLEET_DIR", text) + + def test_the_document_prints_no_identifier_of_its_own(self) -> None: + text = SNAPSHOT_DOC.read_text(encoding="utf-8") + self.assertIsNone( + re.search(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}", text), + "SNAPSHOT.md must not carry a real conversation identifier", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_state_column.py b/tests/test_state_column.py new file mode 100644 index 0000000..0233768 --- /dev/null +++ b/tests/test_state_column.py @@ -0,0 +1,229 @@ +"""The five-word state column and its priority order.""" + +from __future__ import annotations + +import json +import os +import sys +import unittest +from pathlib import Path +from unittest import mock + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO / "lib")) + +from sessionkit_inventory import labels # noqa: E402 +from sessionkit_inventory import processes # noqa: E402 +from sessionkit_inventory.model import canonical_session_order_key # noqa: E402 +from sessionkit_inventory.providers_claude import _pending_claude_tools # noqa: E402 +from sessionkit_inventory.providers_claude import _parse_claude_payload # noqa: E402 +from sessionkit_inventory.render import _display_width # noqa: E402 +from sessionkit_inventory.render import stall_threshold_seconds # noqa: E402 +from sessionkit_tui.model import stall_seconds as tui_stall_seconds # noqa: E402 +from tests.test_inventory import inventory_core, inventory_fixture, uuid_for # noqa: E402 + + +def claude_record(*parts: dict) -> bytes: + return ( + json.dumps( + { + "type": "assistant", + "isSidechain": False, + "message": {"role": "assistant", "content": list(parts)}, + } + ).encode() + + b"\n" + ) + + +class ClaudeQuestionTailTests(unittest.TestCase): + def test_unmatched_ask_user_question_is_open_until_its_exact_result(self) -> None: + question = claude_record( + {"type": "tool_use", "name": "AskUserQuestion", "id": "toolu-q"} + ) + unrelated = json.dumps( + { + "type": "user", + "isSidechain": False, + "message": { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "toolu-other"} + ], + }, + } + ).encode() + b"\n" + answer = json.dumps( + { + "type": "user", + "isSidechain": False, + "message": { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "toolu-q"} + ], + }, + } + ).encode() + b"\n" + + self.assertEqual((True, True), _pending_claude_tools(question + unrelated)) + self.assertEqual((False, False), _pending_claude_tools(question + answer)) + + def test_malformed_tail_never_invents_a_question(self) -> None: + self.assertEqual((False, False), _pending_claude_tools(b"{\nnot-json\n")) + + def test_user_record_cannot_invent_an_ask_user_question(self) -> None: + malformed = ( + json.dumps( + { + "type": "user", + "message": { + "role": "user", + "content": [ + { + "type": "tool_use", + "name": "AskUserQuestion", + "id": "toolu-q", + } + ], + }, + "isSidechain": False, + } + ).encode() + + b"\n" + ) + + ask, pending = _pending_claude_tools(malformed) + self.assertEqual((False, False), (ask, pending)) + parsed = _parse_claude_payload( + [ + { + "pid": 77, + "sessionId": uuid_for(7), + "status": "idle", + "_session_kit_pending_ask_user_question": ask, + "_session_kit_pending_tool_use": pending, + } + ], + palette=(), + attention_records={}, + attention_source="hook", + ) + self.assertFalse(parsed[0]["blocking_question"]) + + def test_permission_prompt_must_match_the_current_unresolved_tool(self) -> None: + exact = uuid_for(7) + item = { + "pid": 77, + "sessionId": exact, + "status": "busy", + "_session_kit_pending_tool_use": True, + "_session_kit_pending_tool_use_at_unix_ms": 100_000, + } + record = { + "notification_type": "permission_prompt", + "needs_you": True, + "recorded_at_ms": 105_000, + } + parsed = _parse_claude_payload( + [item], palette=(), attention_records={exact: record}, attention_source="hook" + ) + self.assertTrue(parsed[0]["blocking_question"]) + + # A keypress can leave the old hook raised. A later ordinary tool must + # not borrow that stale permission marker and become `question`. + item["_session_kit_pending_tool_use_at_unix_ms"] = 200_000 + parsed = _parse_claude_payload( + [item], palette=(), attention_records={exact: record}, attention_source="hook" + ) + self.assertFalse(parsed[0]["blocking_question"]) + + +class ChurnedQuestionCensusTests(unittest.TestCase): + """Positive transcript evidence survives unrelated process-table churn.""" + + @staticmethod + def build(*, churned: bool, provider: str = "claude") -> dict: + fixture = list(inventory_fixture(1, providers=(provider,))) + if provider == "claude": + fixture[1][0]["_session_kit_pending_ask_user_question"] = True + else: + fixture[3][2001][0]["_turn_state"] = "needs your reply" + if churned: + table = processes.ProcessTable(fixture[2]) + table.complete = False + fixture[2] = table + return inventory_core.build_inventory( + *fixture, + now=1_800_000_000, + daemon_binding={"pid": 10, "process_start_ticks": 100}, + )["sessions"][0] + + def test_claude_question_survives_a_churned_census(self) -> None: + complete = self.build(churned=False) + churned = self.build(churned=True) + self.assertTrue(complete["blocking_question"]) + self.assertTrue(churned["blocking_question"]) + + def test_codex_without_a_proven_app_server_marker_never_says_question(self) -> None: + for churned in (False, True): + with self.subTest(churned=churned): + row = self.build(churned=churned, provider="codex") + self.assertTrue(row["needs_you"]) + self.assertFalse(row["blocking_question"]) + + +class StatePriorityAndWidthTests(unittest.TestCase): + def test_exact_valid_state_priority(self) -> None: + rows = [ + {"needs_you": True, "transcript_idle": True, "shpool_id": "four", "availability": "ready"}, + {"agent_status": "working", "shpool_id": "three", "availability": "ready"}, + {"blocking_question": True, "shpool_id": "one", "availability": "ready"}, + {"needs_you": True, "shpool_id": "two", "availability": "ready"}, + ] + ordered = sorted(rows, key=canonical_session_order_key) + self.assertEqual( + ["question", "needs you", "working", "idle"], + [labels.session_state(row, stall_seconds=2700) for row in ordered], + ) + + def test_question_never_widens_the_needs_you_state_column(self) -> None: + self.assertLessEqual( + _display_width(labels.QUESTION), _display_width(labels.NEEDS_YOU) + ) + + def test_non_default_stall_value_drives_both_sorting_and_rendering(self) -> None: + rows = [ + { + "agent_status": "working", + "provider": "claude", + "recent_output_age_seconds": 3_000, + "shpool_id": "quiet-working", + "availability": "ready", + }, + { + "needs_you": True, + "provider": "codex", + "shpool_id": "needs-you", + "availability": "ready", + }, + ] + with mock.patch.dict( + os.environ, {"SESSION_KIT_STALL_SECONDS": "3600"}, clear=False + ): + ordered = sorted(rows, key=canonical_session_order_key) + self.assertEqual(3_600, stall_threshold_seconds()) + self.assertEqual(3_600, tui_stall_seconds()) + self.assertEqual( + ["needs you", "working"], + [ + labels.session_state( + row, stall_seconds=stall_threshold_seconds() + ) + for row in ordered + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_state_io.py b/tests/test_state_io.py new file mode 100644 index 0000000..a4662e2 --- /dev/null +++ b/tests/test_state_io.py @@ -0,0 +1,207 @@ +"""The safe-read discipline for private state files, pinned. + +``read_private_json`` guards every state read on this machine: O_NOFOLLOW, a +regular file only, exactly mode 0600, owned by the current user, bounded +bytes. Its previous coverage lived in a doctor test retired with the one-door +rebuild (2026-08-12); these tests pin the refusals directly so a regression +that follows a symlink or reads a loose-mode file cannot ship undetected. +""" +from __future__ import annotations + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO / "lib")) + +from sessionkit_inventory.state_io import ( # noqa: E402 + _LEGACY_LOCK_FENCE, + CollectionError, + StateLock, + _read_state_json, + atomic_write_json, + atomic_write_private_json, + read_private_json, +) + + +class ReadPrivateJsonTests(unittest.TestCase): + def setUp(self) -> None: + self._raw = tempfile.TemporaryDirectory(prefix=".state-io-") + self.addCleanup(self._raw.cleanup) + self.root = Path(self._raw.name) + + def _private(self, name: str, payload: object) -> Path: + path = self.root / name + path.write_text(json.dumps(payload), encoding="utf-8") + os.chmod(path, 0o600) + return path + + def test_a_conforming_file_reads(self) -> None: + path = self._private("ok.json", {"answer": 42}) + self.assertEqual({"answer": 42}, read_private_json(path, max_bytes=4096)) + + def test_a_planted_symlink_is_refused_not_followed(self) -> None: + real = self._private("real.json", {"secret": True}) + link = self.root / "link.json" + link.symlink_to(real) + with self.assertRaises(CollectionError): + read_private_json(link, max_bytes=4096) + + def test_a_loose_mode_is_refused(self) -> None: + for mode in (0o640, 0o644, 0o666, 0o400): + with self.subTest(mode=oct(mode)): + path = self._private(f"mode-{mode}.json", {}) + os.chmod(path, mode) + with self.assertRaises(CollectionError): + read_private_json(path, max_bytes=4096) + + def test_a_directory_is_refused(self) -> None: + directory = self.root / "dir.json" + directory.mkdir(mode=0o700) + with self.assertRaises(CollectionError): + read_private_json(directory, max_bytes=4096) + + def test_an_oversized_file_is_refused_not_truncated(self) -> None: + path = self._private("big.json", {}) + path.write_text('{"pad": "' + "x" * 200 + '"}', encoding="utf-8") + os.chmod(path, 0o600) + with self.assertRaises(CollectionError): + read_private_json(path, max_bytes=64) + + def test_missing_files_follow_the_allow_missing_switch(self) -> None: + absent = self.root / "absent.json" + self.assertIsNone(read_private_json(absent, max_bytes=64, allow_missing=True)) + with self.assertRaises(CollectionError): + read_private_json(absent, max_bytes=64) + + +class MutableStateReadTests(unittest.TestCase): + def setUp(self) -> None: + self._raw = tempfile.TemporaryDirectory(prefix=".mutable-state-") + self.addCleanup(self._raw.cleanup) + self.root = Path(self._raw.name) + + def test_only_a_missing_predecessor_is_read_as_no_predecessor(self) -> None: + missing = self.root / "missing.json" + self.assertIsNone(_read_state_json(missing, load_json_file=self._load)) + + malformed = self.root / "malformed.json" + malformed.write_text("{not-json", encoding="utf-8") + with self.assertRaises(ValueError): + _read_state_json(malformed, load_json_file=self._load) + + unreadable = self.root / "unreadable.json" + unreadable.write_text("{}", encoding="utf-8") + unreadable.chmod(0) + try: + with self.assertRaises(OSError): + _read_state_json(unreadable, load_json_file=self._load) + finally: + unreadable.chmod(0o600) + + @staticmethod + def _load(path: Path) -> object: + with path.open(encoding="utf-8") as handle: + return json.load(handle) + + +class AtomicStateWriteTests(unittest.TestCase): + def setUp(self) -> None: + self._raw = tempfile.TemporaryDirectory(prefix=".atomic-state-") + self.addCleanup(self._raw.cleanup) + self.root = Path(self._raw.name) + + def _assert_interposed_publication_is_refused(self, writer: object) -> None: + path = self.root / "state.json" + attacker = b'{"attacker": true}\n' + real_replace = os.replace + interposed = False + + def replace_then_interpose(source: object, destination: object) -> None: + nonlocal interposed + real_replace(source, destination) + if Path(destination) == path and not interposed: + replacement = self.root / "interposed.json" + replacement.write_bytes(attacker) + replacement.chmod(0o600) + real_replace(replacement, path) + interposed = True + + with mock.patch( + "sessionkit_inventory.state_io.os.replace", + side_effect=replace_then_interpose, + ): + with self.assertRaisesRegex( + CollectionError, "no longer names the file that was published" + ): + writer(path, {"expected": True}) # type: ignore[operator] + self.assertTrue(interposed) + self.assertEqual(attacker, path.read_bytes()) + + def test_atomic_json_refuses_an_interposed_published_path(self) -> None: + self._assert_interposed_publication_is_refused(atomic_write_json) + + def test_atomic_private_json_refuses_an_interposed_published_path(self) -> None: + self._assert_interposed_publication_is_refused(atomic_write_private_json) + + +class PublishingLockFenceRaceTests(unittest.TestCase): + """A process that loses the fence race retries instead of crashing. + + The legacy `inventory.lock` is retired by replacing it with a mode-0400 + fence file. A second process that checked the fence just before the + replacement then opens the fenced inode with O_RDWR and receives EACCES. + CI caught that interleaving once under coverage instrumentation + (2026-08-19): `acknowledge_pending` escaped with a raw PermissionError. + The open must re-verify the fence and continue through the new lock + generation, and a genuine permission problem must still surface. + """ + + def setUp(self) -> None: + self._raw = tempfile.TemporaryDirectory(prefix=".state-io-") + self.addCleanup(self._raw.cleanup) + self.root = Path(self._raw.name) / "state" + self.root.mkdir(mode=0o700) + self.versioned = self.root / "inventory-v2.lock" + + def _fence(self) -> Path: + legacy = self.root / "inventory.lock" + legacy.write_bytes(_LEGACY_LOCK_FENCE) + legacy.chmod(0o400) + return legacy + + def test_losing_the_fence_race_retries_through_the_new_generation(self) -> None: + self._fence() + real = StateLock._legacy_lock_is_fenced + calls = {"n": 0} + + def stale_then_real(lock: StateLock, path: Path) -> bool: + calls["n"] += 1 + if calls["n"] == 1: + return False # the check that raced the fence publisher + return real(lock, path) + + with mock.patch.object(StateLock, "_legacy_lock_is_fenced", stale_then_real): + with StateLock(self.root, self.versioned): + pass # acquiring is the assertion: no PermissionError escapes + + def test_a_genuine_permission_problem_still_surfaces(self) -> None: + legacy = self.root / "inventory.lock" + legacy.write_bytes(b"not the fence\n") + legacy.chmod(0o000) + with mock.patch.object( + StateLock, "_legacy_lock_is_fenced", lambda lock, path: False + ): + with self.assertRaises(PermissionError): + with StateLock(self.root, self.versioned): + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_subagent_sweep.py b/tests/test_subagent_sweep.py new file mode 100644 index 0000000..c8750e1 --- /dev/null +++ b/tests/test_subagent_sweep.py @@ -0,0 +1,3473 @@ +"""The sub-agent sweep: completion means closure, evidence means output. + +Every test drives ``sweep()`` against a fake /proc tree and the exact Claude +transcript layout built on disk, so the evidence chain is the real one -- +cmdline binding, output snapshots, and stat parsing -- with only the directory +root and the kill hand swapped out. +""" + +from __future__ import annotations + +import ast +import contextlib +import fcntl +import io +import json +import time +import shutil +import subprocess +import os +from pathlib import Path +import signal +import tempfile +import unittest +import unittest.mock + +from tests.support import REPO + +from lib.sessionkit_inventory import subagent_sweep + + +CLAUDE = "/home/user/.local/share/claude/versions/2.1.231" +HOUR = 3600.0 + + +def write_process( + proc: Path, + pid: int, + cmdline: list[str], + *, + ppid: int = 1, + own: int = 0, + reaped: int = 0, + start: int = 1000, + standalone_session: str = "", +) -> None: + home = proc / str(pid) + home.mkdir(parents=True, exist_ok=True) + (home / "cmdline").write_bytes("\0".join(cmdline).encode() + b"\0") + utime, stime = own, 0 + cutime, cstime = reaped, 0 + tail = ["S", str(ppid)] + ["0"] * 9 + tail += [str(utime), str(stime), str(cutime), str(cstime)] + tail += ["0"] * 4 + [str(start), "0", "0"] + (home / "stat").write_text(f"{pid} (co mm) {' '.join(tail)}\n") + if "--agent-id" in cmdline and "--parent-session-id" in cmdline: + transcript = ( + standalone_transcript(proc, standalone_session) + if standalone_session + else worker_transcript(proc, cmdline) + ) + transcript.parent.mkdir(parents=True, exist_ok=True) + if not transcript.exists() and not transcript.is_symlink(): + transcript.write_text("fixture output\n", encoding="utf-8") + + +def worker_cmdline(agent: str = "wave8@team", parent: str = "abcd-1234") -> list[str]: + agent_name, _separator, team_name = agent.rpartition("@") + return [ + CLAUDE, + "--agent-id", + agent, + "--agent-name", + agent_name, + "--team-name", + team_name, + "--parent-session-id", + parent, + "--agent-type", + "general-purpose", + ] + + +def worker_transcript(proc: Path, cmdline: list[str]) -> Path: + """The exact layout Claude binds to the two worker identity flags.""" + agent = cmdline[cmdline.index("--agent-id") + 1] + parent = cmdline[cmdline.index("--parent-session-id") + 1] + return ( + proc.parent + / "home" + / ".claude" + / "projects" + / "fixture" + / parent + / "subagents" + / f"agent-{agent}.jsonl" + ) + + +def standalone_transcript(proc: Path, session_id: str) -> Path: + return ( + proc.parent + / "home" + / ".claude" + / "projects" + / "fixture" + / f"{session_id}.jsonl" + ) + + +def write_declaring_transcript( + path: Path, + agent_name: str, + team_name: str, + *, + session_id: str | None = None, +) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "type": "assistant", + "agentName": agent_name, + "teamName": team_name, + "sessionId": path.stem if session_id is None else session_id, + } + ) + + "\n", + encoding="utf-8", + ) + + +def write_session_record( + proc: Path, + pid: int, + session_id: str, + *, + start: int = 1000, + claude_root: Path | None = None, +) -> Path: + root = claude_root or proc.parent / "home" / ".claude" + record = root / "sessions" / f"{pid}.json" + record.parent.mkdir(parents=True, exist_ok=True) + record.write_text( + json.dumps( + {"pid": pid, "procStart": str(start), "sessionId": session_id} + ), + encoding="utf-8", + ) + return record + + +def output_state(proc: Path, cmdline: list[str]) -> dict[str, object]: + transcript = worker_transcript(proc, cmdline) + info = transcript.stat() + return { + "output_copies": [ + { + "path": os.fspath(transcript), + "dev": info.st_dev, + "ino": info.st_ino, + "size": info.st_size, + "mtime_ns": info.st_mtime_ns, + "ctime_ns": info.st_ctime_ns, + } + ], + } + + +def write_terminal_process( + proc: Path, + pid: int, + cmdline: list[str], + *, + ppid: int, + pgid: int, + session_id: int, + tty: int, + tpgid: int, + start: int, + own_cpu: int = 0, + exe: str | None = None, +) -> None: + """Write the exact /proc stat terminal fields the shell pass consumes.""" + home = proc / str(pid) + home.mkdir(parents=True, exist_ok=True) + (home / "cmdline").write_bytes("\0".join(cmdline).encode() + b"\0") + fields = [ + "S", + str(ppid), + str(pgid), + str(session_id), + str(tty), + str(tpgid), + *(["0"] * 13), + str(start), + ] + fields[11] = str(own_cpu) + (home / "stat").write_text( + f"{pid} (bash fixture) {' '.join(fields)}\n", encoding="utf-8" + ) + if exe is not None: + exe_link = home / "exe" + if exe_link.exists() or exe_link.is_symlink(): + exe_link.unlink() + exe_link.symlink_to(exe) + task = home / "task" / str(pid) + task.mkdir(parents=True, exist_ok=True) + children = task / "children" + if not children.exists(): + children.write_text("", encoding="ascii") + parent_children = proc / str(ppid) / "task" / str(ppid) / "children" + if parent_children.exists(): + values = {int(word) for word in parent_children.read_text().split()} + values.add(pid) + parent_children.write_text( + " ".join(str(value) for value in sorted(values)), encoding="ascii" + ) + + +def rewrite_terminal_stat( + proc: Path, + pid: int, + *, + state: str | None = None, + tpgid: int | None = None, + own_cpu: int | None = None, + start_ticks: int | None = None, +) -> None: + """Change selected fake-proc fields without disturbing its identity.""" + path = proc / str(pid) / "stat" + head, tail = path.read_text(encoding="utf-8").rsplit(")", 1) + fields = tail.split() + if state is not None: + fields[0] = state + if tpgid is not None: + fields[5] = str(tpgid) + if own_cpu is not None: + fields[11] = str(own_cpu) + fields[12] = "0" + if start_ticks is not None: + fields[19] = str(start_ticks) + path.write_text(f"{head}) {' '.join(fields)}\n", encoding="utf-8") + + +def write_background_shell( + proc: Path, + output: Path, + *, + fingerprint: bool = True, + foreground: bool = False, + output_target: str | None = None, + shell_cmdline: list[str] | None = None, + tpgid: int | None = None, + provider_pid: int = 400, + shell_pid: int = 500, + provider_start: int = 4000, + shell_start: int = 5000, +) -> None: + """A provider root and its direct Claude background-task harness shell.""" + tty = 34816 + foreground_pgid = ( + tpgid if tpgid is not None else (shell_pid if foreground else provider_pid) + ) + write_terminal_process( + proc, + provider_pid, + [CLAUDE], + ppid=1, + pgid=provider_pid, + session_id=provider_pid, + tty=tty, + tpgid=foreground_pgid, + start=provider_start, + ) + snapshot = ( + "/home/user/.claude/shell-snapshots/" + "snapshot-bash-1786911563763-zf9x5o.sh" + if fingerprint + else "/home/user/task.sh" + ) + write_terminal_process( + proc, + shell_pid, + shell_cmdline or ["/bin/bash", "-l", snapshot], + ppid=provider_pid, + pgid=shell_pid, + session_id=provider_pid, + tty=tty, + tpgid=foreground_pgid, + start=shell_start, + exe="/bin/bash", + ) + fd_dir = proc / str(shell_pid) / "fd" + fd_dir.mkdir(exist_ok=True) + fd1 = fd_dir / "1" + if fd1.exists() or fd1.is_symlink(): + fd1.unlink() + fd1.symlink_to(output_target or os.fspath(output)) + + +def write_shell_child( + proc: Path, + *, + pid: int = 600, + ppid: int = 500, + pgid: int = 500, + session_id: int = 400, + tty: int = 0, + tpgid: int = -1, + start: int = 6000, + own_cpu: int = 0, +) -> None: + write_terminal_process( + proc, + pid, + ["/usr/bin/python3", "active-task.py"], + ppid=ppid, + pgid=pgid, + session_id=session_id, + tty=tty, + tpgid=tpgid, + start=start, + own_cpu=own_cpu, + ) + + +class SweepHarness(unittest.TestCase): + def setUp(self) -> None: + scratch = tempfile.TemporaryDirectory() + self.addCleanup(scratch.cleanup) + self.proc = Path(scratch.name) / "proc" + self.proc.mkdir() + self.home = Path(scratch.name) / "home" + self.home.mkdir() + self.state_dir = Path(scratch.name) / "state" + self.state_dir.mkdir() + self.kills: list[tuple[int, int]] = [] + # Passes move forward, as they do in life: the sweep starts its clocks + # fresh if it ever sees time run backwards. + self.clock = 0.0 + + def kill(self, pid: int, signum: int) -> None: + self.kills.append((pid, signum)) + if signum == signal.SIGSTOP: + rewrite_terminal_stat(self.proc, pid, state="T") + elif signum == signal.SIGCONT: + rewrite_terminal_stat(self.proc, pid, state="S") + + def run_sweep(self, now: float, environ: dict[str, str] | None = None, **kw): + self.clock = max(self.clock, now) + sweep_environ = {"HOME": os.fspath(self.home), **(environ or {})} + return subagent_sweep.sweep( + proc=self.proc, + state_dir=self.state_dir, + environ=sweep_environ, + now=now, + kill=self.kill, + **kw, + ) + + def sweep_until( + self, + end: float, + environ: dict[str, str] | None = None, + *, + start: float | None = None, + **kw, + ): + """Run passes at a real cadence up to `end`, returning the last actions. + + Nothing is closed on evidence older than the window, so a test that + jumps straight from t=0 to t=7h is not describing a sweep that ever + runs -- it is describing the install-day gap the sweep now refuses to + act on. Passes here land a third of a window apart, which is what the + five-minute timer does against a fifteen-minute rule. + """ + window = subagent_sweep._env_idle_hours(environ or {}) * 3600 + step = max((window or 900.0) / 3.0, 1.0) + moment = self.clock if start is None else start + actions = self.run_sweep(now=moment, environ=environ, **kw) + while not actions and moment < end: + moment = min(moment + step, end) + actions = self.run_sweep(now=moment, environ=environ, **kw) + # Stops at the first pass that acts: running on would escalate the + # TERM it just found into a KILL and hide what the caller asked about. + return actions + + +MINUTE = 60.0 +CURRENT_SESSION = "12345678-1234-4234-8234-123456789abc" +OTHER_SESSION = "abcdef01-abcd-4abc-8abc-abcdef012345" + + +class TheBackgroundShellLaw(SweepHarness): + def setUp(self) -> None: + super().setUp() + self.output = self.home / "background-task.out" + self.output.write_text("finished output\n", encoding="utf-8") + + def run_frozen_overdue( + self, + *, + on_stop=None, + publish_stopped: bool = True, + fail_signal: int | None = None, + vanish_after_signal: int | None = None, + now: float = 15 * MINUTE, + ): + """Exercise the public production pidfd path against fake procfs.""" + descriptor = os.open("/dev/null", os.O_RDONLY) + sent: list[int] = [] + + def pidfd_send(pidfd: int, signum: int) -> None: + self.assertEqual(descriptor, pidfd) + sent.append(signum) + if signum == signal.SIGSTOP: + if publish_stopped: + rewrite_terminal_stat(self.proc, 500, state="T") + if on_stop is not None: + on_stop() + elif signum == vanish_after_signal: + shutil.rmtree(self.proc / "500") + elif signum == signal.SIGCONT and (self.proc / "500" / "stat").exists(): + rewrite_terminal_stat(self.proc, 500, state="S") + elif signum == signal.SIGCONT and vanish_after_signal is not None: + raise ProcessLookupError(3, "No such process") + if signum == fail_signal: + raise OSError("synthetic pidfd signal failure") + + with ( + unittest.mock.patch.object(subagent_sweep, "_HAS_PIDFD", True), + unittest.mock.patch.object( + subagent_sweep.os, "pidfd_open", return_value=descriptor + ), + unittest.mock.patch.object( + subagent_sweep.signal, + "pidfd_send_signal", + side_effect=pidfd_send, + ), + ): + actions = subagent_sweep.sweep( + proc=self.proc, + state_dir=self.state_dir, + environ={"HOME": os.fspath(self.home)}, + now=now, + kill=None, + ) + return actions, sent + + def test_fingerprinted_regular_output_quiet_fifteen_minutes_gets_term( + self, + ) -> None: + write_background_shell(self.proc, self.output) + self.assertEqual([], self.run_sweep(now=0.0)) + + actions = self.run_sweep(now=15 * MINUTE) + + self.assertEqual( + [ + (500, signal.SIGSTOP), + (500, signal.SIGTERM), + (500, signal.SIGCONT), + ], + self.kills, + ) + self.assertEqual(["SIGTERM"], [item["signal"] for item in actions]) + self.assertEqual(["background-shell"], [item["kind"] for item in actions]) + + def test_output_growth_at_minute_ten_resets_the_clock(self) -> None: + write_background_shell(self.proc, self.output) + self.run_sweep(now=0.0) + with self.output.open("a", encoding="utf-8") as handle: + handle.write("child still writing\n") + + self.assertEqual([], self.run_sweep(now=10 * MINUTE)) + self.assertEqual([], self.run_sweep(now=15 * MINUTE)) + actions = self.run_sweep(now=25 * MINUTE) + + self.assertEqual(["SIGTERM"], [item["signal"] for item in actions]) + self.assertEqual( + [signal.SIGSTOP, signal.SIGTERM, signal.SIGCONT], + [signum for _pid, signum in self.kills], + ) + + def test_pipe_fd1_refuses_and_never_signals(self) -> None: + write_background_shell( + self.proc, + self.output, + output_target="pipe:[12345]", + ) + + self.run_sweep(now=0.0) + self.run_sweep(now=15 * MINUTE) + + self.assertEqual([], self.kills) + records = [ + json.loads(line) + for line in (self.state_dir / "subagent-sweep.log") + .read_text(encoding="utf-8") + .splitlines() + ] + self.assertTrue(records) + self.assertTrue(all(item["kind"] == "background-shell" for item in records)) + self.assertTrue(all(item["decision"] == "refused-output" for item in records)) + + def test_foreground_shell_is_never_a_candidate(self) -> None: + write_background_shell(self.proc, self.output, foreground=True) + + self.assertEqual( + [500], + [item["pid"] for item in subagent_sweep.find_background_shells(self.proc)], + ) + self.run_sweep(now=0.0) + self.run_sweep(now=15 * MINUTE) + + self.assertEqual([], self.kills) + refusal = json.loads( + (self.state_dir / "subagent-sweep.log").read_text().splitlines()[-1] + ) + self.assertIn("foreground pgid 500", refusal["reason"]) + + def test_bash_without_snapshot_fingerprint_is_never_a_candidate(self) -> None: + write_background_shell(self.proc, self.output, fingerprint=False) + + self.assertEqual([], subagent_sweep.find_background_shells(self.proc)) + self.run_sweep(now=0.0) + self.run_sweep(now=15 * MINUTE) + + self.assertEqual([], self.kills) + + def test_output_change_inside_signal_window_vetoes_and_rearms(self) -> None: + write_background_shell(self.proc, self.output) + self.run_sweep(now=0.0) + original = subagent_sweep._background_shell_output_snapshot + calls = 0 + + def move_before_delivery(shell, proc): + nonlocal calls + calls += 1 + if calls == 2: + with self.output.open("a", encoding="utf-8") as handle: + handle.write("last instant output\n") + return original(shell, proc) + + with unittest.mock.patch.object( + subagent_sweep, + "_background_shell_output_snapshot", + side_effect=move_before_delivery, + ): + actions = self.run_sweep(now=15 * MINUTE) + + self.assertEqual([], actions) + self.assertEqual( + [(500, signal.SIGSTOP), (500, signal.SIGCONT)], self.kills + ) + later = self.run_sweep(now=30 * MINUTE) + self.assertEqual(["SIGTERM"], [item["signal"] for item in later]) + + def test_waiting_child_zero_cpu_refuses_and_names_youngest_descendant( + self, + ) -> None: + write_background_shell(self.proc, self.output) + write_shell_child(self.proc, pid=600, start=9000, own_cpu=0) + write_shell_child(self.proc, pid=601, start=8000, own_cpu=0) + for minute in (0, 5, 10, 15, 20): + self.assertEqual([], self.run_sweep(now=minute * MINUTE)) + self.assertEqual([], self.kills) + payload = json.loads( + (self.state_dir / "subagent-sweep.state.json").read_text( + encoding="utf-8" + ) + ) + self.assertFalse( + any(key.startswith("background-shell:") for key in payload["tracked"]) + ) + refusals = [ + json.loads(line) + for line in (self.state_dir / "subagent-sweep.log").read_text().splitlines() + ] + self.assertTrue(refusals) + self.assertTrue( + all("youngest live descendant 600" in item["reason"] for item in refusals) + ) + + def test_descendant_foreground_pgid_is_never_a_candidate(self) -> None: + write_background_shell(self.proc, self.output, tpgid=600) + write_shell_child( + self.proc, + pgid=600, + tty=34816, + tpgid=600, + ) + + self.assertEqual( + [500], + [item["pid"] for item in subagent_sweep.find_background_shells(self.proc)], + ) + self.run_sweep(now=0.0) + self.run_sweep(now=15 * MINUTE) + self.assertEqual([], self.kills) + + def test_unrelated_foreground_pgid_stays_eligible(self) -> None: + write_background_shell(self.proc, self.output, tpgid=700) + + self.assertEqual( + [500], + [item["pid"] for item in subagent_sweep.find_background_shells(self.proc)], + ) + self.run_sweep(now=0.0) + actions = self.run_sweep(now=15 * MINUTE) + self.assertEqual(["SIGTERM"], [item["signal"] for item in actions]) + + def test_snapshot_fingerprint_is_structural_not_a_substring(self) -> None: + spoof = [ + "/bin/bash", + "-c", + "while valuable_work; do :; done # " + "shell-snapshots/snapshot-bash-spoof", + ] + write_background_shell(self.proc, self.output, shell_cmdline=spoof) + self.assertEqual([], subagent_sweep.find_background_shells(self.proc)) + + observed = [ + "/bin/bash", + "-c", + "source /home/user/.local/share/session-kit/accounts/claude/duck/" + "shell-snapshots/snapshot-bash-1786911563763-zf9x5o.sh " + "2>/dev/null || true && printf done", + ] + write_background_shell(self.proc, self.output, shell_cmdline=observed) + self.assertEqual( + [500], + [item["pid"] for item in subagent_sweep.find_background_shells(self.proc)], + ) + + exact_path = [ + "/bin/bash", + "-l", + "/home/user/.claude/shell-snapshots/" + "snapshot-bash-1786911563763-zf9x5o.sh", + ] + write_background_shell(self.proc, self.output, shell_cmdline=exact_path) + self.assertEqual( + [500], + [item["pid"] for item in subagent_sweep.find_background_shells(self.proc)], + ) + + def test_stop_window_foreground_flip_continues_without_term( + self, + ) -> None: + write_background_shell(self.proc, self.output) + self.run_sweep(now=0.0) + + def take_foreground() -> None: + rewrite_terminal_stat(self.proc, 400, tpgid=500) + rewrite_terminal_stat(self.proc, 500, state="T", tpgid=500) + + actions, sent = self.run_frozen_overdue(on_stop=take_foreground) + self.assertEqual([], actions) + self.assertEqual([signal.SIGSTOP, signal.SIGCONT], sent) + + def test_stop_window_fd1_rebind_continues_without_term(self) -> None: + write_background_shell(self.proc, self.output) + self.run_sweep(now=0.0) + rebound = self.home / "rebound.out" + rebound.write_text("different file\n", encoding="utf-8") + + def rebind() -> None: + fd1 = self.proc / "500" / "fd" / "1" + fd1.unlink() + fd1.symlink_to(rebound) + + actions, sent = self.run_frozen_overdue(on_stop=rebind) + self.assertEqual([], actions) + self.assertEqual([signal.SIGSTOP, signal.SIGCONT], sent) + records = [ + json.loads(line) + for line in (self.state_dir / "subagent-sweep.log") + .read_text(encoding="utf-8") + .splitlines() + ] + self.assertIn("refused-final-proof", [item["decision"] for item in records]) + + def test_new_descendant_mid_window_refuses_for_as_long_as_it_lives(self) -> None: + write_background_shell(self.proc, self.output) + self.run_sweep(now=0.0) + write_shell_child(self.proc, own_cpu=10) + + self.assertEqual([], self.run_sweep(now=5 * MINUTE)) + self.assertEqual([], self.run_sweep(now=15 * MINUTE)) + self.assertEqual([], self.run_sweep(now=20 * MINUTE)) + self.assertEqual([], self.run_sweep(now=60 * MINUTE)) + self.assertEqual([], self.kills) + + def test_shell_own_cpu_change_between_passes_rearms(self) -> None: + write_background_shell(self.proc, self.output) + self.run_sweep(now=0.0) + rewrite_terminal_stat(self.proc, 500, own_cpu=1) + + self.assertEqual([], self.run_sweep(now=15 * MINUTE)) + actions = self.run_sweep(now=30 * MINUTE) + + self.assertEqual(["SIGTERM"], [item["signal"] for item in actions]) + self.assertEqual( + [signal.SIGSTOP, signal.SIGTERM, signal.SIGCONT], + [signum for _pid, signum in self.kills], + ) + + def test_successful_frozen_delivery_is_stop_term_cont(self) -> None: + write_background_shell(self.proc, self.output) + self.run_sweep(now=0.0) + + actions, sent = self.run_frozen_overdue() + + self.assertEqual(["SIGTERM"], [item["signal"] for item in actions]) + self.assertEqual( + [signal.SIGSTOP, signal.SIGTERM, signal.SIGCONT], sent + ) + + def test_stopped_state_verification_failure_continues_without_term(self) -> None: + write_background_shell(self.proc, self.output) + self.run_sweep(now=0.0) + + actions, sent = self.run_frozen_overdue(publish_stopped=False) + + self.assertEqual([], actions) + self.assertEqual([signal.SIGSTOP, signal.SIGCONT], sent) + + def test_stop_window_new_descendant_continues_without_term(self) -> None: + write_background_shell(self.proc, self.output) + self.run_sweep(now=0.0) + + actions, sent = self.run_frozen_overdue( + on_stop=lambda: write_shell_child(self.proc, own_cpu=0) + ) + + self.assertEqual([], actions) + self.assertEqual([signal.SIGSTOP, signal.SIGCONT], sent) + + def test_stop_window_own_cpu_change_continues_without_term(self) -> None: + write_background_shell(self.proc, self.output) + self.run_sweep(now=0.0) + + actions, sent = self.run_frozen_overdue( + on_stop=lambda: rewrite_terminal_stat(self.proc, 500, own_cpu=1) + ) + + self.assertEqual([], actions) + self.assertEqual([signal.SIGSTOP, signal.SIGCONT], sent) + + def test_stop_window_identity_change_continues_without_term(self) -> None: + write_background_shell(self.proc, self.output) + self.run_sweep(now=0.0) + + actions, sent = self.run_frozen_overdue( + on_stop=lambda: rewrite_terminal_stat( + self.proc, 500, state="T", start_ticks=9999 + ) + ) + + self.assertEqual([], actions) + self.assertEqual([signal.SIGSTOP, signal.SIGCONT], sent) + + def test_stop_window_unreadable_proof_continues_without_term(self) -> None: + write_background_shell(self.proc, self.output) + self.run_sweep(now=0.0) + + def break_children_read() -> None: + path = self.proc / "500" / "task" / "500" / "children" + path.unlink() + path.mkdir() + + actions, sent = self.run_frozen_overdue(on_stop=break_children_read) + + self.assertEqual([], actions) + self.assertEqual([signal.SIGSTOP, signal.SIGCONT], sent) + + def test_term_delivery_error_still_continues_the_stopped_shell(self) -> None: + write_background_shell(self.proc, self.output) + self.run_sweep(now=0.0) + + actions, sent = self.run_frozen_overdue(fail_signal=signal.SIGTERM) + + self.assertEqual([], actions) + self.assertEqual( + [signal.SIGSTOP, signal.SIGTERM, signal.SIGCONT], sent + ) + records = [ + json.loads(line) + for line in (self.state_dir / "subagent-sweep.log") + .read_text(encoding="utf-8") + .splitlines() + ] + self.assertFalse(any(item.get("signal") == "SIGTERM" for item in records)) + + def test_kill_reaped_before_cont_is_logged_as_delivered_kill(self) -> None: + write_background_shell(self.proc, self.output) + self.run_sweep(now=0.0) + self.assertEqual( + ["SIGTERM"], + [item["signal"] for item in self.run_sweep(now=15 * MINUTE)], + ) + + actions, sent = self.run_frozen_overdue( + vanish_after_signal=signal.SIGKILL, + now=20 * MINUTE, + ) + + self.assertEqual( + [signal.SIGSTOP, signal.SIGKILL, signal.SIGCONT], sent + ) + self.assertEqual(["SIGKILL"], [item["signal"] for item in actions]) + self.assertTrue(actions[0]["target_reaped_before_cont"]) + records = [ + json.loads(line) + for line in (self.state_dir / "subagent-sweep.log") + .read_text(encoding="utf-8") + .splitlines() + ] + self.assertEqual("SIGKILL", records[-1]["signal"]) + self.assertTrue(records[-1]["target_reaped_before_cont"]) + self.assertNotEqual("refused-final-proof", records[-1].get("decision")) + + def test_unreadable_descendant_refuses_only_its_candidate(self) -> None: + write_background_shell(self.proc, self.output) + write_shell_child(self.proc, own_cpu=0) + unreadable = self.proc / "600" / "stat" + unreadable.unlink() + unreadable.mkdir() + other_output = self.home / "other-background-task.out" + other_output.write_text("other finished output\n", encoding="utf-8") + write_background_shell( + self.proc, + other_output, + provider_pid=700, + shell_pid=800, + provider_start=7000, + shell_start=8000, + ) + + self.assertEqual([], self.run_sweep(now=0.0)) + actions = self.run_sweep(now=15 * MINUTE) + + self.assertEqual([800], [item["pid"] for item in actions]) + self.assertEqual( + [ + (800, signal.SIGSTOP), + (800, signal.SIGTERM), + (800, signal.SIGCONT), + ], + self.kills, + ) + records = [ + json.loads(line) + for line in (self.state_dir / "subagent-sweep.log").read_text().splitlines() + ] + refused = [item for item in records if item.get("decision") == "refused-output"] + self.assertTrue(refused) + self.assertTrue(all(item["pid"] == 500 for item in refused)) + self.assertTrue(any("descendant 600" in item["reason"] for item in refused)) + + def test_pidfd_unavailable_refuses_without_kill_fallback(self) -> None: + write_background_shell(self.proc, self.output) + self.run_sweep(now=0.0) + + with ( + unittest.mock.patch.object(subagent_sweep, "_HAS_PIDFD", False), + unittest.mock.patch.object(subagent_sweep.os, "kill") as pid_kill, + ): + actions = subagent_sweep.sweep( + proc=self.proc, + state_dir=self.state_dir, + environ={"HOME": os.fspath(self.home)}, + now=15 * MINUTE, + kill=None, + ) + + self.assertEqual([], actions) + pid_kill.assert_not_called() + + +class TheWorkerOutputBinding(SweepHarness): + def _find(self, **extra: str) -> list[dict[str, object]]: + return subagent_sweep.find_subagents( + self.proc, {"HOME": os.fspath(self.home), **extra} + ) + + def test_legacy_agent_transcript_produces_a_bound_snapshot(self) -> None: + cmdline = worker_cmdline("legacy@team", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + write_process(self.proc, 500, cmdline) + + agents = self._find() + snapshot, reason = subagent_sweep._output_snapshot( + agents[0], {"HOME": os.fspath(self.home)} + ) + + self.assertEqual("", reason) + self.assertIsNotNone(snapshot) + assert snapshot is not None + self.assertEqual( + [os.fspath(worker_transcript(self.proc, cmdline))], + [copy["path"] for copy in snapshot["output_copies"]], + ) + self.assertEqual( + worker_transcript(self.proc, cmdline).stat().st_size, + snapshot["output_copies"][0]["size"], + ) + + def test_standalone_session_transcript_produces_a_bound_snapshot(self) -> None: + cmdline = worker_cmdline("named@session-12345678") + write_process( + self.proc, + 501, + cmdline, + standalone_session=CURRENT_SESSION, + ) + write_session_record(self.proc, 501, CURRENT_SESSION) + + agents = self._find() + identity, identity_error = subagent_sweep._worker_session_identity( + agents[0], {"HOME": os.fspath(self.home)} + ) + self.assertEqual((CURRENT_SESSION, ""), (identity, identity_error)) + snapshot, reason = subagent_sweep._output_snapshot( + agents[0], {"HOME": os.fspath(self.home)} + ) + + self.assertEqual("", reason) + self.assertIsNotNone(snapshot) + assert snapshot is not None + self.assertEqual( + [os.fspath(standalone_transcript(self.proc, CURRENT_SESSION))], + [copy["path"] for copy in snapshot["output_copies"]], + ) + + def test_undecidable_standalone_identity_refuses_with_the_file_name(self) -> None: + write_process( + self.proc, + 502, + worker_cmdline("missing@session-12345678"), + standalone_session=CURRENT_SESSION, + ) + # Larger than both probe windows with no parseable identity record + # anywhere near either end: genuinely undecidable. A small file seen + # end to end decides non-member instead (its own test below). + oversized = standalone_transcript(self.proc, CURRENT_SESSION) + oversized.parent.mkdir(parents=True, exist_ok=True) + junk_line = b"x" * 1024 + b"\n" + oversized.write_bytes( + junk_line * (3 * subagent_sweep._MAX_TRANSCRIPT_PROBE_BYTES // len(junk_line)) + ) + agent = self._find()[0] + + snapshot, reason = subagent_sweep._output_snapshot( + agent, {"HOME": os.fspath(self.home)} + ) + + self.assertIsNone(snapshot) + self.assertEqual( + "worker transcript membership undecidable: " + f"{standalone_transcript(self.proc, CURRENT_SESSION)}", + reason, + ) + self.assertEqual([], self.run_sweep(now=0.0)) + refusal = json.loads( + (self.state_dir / "subagent-sweep.log").read_text(encoding="utf-8") + ) + self.assertEqual("refused-output", refusal["decision"]) + self.assertEqual(reason, refusal["reason"]) + + def test_two_standalone_session_identities_refuse_as_ambiguous(self) -> None: + write_process( + self.proc, + 503, + worker_cmdline("ambiguous@session-12345678"), + standalone_session=CURRENT_SESSION, + ) + write_session_record(self.proc, 503, CURRENT_SESSION) + alternate = self.proc.parent / "alternate-claude" + write_session_record( + self.proc, 503, OTHER_SESSION, claude_root=alternate + ) + for session_id in (CURRENT_SESSION, OTHER_SESSION): + root = self.home / ".claude" if session_id == CURRENT_SESSION else alternate + transcript = root / "projects" / "fixture" / f"{session_id}.jsonl" + transcript.parent.mkdir(parents=True, exist_ok=True) + transcript.write_text("exact candidate\n", encoding="utf-8") + agent = self._find(CLAUDE_CONFIG_DIR=os.fspath(alternate))[0] + + snapshot, reason = subagent_sweep._output_snapshot( + agent, + { + "HOME": os.fspath(self.home), + "CLAUDE_CONFIG_DIR": os.fspath(alternate), + }, + ) + + self.assertIsNone(snapshot) + self.assertEqual( + "worker session identity is ambiguous across exact session records", + reason, + ) + + def test_stale_pid_session_record_refuses_with_the_generation_field(self) -> None: + write_process( + self.proc, + 507, + worker_cmdline("recycled@session-12345678"), + standalone_session=CURRENT_SESSION, + ) + write_session_record(self.proc, 507, CURRENT_SESSION, start=9999) + + agent = self._find()[0] + snapshot, reason = subagent_sweep._output_snapshot( + agent, {"HOME": os.fspath(self.home)} + ) + + self.assertIsNone(snapshot) + self.assertEqual( + "no exact worker session record matches the worker procStart", reason + ) + + def test_an_unreadable_exact_copy_refuses_all_readable_copies(self) -> None: + cmdline = worker_cmdline("copies@session-12345678") + write_process( + self.proc, + 504, + cmdline, + standalone_session=CURRENT_SESSION, + ) + write_session_record(self.proc, 504, CURRENT_SESSION) + alternate = self.proc.parent / "alternate-claude" + bad = alternate / "projects" / "fixture" / f"{CURRENT_SESSION}.jsonl" + bad.parent.mkdir(parents=True) + bad.symlink_to(bad.with_name("missing.jsonl")) + agent = self._find()[0] + + snapshot, reason = subagent_sweep._output_snapshot( + agent, + { + "HOME": os.fspath(self.home), + "CLAUDE_CONFIG_DIR": os.fspath(alternate), + }, + ) + + self.assertIsNone(snapshot) + self.assertIn("cannot read worker output transcript", reason) + self.assertIn(os.fspath(bad), reason) + + def test_every_readable_exact_copy_is_published_in_path_order(self) -> None: + cmdline = worker_cmdline("newest@session-12345678") + write_process( + self.proc, + 505, + cmdline, + standalone_session=CURRENT_SESSION, + ) + write_session_record(self.proc, 505, CURRENT_SESSION) + older = standalone_transcript(self.proc, CURRENT_SESSION) + alternate = self.proc.parent / "alternate-claude" + newer = alternate / "projects" / "fixture" / f"{CURRENT_SESSION}.jsonl" + newer.parent.mkdir(parents=True) + newer.write_text("newer exact copy\n", encoding="utf-8") + os.utime(older, ns=(1_000_000_000, 1_000_000_000)) + os.utime(newer, ns=(2_000_000_000, 2_000_000_000)) + agent = self._find()[0] + + snapshot, reason = subagent_sweep._output_snapshot( + agent, + { + "HOME": os.fspath(self.home), + "CLAUDE_CONFIG_DIR": os.fspath(alternate), + }, + ) + + self.assertEqual("", reason) + self.assertIsNotNone(snapshot) + assert snapshot is not None + self.assertEqual( + sorted((os.fspath(older), os.fspath(newer))), + [copy["path"] for copy in snapshot["output_copies"]], + ) + by_path = {copy["path"]: copy for copy in snapshot["output_copies"]} + self.assertEqual(1_000_000_000, by_path[os.fspath(older)]["mtime_ns"]) + self.assertEqual(2_000_000_000, by_path[os.fspath(newer)]["mtime_ns"]) + + def test_process_table_churn_keeps_only_same_generation_identity(self) -> None: + cmdline = worker_cmdline("churn@session-12345678") + write_process( + self.proc, + 506, + cmdline, + own=1, + standalone_session=CURRENT_SESSION, + ) + write_session_record(self.proc, 506, CURRENT_SESSION) + stable = self._find() + real_reader = subagent_sweep._read_cmdline + reads = 0 + + def cpu_churn(proc: Path, pid: int): + nonlocal reads + answer = real_reader(proc, pid) + reads += 1 + if reads == 1: + write_process( + proc, + pid, + cmdline, + own=999_999, + standalone_session=CURRENT_SESSION, + ) + return answer + + with unittest.mock.patch.object( + subagent_sweep, "_read_cmdline", side_effect=cpu_churn + ): + churned = self._find() + self.assertEqual(stable, churned) + + reads = 0 + + def generation_churn(proc: Path, pid: int): + nonlocal reads + answer = real_reader(proc, pid) + reads += 1 + if reads == 1: + write_process( + proc, + pid, + cmdline, + start=9999, + standalone_session=CURRENT_SESSION, + ) + return answer + + with unittest.mock.patch.object( + subagent_sweep, "_read_cmdline", side_effect=generation_churn + ): + self.assertEqual([], self._find()) + + +class TheRegistrylessWorkerOutputBinding(SweepHarness): + def _agent(self) -> dict[str, object]: + return subagent_sweep.find_subagents( + self.proc, {"HOME": os.fspath(self.home)} + )[0] + + def test_declaring_transcript_binds_and_idle_worker_reaches_sigterm(self) -> None: + cmdline = worker_cmdline("sweep@proof@session-933f74c7") + write_process( + self.proc, 520, cmdline, standalone_session=CURRENT_SESSION + ) + transcript = standalone_transcript(self.proc, CURRENT_SESSION) + write_declaring_transcript( + transcript, "sweep@proof", "session-933f74c7" + ) + + self.assertEqual([], self.run_sweep(now=0.0)) + self.assertEqual([], self.run_sweep(now=5 * MINUTE)) + self.assertEqual([], self.run_sweep(now=10 * MINUTE)) + actions = self.run_sweep(now=15 * MINUTE) + + self.assertEqual(["SIGTERM"], [action["signal"] for action in actions]) + self.assertEqual([(520, signal.SIGTERM)], self.kills) + + def test_old_same_identity_copy_and_other_project_join_the_moving_set( + self, + ) -> None: + cmdline = worker_cmdline("restarted@session-933f74c7") + write_process( + self.proc, 521, cmdline, standalone_session=CURRENT_SESSION + ) + live = standalone_transcript(self.proc, CURRENT_SESSION) + write_declaring_transcript(live, "restarted", "session-933f74c7") + quiet = ( + self.home + / ".claude" + / "projects" + / "previous-run" + / f"{OTHER_SESSION}.jsonl" + ) + write_declaring_transcript(quiet, "restarted", "session-933f74c7") + + snapshot, reason = subagent_sweep._output_snapshot( + self._agent(), {"HOME": os.fspath(self.home)} + ) + self.assertEqual("", reason) + self.assertIsNotNone(snapshot) + assert snapshot is not None + self.assertEqual( + sorted((os.fspath(live), os.fspath(quiet))), + [copy["path"] for copy in snapshot["output_copies"]], + ) + + self.assertEqual([], self.run_sweep(now=0.0)) + for minute in (5, 10, 15, 20): + with live.open("a", encoding="utf-8") as handle: + handle.write(f"live output at {minute}\n") + self.assertEqual([], self.run_sweep(now=minute * MINUTE)) + self.assertEqual([], self.kills) + + def test_declaring_transcript_with_wrong_session_id_refuses(self) -> None: + cmdline = worker_cmdline("mismatch@session-933f74c7") + write_process( + self.proc, 522, cmdline, standalone_session=CURRENT_SESSION + ) + transcript = standalone_transcript(self.proc, CURRENT_SESSION) + write_declaring_transcript( + transcript, + "mismatch", + "session-933f74c7", + session_id=OTHER_SESSION, + ) + + snapshot, reason = subagent_sweep._output_snapshot( + self._agent(), {"HOME": os.fspath(self.home)} + ) + + self.assertIsNone(snapshot) + self.assertIn("sessionId", reason) + + def test_declaring_transcript_that_becomes_unreadable_refuses(self) -> None: + cmdline = worker_cmdline("unreadable@session-933f74c7") + write_process( + self.proc, 523, cmdline, standalone_session=CURRENT_SESSION + ) + transcript = standalone_transcript(self.proc, CURRENT_SESSION) + write_declaring_transcript( + transcript, "unreadable", "session-933f74c7" + ) + + refusal = f"cannot read worker output transcript: {transcript}" + with unittest.mock.patch.object( + subagent_sweep, + "_content_transcript_probe", + return_value=(False, None, refusal), + ): + snapshot, reason = subagent_sweep._output_snapshot( + self._agent(), {"HOME": os.fspath(self.home)} + ) + + self.assertIsNone(snapshot) + self.assertIn("cannot read worker output transcript", reason) + self.assertIn(os.fspath(transcript), reason) + + def test_valid_declaration_beyond_the_head_window_is_a_member(self) -> None: + cmdline = worker_cmdline("oversized@session-933f74c7") + write_process( + self.proc, 524, cmdline, standalone_session=CURRENT_SESSION + ) + transcript = standalone_transcript(self.proc, CURRENT_SESSION) + raw_pair = b'"agentName":"oversized","teamName":"session-933f74c7"' + prefix = raw_pair + b"x" * ( + subagent_sweep._MAX_TRANSCRIPT_PROBE_BYTES - len(raw_pair) + ) + declaration = json.dumps( + { + "agentName": "oversized", + "teamName": "session-933f74c7", + "sessionId": CURRENT_SESSION, + } + ).encode() + transcript.write_bytes(prefix + b"\n" + declaration + b"\n") + + snapshot, reason = subagent_sweep._output_snapshot( + self._agent(), {"HOME": os.fspath(self.home)} + ) + + self.assertEqual("", reason) + self.assertIsNotNone(snapshot) + assert snapshot is not None + self.assertEqual( + [os.fspath(transcript)], + [copy["path"] for copy in snapshot["output_copies"]], + ) + + def test_exact_probe_limit_final_line_without_newline_is_a_member(self) -> None: + transcript = standalone_transcript(self.proc, CURRENT_SESSION) + transcript.parent.mkdir(parents=True, exist_ok=True) + declaration = json.dumps( + { + "agentName": "boundary", + "teamName": "session-933f74c7", + "sessionId": CURRENT_SESSION, + } + ).encode() + transcript.write_bytes( + declaration + + b" " + * (subagent_sweep._MAX_TRANSCRIPT_PROBE_BYTES - len(declaration)) + ) + + member, reason = subagent_sweep._content_transcript_member( + transcript, "boundary", "session-933f74c7" + ) + + self.assertTrue(member) + self.assertEqual("", reason) + + def test_a_fully_scanned_identityless_transcript_decides_non_member( + self, + ) -> None: + """An aborted session leaves a small setup-records-only transcript. + + Every live estate has one. Its complete scan proves no record carries + the agent identity fields, so it decides non-member instead of + refusing the whole answer as undecidable (live finding, 2026-08-16: + a five-day-old aborted-session file froze every binding).""" + transcript = standalone_transcript(self.proc, CURRENT_SESSION) + transcript.parent.mkdir(parents=True, exist_ok=True) + records = [ + {"type": "agent-color", "agentColor": "pink", "sessionId": CURRENT_SESSION}, + {"type": "mode", "mode": "normal", "sessionId": CURRENT_SESSION}, + ] + transcript.write_bytes( + b"\n".join(json.dumps(record).encode() for record in records) + b"\n" + ) + + stat_result = transcript.stat() + long_after_the_husk = stat_result.st_mtime_ns + 10**13 + + member, reason = subagent_sweep._content_transcript_member( + transcript, + "boundary", + "session-933f74c7", + worker_started_ns=long_after_the_husk, + ) + + self.assertFalse(member) + self.assertEqual("", reason) + + def test_an_identityless_transcript_as_new_as_the_worker_refuses( + self, + ) -> None: + """A fresh worker's transcript wears the husk's face for a moment. + + Review lanes rv-c10b-1/2 killed a live worker whose just-born + transcript held only setup records: the complete-scan rule dismissed + it and a quiet decoy became the whole copy set. Age is the exact + discriminator, a transcript cannot predate its worker, so a + setup-only file as recent as the worker refuses the whole answer, + and an unreadable worker start refuses too.""" + transcript = standalone_transcript(self.proc, CURRENT_SESSION) + transcript.parent.mkdir(parents=True, exist_ok=True) + records = [ + {"type": "agent-setting", "sessionId": CURRENT_SESSION}, + {"type": "mode", "mode": "normal", "sessionId": CURRENT_SESSION}, + ] + transcript.write_bytes( + b"\n".join(json.dumps(record).encode() for record in records) + b"\n" + ) + long_before_the_file = transcript.stat().st_mtime_ns - 10**12 + + member, reason = subagent_sweep._content_transcript_member( + transcript, + "boundary", + "session-933f74c7", + worker_started_ns=long_before_the_file, + ) + self.assertFalse(member) + self.assertIn("identityless but as recent as the worker", reason) + + member, reason = subagent_sweep._content_transcript_member( + transcript, "boundary", "session-933f74c7" + ) + self.assertFalse(member) + self.assertIn("worker start time is unreadable", reason) + + def test_new_registry_record_without_its_transcript_refuses_old_content( + self, + ) -> None: + cmdline = worker_cmdline("registry-race@session-933f74c7") + write_process( + self.proc, 525, cmdline, standalone_session=CURRENT_SESSION + ) + old = standalone_transcript(self.proc, CURRENT_SESSION) + write_declaring_transcript(old, "registry-race", "session-933f74c7") + agent = self._agent() + write_session_record(self.proc, 525, OTHER_SESSION) + + snapshot, reason = subagent_sweep._output_snapshot( + agent, {"HOME": os.fspath(self.home)} + ) + + self.assertIsNone(snapshot) + self.assertEqual( + "worker output transcript for exact sessionId was not found", reason + ) + + def test_shape_without_agent_type_does_not_use_content_fallback(self) -> None: + cmdline = worker_cmdline("partial@session-933f74c7") + type_index = cmdline.index("--agent-type") + del cmdline[type_index : type_index + 2] + write_process( + self.proc, 526, cmdline, standalone_session=CURRENT_SESSION + ) + transcript = standalone_transcript(self.proc, CURRENT_SESSION) + write_declaring_transcript(transcript, "partial", "session-933f74c7") + + snapshot, reason = subagent_sweep._output_snapshot( + self._agent(), {"HOME": os.fspath(self.home)} + ) + + self.assertIsNone(snapshot) + self.assertEqual( + "worker session identity is missing exact sessions/<pid>.json record", + reason, + ) + + +class C10RoundTwoStructuralRaces(SweepHarness): + def _agent(self) -> dict[str, object]: + return subagent_sweep.find_subagents( + self.proc, {"HOME": os.fspath(self.home)} + )[0] + + def _tracked_record(self) -> dict[str, object]: + state = json.loads( + (self.state_dir / "subagent-sweep.state.json").read_text() + ) + self.assertEqual(1, len(state["tracked"])) + return next(iter(state["tracked"].values())) + + def test_tail_only_live_copy_and_quiet_decoy_never_signal(self) -> None: + cmdline = worker_cmdline("tail-live@session-933f74c7") + write_process( + self.proc, 530, cmdline, standalone_session=CURRENT_SESSION + ) + live = standalone_transcript(self.proc, CURRENT_SESSION) + declaration = json.dumps( + { + "type": "assistant", + "agentName": "tail-live", + "teamName": "session-933f74c7", + "sessionId": CURRENT_SESSION, + } + ).encode() + prefix = b"x" * (subagent_sweep._MAX_TRANSCRIPT_PROBE_BYTES + 1024) + live.write_bytes(prefix + b"\n" + declaration + b"\n") + self.assertGreater(live.read_bytes().index(declaration), + subagent_sweep._MAX_TRANSCRIPT_PROBE_BYTES) + + decoy = live.with_name(f"{OTHER_SESSION}.jsonl") + write_declaring_transcript( + decoy, "tail-live", "session-933f74c7" + ) + snapshot, reason = subagent_sweep._output_snapshot( + self._agent(), {"HOME": os.fspath(self.home)} + ) + self.assertEqual("", reason) + assert snapshot is not None + self.assertEqual( + sorted((os.fspath(live), os.fspath(decoy))), + [copy["path"] for copy in snapshot["output_copies"]], + ) + + self.assertEqual([], self.run_sweep(now=0.0)) + for minute in (5, 10, 15, 20): + with live.open("ab") as handle: + handle.write(declaration + b"\n") + self.assertEqual([], self.run_sweep(now=minute * MINUTE)) + self.assertIn( + os.fspath(live), + [copy["path"] for copy in self._tracked_record()["output_copies"]], + ) + self.assertEqual([], self.kills) + + def test_same_size_same_mtime_path_swap_resets_before_term(self) -> None: + cmdline = worker_cmdline("swap@session-933f74c7") + write_process( + self.proc, 531, cmdline, standalone_session=CURRENT_SESSION + ) + transcript = standalone_transcript(self.proc, CURRENT_SESSION) + write_declaring_transcript(transcript, "swap", "session-933f74c7") + self.run_sweep(now=0.0) + self.run_sweep(now=5 * MINUTE) + self.run_sweep(now=10 * MINUTE) + armed = self._tracked_record()["output_copies"][0] + + replacement = transcript.with_suffix(".swap") + replacement.write_bytes(transcript.read_bytes()) + os.utime( + replacement, + ns=(int(armed["mtime_ns"]), int(armed["mtime_ns"])), + ) + real_fstat = os.fstat + stable_stats = 0 + + def swap_after_probe(descriptor: int): + nonlocal stable_stats + info = real_fstat(descriptor) + if info.st_ino == armed["ino"]: + stable_stats += 1 + if stable_stats == 2: + os.replace(replacement, transcript) + return info + + with unittest.mock.patch.object( + subagent_sweep.os, "fstat", side_effect=swap_after_probe + ): + actions = self.run_sweep(now=15 * MINUTE) + + self.assertEqual([], actions) + self.assertEqual([], self.kills) + reset = self._tracked_record() + current = transcript.stat() + self.assertEqual(15 * MINUTE, reset["last_active"]) + self.assertEqual(current.st_ino, reset["output_copies"][0]["ino"]) + self.assertNotEqual(armed["ino"], reset["output_copies"][0]["ino"]) + self.assertEqual(armed["size"], reset["output_copies"][0]["size"]) + self.assertEqual(armed["mtime_ns"], reset["output_copies"][0]["mtime_ns"]) + + def test_registry_created_in_term_guard_vetoes_and_next_pass_binds(self) -> None: + cmdline = worker_cmdline("late-registry@session-933f74c7") + write_process( + self.proc, 532, cmdline, standalone_session=CURRENT_SESSION + ) + old = standalone_transcript(self.proc, CURRENT_SESSION) + write_declaring_transcript(old, "late-registry", "session-933f74c7") + for minute in (0, 5, 10): + self.assertEqual([], self.run_sweep(now=minute * MINUTE)) + registry = standalone_transcript(self.proc, OTHER_SESSION) + real_guard = subagent_sweep._still_the_worker + created = False + + def create_registry_in_guard(proc: Path, pid: int, start: int) -> bool: + nonlocal created + if not created: + created = True + write_session_record(self.proc, 532, OTHER_SESSION) + write_declaring_transcript( + registry, "late-registry", "session-933f74c7" + ) + return real_guard(proc, pid, start) + + with unittest.mock.patch.object( + subagent_sweep, + "_still_the_worker", + side_effect=create_registry_in_guard, + ): + actions = self.run_sweep(now=15 * MINUTE) + + self.assertEqual([], actions) + self.assertEqual([], self.kills) + self.assertEqual( + [os.fspath(registry)], + [copy["path"] for copy in self._tracked_record()["output_copies"]], + ) + with registry.open("a", encoding="utf-8") as handle: + handle.write("active registry output\n") + self.assertEqual([], self.run_sweep(now=20 * MINUTE)) + self.assertEqual( + [os.fspath(registry)], + [copy["path"] for copy in self._tracked_record()["output_copies"]], + ) + + def test_registry_created_in_kill_guard_vetoes_only_that_pass(self) -> None: + cmdline = worker_cmdline("late-kill@session-933f74c7") + write_process( + self.proc, 533, cmdline, standalone_session=CURRENT_SESSION + ) + old = standalone_transcript(self.proc, CURRENT_SESSION) + write_declaring_transcript(old, "late-kill", "session-933f74c7") + for minute in (0, 5, 10): + self.assertEqual([], self.run_sweep(now=minute * MINUTE)) + self.assertEqual(["SIGTERM"], [ + action["signal"] for action in self.run_sweep(now=15 * MINUTE) + ]) + registry = standalone_transcript(self.proc, OTHER_SESSION) + real_guard = subagent_sweep._still_the_worker + created = False + + def create_registry_in_guard(proc: Path, pid: int, start: int) -> bool: + nonlocal created + if not created: + created = True + write_session_record(self.proc, 533, OTHER_SESSION) + write_declaring_transcript( + registry, "late-kill", "session-933f74c7" + ) + return real_guard(proc, pid, start) + + with unittest.mock.patch.object( + subagent_sweep, + "_still_the_worker", + side_effect=create_registry_in_guard, + ): + self.assertEqual([], self.run_sweep(now=20 * MINUTE)) + self.assertEqual([(533, signal.SIGTERM)], self.kills) + tracked = self._tracked_record() + self.assertTrue(tracked["term_sent"]) + self.assertEqual( + [os.fspath(registry)], + [copy["path"] for copy in tracked["output_copies"]], + ) + + actions = self.run_sweep(now=25 * MINUTE) + self.assertEqual(["SIGKILL"], [action["signal"] for action in actions]) + self.assertEqual( + [(533, signal.SIGTERM), (533, signal.SIGKILL)], self.kills + ) + + def test_undecidable_uuid_transcript_refuses_the_whole_answer(self) -> None: + cmdline = worker_cmdline("junk@session-933f74c7") + write_process( + self.proc, 534, cmdline, standalone_session=CURRENT_SESSION + ) + transcript = standalone_transcript(self.proc, CURRENT_SESSION) + transcript.write_bytes( + b"not json\n" * (subagent_sweep._MAX_TRANSCRIPT_PROBE_BYTES // 4) + ) + expected = f"worker transcript membership undecidable: {transcript}" + + snapshot, reason = subagent_sweep._output_snapshot( + self._agent(), {"HOME": os.fspath(self.home)} + ) + self.assertIsNone(snapshot) + self.assertEqual(expected, reason) + for minute in (0, 5, 10, 15, 20): + self.assertEqual([], self.run_sweep(now=minute * MINUTE)) + self.assertEqual([], self.kills) + refusals = [ + json.loads(line) + for line in (self.state_dir / "subagent-sweep.log").read_text().splitlines() + ] + self.assertTrue(refusals) + self.assertTrue(all(record["reason"] == expected for record in refusals)) + + def test_wrong_identity_and_root_uuid_are_confidently_excluded(self) -> None: + cmdline = worker_cmdline("true-one@session-933f74c7") + write_process( + self.proc, 535, cmdline, standalone_session=CURRENT_SESSION + ) + true_copy = standalone_transcript(self.proc, CURRENT_SESSION) + write_declaring_transcript(true_copy, "true-one", "session-933f74c7") + wrong = true_copy.with_name(f"{OTHER_SESSION}.jsonl") + write_declaring_transcript(wrong, "another", "team") + root_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + root = true_copy.with_name(f"{root_id}.jsonl") + root.write_text( + json.dumps( + {"type": "assistant", "sessionId": root_id, "message": "root"} + ) + "\n" + ) + + snapshot, reason = subagent_sweep._output_snapshot( + self._agent(), {"HOME": os.fspath(self.home)} + ) + + self.assertEqual("", reason) + assert snapshot is not None + self.assertEqual( + [os.fspath(true_copy)], + [copy["path"] for copy in snapshot["output_copies"]], + ) + + def test_prior_three_field_copy_evidence_rearms_by_inequality(self) -> None: + now = 10_000.0 + cmdline = worker_cmdline("migration@team") + write_process(self.proc, 536, cmdline) + transcript = worker_transcript(self.proc, cmdline) + info = transcript.stat() + boot = subagent_sweep._boot_id(self.proc) + narrow = { + "path": os.fspath(transcript), + "size": info.st_size, + "mtime_ns": info.st_mtime_ns, + } + (self.state_dir / "subagent-sweep.state.json").write_text( + json.dumps( + { + "version": subagent_sweep.STATE_VERSION, + "tracked": { + f"{boot}:536:1000": { + "output_copies": [narrow], + "last_active": now - 20 * MINUTE, + "term_sent": False, + } + }, + "boot_id": boot, + "last_pass": now - 5 * MINUTE, + "window_seconds": 15 * MINUTE, + } + ) + ) + + self.assertEqual([], self.run_sweep(now=now)) + self.assertEqual([], self.kills) + tracked = self._tracked_record() + self.assertEqual(now, tracked["last_active"]) + self.assertEqual( + {"path", "dev", "ino", "size", "mtime_ns", "ctime_ns"}, + set(tracked["output_copies"][0]), + ) + + +class EveryTranscriptCopyIsTheIdleClock(SweepHarness): + def test_a_future_dated_quiet_copy_cannot_hide_a_moving_live_transcript( + self, + ) -> None: + cmdline = worker_cmdline("moving@session-12345678") + write_process( + self.proc, 510, cmdline, standalone_session=CURRENT_SESSION + ) + write_session_record(self.proc, 510, CURRENT_SESSION) + live = standalone_transcript(self.proc, CURRENT_SESSION) + alternate = self.proc.parent / "future-profile" + quiet = alternate / "projects" / "fixture" / f"{CURRENT_SESSION}.jsonl" + quiet.parent.mkdir(parents=True) + quiet.write_text("quiet copy\n", encoding="utf-8") + future_ns = 2_000_000_000_000_000_000 + os.utime(quiet, ns=(future_ns, future_ns)) + environ = {"CLAUDE_CONFIG_DIR": os.fspath(alternate)} + + self.assertEqual([], self.run_sweep(now=0.0, environ=environ)) + for minute in (5, 10, 15, 20): + with live.open("a", encoding="utf-8") as handle: + handle.write(f"live output at {minute}\n") + self.assertEqual([], self.run_sweep(now=minute * MINUTE, environ=environ)) + self.assertEqual([], self.kills) + + def test_a_copy_stopping_while_another_moves_keeps_resetting(self) -> None: + cmdline = worker_cmdline( + "legacy-moving@team", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + ) + write_process(self.proc, 511, cmdline) + live = worker_transcript(self.proc, cmdline) + alternate = self.proc.parent / "legacy-profile" + quiet = ( + alternate + / "projects" + / "fixture" + / cmdline[cmdline.index("--parent-session-id") + 1] + / "subagents" + / live.name + ) + quiet.parent.mkdir(parents=True) + quiet.write_text("copy before it stops\n", encoding="utf-8") + future_ns = 2_000_000_000_000_000_000 + os.utime(quiet, ns=(future_ns, future_ns)) + environ = {"CLAUDE_CONFIG_DIR": os.fspath(alternate)} + + self.assertEqual([], self.run_sweep(now=0.0, environ=environ)) + with quiet.open("a", encoding="utf-8") as handle: + handle.write("copy's final output\n") + os.utime(quiet, ns=(future_ns, future_ns)) + self.assertEqual([], self.run_sweep(now=5 * MINUTE, environ=environ)) + for minute in (10, 15, 20): + with live.open("a", encoding="utf-8") as handle: + handle.write(f"other copy at {minute}\n") + self.assertEqual([], self.run_sweep(now=minute * MINUTE, environ=environ)) + self.assertEqual([], self.kills) + + def test_identical_copies_all_quiet_for_the_window_fire_idle(self) -> None: + cmdline = worker_cmdline("quiet@session-12345678") + write_process( + self.proc, 512, cmdline, standalone_session=CURRENT_SESSION + ) + write_session_record(self.proc, 512, CURRENT_SESSION) + live = standalone_transcript(self.proc, CURRENT_SESSION) + alternate = self.proc.parent / "quiet-profile" + copy = alternate / "projects" / "fixture" / f"{CURRENT_SESSION}.jsonl" + copy.parent.mkdir(parents=True) + shutil.copyfile(live, copy) + identical_ns = 1_900_000_000_000_000_000 + os.utime(live, ns=(identical_ns, identical_ns)) + os.utime(copy, ns=(identical_ns, identical_ns)) + environ = {"CLAUDE_CONFIG_DIR": os.fspath(alternate)} + + self.assertEqual([], self.run_sweep(now=0.0, environ=environ)) + self.assertEqual([], self.run_sweep(now=5 * MINUTE, environ=environ)) + self.assertEqual([], self.run_sweep(now=10 * MINUTE, environ=environ)) + actions = self.run_sweep(now=15 * MINUTE, environ=environ) + + self.assertEqual(["SIGTERM"], [action["signal"] for action in actions]) + self.assertEqual([(512, signal.SIGTERM)], self.kills) + + def test_appearance_disappearance_shrink_and_mtime_each_reset(self) -> None: + cmdline = worker_cmdline( + "set-changes@team", "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + ) + write_process(self.proc, 513, cmdline) + live = worker_transcript(self.proc, cmdline) + alternate = self.proc.parent / "changing-profile" + copy = ( + alternate + / "projects" + / "fixture" + / cmdline[cmdline.index("--parent-session-id") + 1] + / "subagents" + / live.name + ) + environ = {"CLAUDE_CONFIG_DIR": os.fspath(alternate)} + + self.assertEqual([], self.run_sweep(now=0.0, environ=environ)) + copy.parent.mkdir(parents=True) + copy.write_text("appeared\n", encoding="utf-8") + self.assertEqual([], self.run_sweep(now=5 * MINUTE, environ=environ)) + copy.unlink() + self.assertEqual([], self.run_sweep(now=10 * MINUTE, environ=environ)) + live.write_text("x", encoding="utf-8") + self.assertEqual([], self.run_sweep(now=15 * MINUTE, environ=environ)) + changed_ns = live.stat().st_mtime_ns + 1_000_000_000 + os.utime(live, ns=(changed_ns, changed_ns)) + self.assertEqual([], self.run_sweep(now=20 * MINUTE, environ=environ)) + self.assertEqual([], self.run_sweep(now=30 * MINUTE, environ=environ)) + actions = self.run_sweep(now=35 * MINUTE, environ=environ) + + self.assertEqual(["SIGTERM"], [action["signal"] for action in actions]) + + +class TheFifteenMinuteRule(SweepHarness): + """Operator ruling 2026-08-15: "close it 15 minutes after it's finished".""" + + def test_a_worker_idle_sixteen_minutes_is_swept(self) -> None: + write_process(self.proc, 500, worker_cmdline(), own=40) + actions = self.sweep_until(16 * MINUTE) + self.assertEqual(["SIGTERM"], [action["signal"] for action in actions]) + self.assertEqual([(500, signal.SIGTERM)], self.kills) + + def test_a_worker_idle_fourteen_minutes_is_left_alone(self) -> None: + write_process(self.proc, 500, worker_cmdline(), own=40) + self.assertEqual(self.sweep_until(14 * MINUTE), []) + self.assertEqual([], self.kills) + + def test_the_window_is_fifteen_minutes_by_default(self) -> None: + self.assertEqual(15.0, subagent_sweep.DEFAULT_IDLE_MINUTES) + self.assertAlmostEqual(0.25, subagent_sweep._env_idle_hours({})) + + def test_minutes_can_be_said_in_minutes(self) -> None: + """A name that says hours cannot express 15 without a fraction.""" + self.assertAlmostEqual( + 0.5, + subagent_sweep._env_idle_hours( + {"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "30"} + ), + ) + + def test_the_old_name_still_works_and_still_disables(self) -> None: + """An operator who set the hours name must not be reinterpreted.""" + self.assertEqual( + 6.0, + subagent_sweep._env_idle_hours({"SESSION_KIT_SUBAGENT_IDLE_HOURS": "6"}), + ) + self.assertEqual( + 0.0, + subagent_sweep._env_idle_hours({"SESSION_KIT_SUBAGENT_IDLE_HOURS": "0"}), + ) + + def test_minutes_outranks_hours_and_zero_still_disables(self) -> None: + self.assertAlmostEqual( + 0.5, + subagent_sweep._env_idle_hours( + { + "SESSION_KIT_SUBAGENT_IDLE_MINUTES": "30", + "SESSION_KIT_SUBAGENT_IDLE_HOURS": "6", + } + ), + ) + write_process(self.proc, 500, worker_cmdline(), own=40) + environ = {"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "0"} + self.assertEqual(self.run_sweep(now=0.0, environ=environ), []) + self.assertEqual(self.run_sweep(now=99 * HOUR, environ=environ), []) + self.assertEqual([], self.kills) + + def test_a_junk_minutes_value_disables_the_sweep(self) -> None: + """A window nobody can read is a refusal, not the narrowest window. + + It used to fall back to fifteen minutes, so a typo in the very + variable the docs tell them to RAISE made the closer more aggressive + (lane A F3). + """ + for junk in ("abc", "-5", "nan", "inf", "1h", "30 minutes", ""): + with self.subTest(value=junk): + self.assertEqual( + 0.0, + subagent_sweep._env_idle_hours( + {"SESSION_KIT_SUBAGENT_IDLE_MINUTES": junk} + ), + ) + + def test_a_junk_value_never_revives_a_sweep_that_was_turned_off(self) -> None: + """They turned it off with the old name; a typo must not turn it on.""" + self.assertEqual( + 0.0, + subagent_sweep._env_idle_hours( + { + "SESSION_KIT_SUBAGENT_IDLE_MINUTES": "junk", + "SESSION_KIT_SUBAGENT_IDLE_HOURS": "0", + } + ), + ) + + def test_output_movement_inside_the_window_resets_the_clock(self) -> None: + cmdline = worker_cmdline() + write_process(self.proc, 500, cmdline, own=40) + self.assertEqual(self.run_sweep(now=0.0), []) + with worker_transcript(self.proc, cmdline).open("a", encoding="utf-8") as handle: + handle.write("new output\n") + self.assertEqual(self.run_sweep(now=10 * MINUTE), []) + self.assertEqual(self.run_sweep(now=20 * MINUTE), []) # clock restarted + self.assertEqual([], self.kills) + actions = self.run_sweep(now=25 * MINUTE) + self.assertEqual(["SIGTERM"], [action["signal"] for action in actions]) + + +class OutputIsTheIdleClock(SweepHarness): + def test_moving_cpu_with_static_output_is_swept(self) -> None: + cmdline = worker_cmdline("ticking@team") + write_process(self.proc, 500, cmdline, own=40) + self.assertEqual([], self.run_sweep(now=0.0)) + for minute, ticks in ((5, 41), (10, 42)): + write_process(self.proc, 500, cmdline, own=ticks) + self.assertEqual([], self.run_sweep(now=minute * MINUTE)) + write_process(self.proc, 500, cmdline, own=43) + + actions = self.run_sweep(now=15 * MINUTE) + + self.assertEqual(["SIGTERM"], [action["signal"] for action in actions]) + + def test_growing_output_with_zero_cpu_is_not_swept(self) -> None: + cmdline = worker_cmdline("writing@team") + write_process(self.proc, 500, cmdline, own=0) + transcript = worker_transcript(self.proc, cmdline) + self.assertEqual([], self.run_sweep(now=0.0)) + for minute in (5, 10, 15, 20): + with transcript.open("a", encoding="utf-8") as handle: + handle.write(f"output at {minute}\n") + self.assertEqual([], self.run_sweep(now=minute * MINUTE)) + self.assertEqual([], self.kills) + + def test_unreadable_output_is_not_idle_and_is_logged(self) -> None: + cmdline = worker_cmdline("unreadable@team") + write_process(self.proc, 500, cmdline, own=0) + transcript = worker_transcript(self.proc, cmdline) + transcript.unlink() + transcript.symlink_to(transcript.with_name("missing.jsonl")) + + self.assertEqual([], self.run_sweep(now=0.0)) + self.assertEqual([], self.run_sweep(now=15 * MINUTE)) + self.assertEqual([], self.run_sweep(now=30 * MINUTE)) + self.assertEqual([], self.kills) + records = [ + json.loads(line) + for line in (self.state_dir / "subagent-sweep.log").read_text().splitlines() + ] + self.assertEqual(3, len(records)) + self.assertTrue(all(record["decision"] == "refused-output" for record in records)) + self.assertTrue(all(record["agent_id"] == "unreadable@team" for record in records)) + + +class TheZombiePath(SweepHarness): + def test_an_idle_worker_gets_term_then_kill_across_passes(self) -> None: + # Pinned to a six-hour window on purpose: this case is about the + # TERM-then-KILL sequence, not about what the default happens to be. + write_process(self.proc, 500, worker_cmdline(), own=40) + six = {"SESSION_KIT_SUBAGENT_IDLE_HOURS": "6"} + self.assertEqual(self.run_sweep(now=0.0, environ=six), []) # arms the clock + self.assertEqual(self.run_sweep(now=1 * HOUR, environ=six), []) # too soon + actions = self.sweep_until(7 * HOUR, six) + self.assertEqual([a["signal"] for a in actions], ["SIGTERM"]) + self.assertEqual(self.kills, [(500, signal.SIGTERM)]) + actions = self.run_sweep(now=self.clock + 60, environ=six) + self.assertEqual([a["signal"] for a in actions], ["SIGKILL"]) + self.assertEqual(self.kills[-1], (500, signal.SIGKILL)) + + def test_a_term_handler_writing_output_cannot_dodge_the_kill(self) -> None: + cmdline = worker_cmdline() + write_process(self.proc, 500, cmdline, own=40) + self.run_sweep(now=0.0) + self.run_sweep(now=15 * MINUTE) # TERM sent + with worker_transcript(self.proc, cmdline).open("a", encoding="utf-8") as handle: + handle.write("graceful shutdown\n") + actions = self.run_sweep(now=15 * MINUTE + 60) + self.assertEqual([a["signal"] for a in actions], ["SIGKILL"]) + self.assertTrue(actions[0]["moved_after_term"]) + + def test_output_movement_before_term_resets_the_clock(self) -> None: + cmdline = worker_cmdline() + write_process(self.proc, 500, cmdline, own=40) + self.run_sweep(now=0.0) + with worker_transcript(self.proc, cmdline).open("a", encoding="utf-8") as handle: + handle.write("still working\n") + self.assertEqual(self.run_sweep(now=15 * MINUTE), []) + self.assertEqual(self.kills, []) + # ...and the reset clock counts from the movement, not from zero. + actions = self.run_sweep(now=30 * MINUTE) + self.assertEqual([a["signal"] for a in actions], ["SIGTERM"]) + + def test_pid_reuse_never_inherits_the_term_decision(self) -> None: + write_process(self.proc, 500, worker_cmdline(), own=40, start=1000) + self.run_sweep(now=0.0) + self.run_sweep(now=15 * MINUTE) # TERM to the old process + write_process(self.proc, 500, worker_cmdline(), own=0, start=9999) + actions = self.run_sweep(now=15 * MINUTE + 60) + self.assertEqual(actions, []) # fresh identity, fresh clock + self.assertEqual([s for _, s in self.kills], [signal.SIGTERM]) + + def test_a_vanished_process_is_logged_already_gone(self) -> None: + write_process(self.proc, 500, worker_cmdline(), own=40) + self.run_sweep(now=0.0) + + def raising_kill(pid: int, signum: int) -> None: + raise ProcessLookupError + + actions = subagent_sweep.sweep( + proc=self.proc, + state_dir=self.state_dir, + environ={"HOME": os.fspath(self.home)}, + now=15 * MINUTE, + kill=raising_kill, + ) + self.assertEqual([a["signal"] for a in actions], ["already-gone"]) + + +class TheEvidenceChain(SweepHarness): + def test_a_worker_whose_child_burns_cpu_is_swept_on_static_output(self) -> None: + write_process(self.proc, 500, worker_cmdline(), own=40) + write_process(self.proc, 501, ["make", "-j8"], ppid=500, own=100) + self.run_sweep(now=0.0) + write_process(self.proc, 501, ["make", "-j8"], ppid=500, own=900) + actions = self.run_sweep(now=15 * MINUTE) + self.assertEqual(["SIGTERM"], [action["signal"] for action in actions]) + self.assertEqual([(500, signal.SIGTERM)], self.kills) + + def test_reaped_child_cpu_is_not_output_activity(self) -> None: + write_process(self.proc, 500, worker_cmdline(), own=40, reaped=0) + write_process(self.proc, 501, ["sh", "-c", "true"], ppid=500, own=60) + self.run_sweep(now=0.0) + # The child exits and its ticks land in the parent's cutime. Neither + # fact changes the transcript, so the output clock keeps counting. + for entry in (self.proc / "501").iterdir(): + entry.unlink() + (self.proc / "501").rmdir() + write_process(self.proc, 500, worker_cmdline(), own=40, reaped=60) + actions = self.run_sweep(now=15 * MINUTE) + self.assertEqual([a["signal"] for a in actions], ["SIGTERM"]) + + def test_a_burning_grandchild_does_not_move_the_output_clock(self) -> None: + write_process(self.proc, 500, worker_cmdline(), own=40) + write_process(self.proc, 501, ["sh"], ppid=500, own=1) + write_process(self.proc, 502, ["cc1"], ppid=501, own=100) + self.run_sweep(now=0.0) + write_process(self.proc, 502, ["cc1"], ppid=501, own=500) + actions = self.run_sweep(now=15 * MINUTE) + self.assertEqual(["SIGTERM"], [action["signal"] for action in actions]) + + +class TheWorkerShape(SweepHarness): + def test_a_shell_quoting_the_flag_in_its_script_is_ignored(self) -> None: + write_process( + self.proc, + 600, + ["/bin/bash", "-c", "pgrep -f -- '--parent-session-id abcd'"], + own=5, + ) + self.assertEqual(subagent_sweep.find_subagents(self.proc), []) + + def test_a_grep_with_the_exact_flag_argv_is_ignored(self) -> None: + write_process( + self.proc, + 601, + ["grep", "--parent-session-id", "abcd", "--agent-id", "x@y", "log"], + own=5, + ) + self.assertEqual(subagent_sweep.find_subagents(self.proc), []) + + def test_a_worker_missing_the_agent_id_flag_is_ignored(self) -> None: + write_process( + self.proc, 602, [CLAUDE, "--parent-session-id", "abcd"], own=5 + ) + self.assertEqual(subagent_sweep.find_subagents(self.proc), []) + + def test_the_lane_impostor_battery_never_matches(self) -> None: + # Every impostor shape the review lanes used to break the old + # substring match (X20-F2). Each carries both flags as exact argv + # elements with values; the executable test alone must reject them. + flags = ["--parent-session-id", "abcd", "--agent-id", "x@y"] + impostors = [ + ["grep", "claude"] + flags + ["log"], + ["pgrep", "claude"] + flags, + ["/tmp/notclaude"] + flags, + ["/home/user/claude-tmp/helper"] + flags, + ["/usr/bin/python3", "/home/user/.claude/hooks/courier.py"] + flags, + # Round 3, lane finding 4: a tool of THEIRS under a directory named + # `claude`. The old rule accepted any executable with a `claude` + # path component, so a review lane launched a real sleeper shaped + # like this and watched the sweep TERM it after sixteen minutes. + ["/home/user/fixture/claude/operator-tool.js"] + flags, + ["/home/user/claude/my-script"] + flags, + ["/opt/claude/bin/helper"] + flags, + # `claude/versions/...` but the version component is not a version. + ["/home/user/claude/versions/mytool"] + flags, + # The same shape behind a node wrapper. + ["node", "/x/claude/cli.js"] + flags, + ] + for pid, cmdline in enumerate(impostors, start=610): + write_process(self.proc, pid, cmdline, own=5) + self.assertEqual(subagent_sweep.find_subagents(self.proc), []) + + def test_the_equals_flag_form_is_refused(self) -> None: + # Exact elements only: a worker never uses --flag=value, so a + # process that does is not a worker. + write_process( + self.proc, + 620, + [CLAUDE, "--parent-session-id=abcd", "--agent-id=x@y"], + own=5, + ) + self.assertEqual(subagent_sweep.find_subagents(self.proc), []) + + def test_the_real_worker_shape_is_found_with_its_identity(self) -> None: + write_process(self.proc, 603, worker_cmdline("opus-lane@t", "sess-1"), own=7) + found = subagent_sweep.find_subagents(self.proc) + self.assertEqual(len(found), 1) + self.assertEqual(found[0]["agent_id"], "opus-lane@t") + self.assertEqual(found[0]["parent"], "sess-1") + + def test_a_node_wrapped_worker_is_still_found(self) -> None: + """Behind a node wrapper, argv[1] faces the SAME provider test. + + This used to assert on `node /x/claude/cli.js`, which the round-3 + tightening refuses -- and rightly: an executable named `cli.js` under a + directory named `claude` is the operator-tool shape from lane finding + 4, not a provider layout. It was never a real shape either; the actual + npm layout is `.../@anthropic-ai/claude-code/cli.js`, whose component + is `claude-code`, and that has never matched at any revision. Re-pointed + at a genuine provider path, and the old string is now in the impostor + battery where it belongs. + """ + write_process( + self.proc, + 604, + ["node", "/x/share/claude/versions/2.1.233"] + worker_cmdline()[1:], + own=7, + ) + self.assertEqual(len(subagent_sweep.find_subagents(self.proc)), 1) + + +class TheSwitches(SweepHarness): + def arm_overdue_worker(self) -> None: + write_process(self.proc, 500, worker_cmdline(), own=40) + self.run_sweep(now=0.0) + + def test_the_env_kill_switch_stops_the_sweep(self) -> None: + self.arm_overdue_worker() + for value in ("0", "off", "no", "false", " OFF "): + actions = self.run_sweep( + now=15 * MINUTE, environ={"SESSION_KIT_SUBAGENT_SWEEP": value} + ) + self.assertEqual(actions, [], value) + self.assertEqual(self.kills, []) + + def test_the_state_file_kill_switch_stops_the_sweep(self) -> None: + self.arm_overdue_worker() + (self.state_dir / "subagent-sweep-off").touch() + self.assertEqual(self.run_sweep(now=15 * MINUTE), []) + self.assertEqual(self.kills, []) + + def test_zero_idle_hours_disables_rather_than_kills_instantly(self) -> None: + self.arm_overdue_worker() + actions = self.run_sweep( + now=15 * MINUTE, environ={"SESSION_KIT_SUBAGENT_IDLE_HOURS": "0"} + ) + self.assertEqual(actions, []) + self.assertEqual(self.kills, []) + + def test_garbage_idle_hours_disables_rather_than_guessing(self) -> None: + for raw in ("abc", "-3", "inf", "nan", "", "6h"): + self.assertEqual( + 0.0, + subagent_sweep._env_idle_hours( + {"SESSION_KIT_SUBAGENT_IDLE_HOURS": raw} + ), + raw, + ) + + def test_a_shorter_threshold_is_honoured(self) -> None: + window = {"SESSION_KIT_SUBAGENT_IDLE_HOURS": "1.5"} + write_process(self.proc, 500, worker_cmdline(), own=40) + actions = self.sweep_until(2 * HOUR, window) + self.assertEqual([a["signal"] for a in actions], ["SIGTERM"]) + + +class TheSignalPath(SweepHarness): + def test_sigcont_is_delivered_on_every_refusal_path_after_stop(self) -> None: + deliver = getattr(subagent_sweep, "_deliver_background_shell", None) + evidence_snapshot = getattr( + subagent_sweep, "_background_shell_evidence_snapshot", None + ) + if not callable(deliver) or not callable(evidence_snapshot): + self.skipTest("round-2 pidfd helper API is absent (regression guard)") + output = self.home / "background-task.out" + output.write_text("finished output\n", encoding="utf-8") + write_background_shell(self.proc, output) + shell = subagent_sweep.find_background_shells(self.proc)[0] + armed, error = evidence_snapshot(shell, self.proc) + self.assertEqual("", error) + assert armed is not None + + for path in ("stopped-state", "frozen-reproof"): + with self.subTest(path=path): + rewrite_terminal_stat(self.proc, 500, state="S") + sent: list[int] = [] + + def send(_pid: int, signum: int) -> None: + sent.append(signum) + if signum == signal.SIGSTOP and path != "stopped-state": + rewrite_terminal_stat(self.proc, 500, state="T") + elif signum == signal.SIGCONT: + rewrite_terminal_stat(self.proc, 500, state="S") + + proof_patch = ( + unittest.mock.patch.object( + subagent_sweep, + "_background_shell_final_proof", + return_value=(None, "synthetic frozen refusal"), + ) + if path == "frozen-reproof" + else contextlib.nullcontext() + ) + with proof_patch: + delivered, proof, refusal = deliver( + self.proc, + shell, + armed, + signal.SIGTERM, + send_signal=send, + ) + + self.assertFalse(delivered) + self.assertIsNone(proof) + self.assertTrue(refusal) + self.assertEqual([signal.SIGSTOP, signal.SIGCONT], sent) + + def test_background_shell_pidfd_helper_orders_freeze_proof_close_resume( + self, + ) -> None: + deliver = getattr(subagent_sweep, "_deliver_background_shell", None) + evidence_snapshot = getattr( + subagent_sweep, "_background_shell_evidence_snapshot", None + ) + final_proof = getattr(subagent_sweep, "_background_shell_final_proof", None) + if ( + not callable(deliver) + or not callable(evidence_snapshot) + or not callable(final_proof) + ): + self.skipTest("round-2 pidfd helper API is absent (regression guard)") + output = self.home / "background-task.out" + output.write_text("finished output\n", encoding="utf-8") + write_background_shell(self.proc, output) + shell = subagent_sweep.find_background_shells(self.proc)[0] + armed, error = evidence_snapshot(shell, self.proc) + self.assertEqual("", error) + assert armed is not None + events: list[str] = [] + original_proof = final_proof + descriptor = os.open("/dev/null", os.O_RDONLY) + + def open_pidfd(pid: int) -> int: + self.assertEqual(500, pid) + events.append("pidfd-open") + return descriptor + + def prove(*args, **kwargs): + events.append("final-proof") + return original_proof(*args, **kwargs) + + def send(pidfd: int, signum: int) -> None: + self.assertEqual(descriptor, pidfd) + if signum == signal.SIGSTOP: + events.append("pidfd-stop") + rewrite_terminal_stat(self.proc, 500, state="T") + elif signum == signal.SIGTERM: + events.append("pidfd-term") + elif signum == signal.SIGCONT: + events.append("pidfd-cont") + rewrite_terminal_stat(self.proc, 500, state="S") + + with ( + unittest.mock.patch.object(subagent_sweep, "_HAS_PIDFD", True), + unittest.mock.patch.object( + subagent_sweep.os, "pidfd_open", side_effect=open_pidfd + ), + unittest.mock.patch.object( + subagent_sweep, + "_background_shell_final_proof", + side_effect=prove, + ), + unittest.mock.patch.object( + subagent_sweep.signal, + "pidfd_send_signal", + side_effect=send, + ), + ): + delivered, proof, refusal = ( + deliver( + self.proc, + shell, + armed, + signal.SIGTERM, + ) + ) + + self.assertTrue(delivered) + self.assertIsNotNone(proof) + self.assertEqual("", refusal) + self.assertEqual( + ["pidfd-open", "pidfd-stop", "final-proof", "pidfd-term", "pidfd-cont"], + events, + ) + + def test_exact_process_delivery_accepts_a_shell_and_uses_pidfd(self) -> None: + write_process(self.proc, 501, ["bash"], own=1, start=2000) + with ( + unittest.mock.patch.object(subagent_sweep, "_HAS_PIDFD", True), + unittest.mock.patch.object( + subagent_sweep.os, "pidfd_open", return_value=77 + ) as opened, + unittest.mock.patch.object( + subagent_sweep.signal, "pidfd_send_signal" + ) as sent, + unittest.mock.patch.object(subagent_sweep.os, "close") as closed, + ): + subagent_sweep._deliver_exact_process( + self.proc, 501, 2000, signal.SIGTERM + ) + opened.assert_called_once_with(501) + sent.assert_called_once_with(77, signal.SIGTERM) + closed.assert_called_once_with(77) + + def test_exact_process_delivery_rechecks_after_pin_and_refuses_reuse( + self, + ) -> None: + write_process(self.proc, 501, ["bash"], own=1, start=2000) + + def recycle_after_pin(pid: int) -> int: + write_process(self.proc, pid, ["bash"], own=1, start=9999) + return 77 + + with ( + unittest.mock.patch.object(subagent_sweep, "_HAS_PIDFD", True), + unittest.mock.patch.object( + subagent_sweep.os, "pidfd_open", side_effect=recycle_after_pin + ), + unittest.mock.patch.object( + subagent_sweep.signal, "pidfd_send_signal" + ) as sent, + unittest.mock.patch.object(subagent_sweep.os, "close"), + ): + with self.assertRaises(ProcessLookupError): + subagent_sweep._deliver_exact_process( + self.proc, 501, 2000, signal.SIGTERM + ) + sent.assert_not_called() + + def test_a_recycled_pid_is_refused_at_signal_time(self) -> None: + # The scan-to-signal race (X20-F3): the scan recorded one identity, + # the process was replaced before delivery. The pre-signal identity + # re-read must refuse; the recycled process must never be struck. + write_process(self.proc, 500, worker_cmdline(), own=40, start=1000) + self.run_sweep(now=0.0) + stale = { + "pid": 500, + "start_ticks": 1000, + "agent_id": "wave8@team", + "parent": "abcd-1234", + } + write_process(self.proc, 500, worker_cmdline(), own=0, start=9999) + with unittest.mock.patch.object( + subagent_sweep, "find_subagents", return_value=[stale] + ): + actions = self.run_sweep(now=15 * MINUTE) + self.assertEqual([a["signal"] for a in actions], ["already-gone"]) + self.assertEqual(self.kills, []) + + def test_still_the_worker_rejects_new_ticks_and_new_shape(self) -> None: + write_process(self.proc, 500, worker_cmdline(), own=40, start=1000) + self.assertTrue(subagent_sweep._still_the_worker(self.proc, 500, 1000)) + self.assertFalse(subagent_sweep._still_the_worker(self.proc, 500, 2000)) + write_process(self.proc, 501, ["bash"], own=1, start=1000) + self.assertFalse(subagent_sweep._still_the_worker(self.proc, 501, 1000)) + + def test_an_undelivered_term_never_escalates_to_kill(self) -> None: + # X20-F5: a TERM that failed to land is not a TERM. The next pass + # must retry TERM, never fire KILL after a signal nobody received. + write_process(self.proc, 500, worker_cmdline(), own=40) + self.run_sweep(now=0.0) + + def refusing_kill(pid: int, signum: int) -> None: + raise PermissionError(1, "refused") + + blocked = subagent_sweep.sweep( + proc=self.proc, + state_dir=self.state_dir, + environ={"HOME": os.fspath(self.home)}, + now=15 * MINUTE, + kill=refusing_kill, + ) + self.assertEqual([a["signal"] for a in blocked], ["error:1"]) + retry = self.run_sweep(now=15 * MINUTE + 60) + self.assertEqual([a["signal"] for a in retry], ["SIGTERM"]) + + def test_a_second_concurrent_sweep_skips_instead_of_killing_early(self) -> None: + # X20-F7: overlap must never collapse the TERM-to-KILL grace pass. + # The lock is the state DIRECTORY's own descriptor -- no lock file. + write_process(self.proc, 500, worker_cmdline(), own=40) + self.run_sweep(now=0.0) + import os as _os + + holder = _os.open(self.state_dir, _os.O_RDONLY) + self.addCleanup(_os.close, holder) + fcntl.flock(holder, fcntl.LOCK_EX) + actions = self.run_sweep(now=15 * MINUTE) + self.assertEqual(actions, []) + self.assertEqual(self.kills, []) + + +class DryRunAndRecords(SweepHarness): + def test_dry_run_reports_but_touches_nothing(self) -> None: + write_process(self.proc, 500, worker_cmdline(), own=40) + self.run_sweep(now=0.0) + state_before = (self.state_dir / "subagent-sweep.state.json").read_text() + # Touches nothing means the WHOLE directory inventory and every byte + # in it (X20-F4 round 2 caught a lock file this assertion would have + # caught in round 1): compare the complete listing, not chosen files. + inventory_before = { + entry.name: entry.read_bytes() if entry.is_file() else None + for entry in self.state_dir.iterdir() + } + actions = self.run_sweep(now=15 * MINUTE, dry_run=True) + self.assertEqual([a["signal"] for a in actions], ["SIGTERM"]) + self.assertTrue(actions[0]["dry_run"]) + self.assertEqual(self.kills, []) + state_after = (self.state_dir / "subagent-sweep.state.json").read_text() + self.assertEqual(state_before, state_after) + inventory_after = { + entry.name: entry.read_bytes() if entry.is_file() else None + for entry in self.state_dir.iterdir() + } + self.assertEqual(inventory_before, inventory_after) + # A dry run must not commit the TERM decision either: the next real + # pass sends TERM, not KILL. It watches a fresh window first, because + # a dry run writes nothing at all -- including the record of when a + # pass last ran -- so the real pass after one sees a gap and starts + # the clocks over. Fail-safe, and the point still stands: TERM. + real = self.sweep_until(45 * MINUTE) + self.assertEqual([a["signal"] for a in real], ["SIGTERM"]) + + def test_a_cold_dry_run_creates_nothing_at_all(self) -> None: + # The reaper may run its very first pass in dry-run mode against a + # state directory that has never seen a sweep: even the lock must + # not appear (X20-F4 round 2, the flock is on the directory itself). + write_process(self.proc, 500, worker_cmdline(), own=40) + actions = self.run_sweep(now=0.0, dry_run=True) + self.assertEqual(actions, []) + self.assertEqual(sorted(p.name for p in self.state_dir.iterdir()), []) + + def test_every_action_lands_in_the_log_as_one_json_line(self) -> None: + write_process(self.proc, 500, worker_cmdline(), own=40) + self.run_sweep(now=0.0) + self.run_sweep(now=15 * MINUTE) + self.run_sweep(now=15 * MINUTE + 60) + lines = ( + (self.state_dir / "subagent-sweep.log").read_text().strip().splitlines() + ) + self.assertEqual( + [json.loads(line)["signal"] for line in lines], + ["SIGTERM", "SIGKILL"], + ) + for line in lines: + record = json.loads(line) + self.assertEqual(record["pid"], 500) + self.assertEqual(record["agent_id"], "wave8@team") + + def test_a_corrupt_state_file_starts_fresh_instead_of_crashing(self) -> None: + write_process(self.proc, 500, worker_cmdline(), own=40) + (self.state_dir / "subagent-sweep.state.json").write_text("{not json") + self.assertEqual(self.run_sweep(now=0.0), []) # fresh clock, no crash + actions = self.run_sweep(now=15 * MINUTE) + self.assertEqual([a["signal"] for a in actions], ["SIGTERM"]) + + +class NothingIsClosedOnEvidenceOlderThanTheRule(SweepHarness): + """Round 2, lane A F2/F8 and Codex 1: the clock has to be trustworthy.""" + + def legacy_state(self, *, idle_minutes: list[int], now: float) -> list[int]: + """A state file armed by the OLD six-hour pass, as install day finds it.""" + pids = [500 + index for index in range(len(idle_minutes))] + for pid in pids: + write_process(self.proc, pid, worker_cmdline(f"w{pid}@team"), own=40) + (self.state_dir / "subagent-sweep.state.json").write_text( + json.dumps( + { + "version": 2, + "tracked": { + f"{pid}:1000": { + "cpu_ticks": 40, + "last_active": now - minutes * MINUTE, + "term_sent": False, + } + for pid, minutes in zip(pids, idle_minutes) + }, + } + ) + ) + return pids + + def test_the_first_pass_after_install_closes_nothing(self) -> None: + """The single worst thing this branch could do. + + Every worker idle longer than fifteen minutes under the SIX-hour rule + becomes an immediate candidate the moment the new rule lands. Nothing + may be closed on evidence gathered before the rule that judges it. + """ + now = 10_000.0 + idle = [0, 3, 8, 17, 25, 40, 90, 210, 400, 700, 1200, 1500] + self.legacy_state(idle_minutes=idle, now=now) + + actions = self.run_sweep(now=now) + + self.assertEqual([], actions) + self.assertEqual([], self.kills) + self.assertGreaterEqual(len([m for m in idle if m >= 15]), 9) + + def test_a_full_window_under_the_new_rule_still_closes(self) -> None: + """Fresh clocks are a delay, not an amnesty.""" + now = 10_000.0 + self.legacy_state(idle_minutes=[600, 600], now=now) + self.assertEqual([], self.run_sweep(now=now)) + + actions = self.sweep_until(now + 20 * MINUTE, start=now + 5 * MINUTE) + + self.assertEqual(["SIGTERM", "SIGTERM"], [a["signal"] for a in actions]) + + def test_a_gap_longer_than_the_window_starts_the_clocks_over(self) -> None: + """A skipped pass -- the flock held by the hourly reaper -- is a gap.""" + write_process(self.proc, 500, worker_cmdline(), own=40) + self.assertEqual([], self.run_sweep(now=0.0)) + + self.assertEqual([], self.run_sweep(now=3 * HOUR)) # nothing observed between + self.assertEqual([], self.kills) + + def test_a_clock_that_jumps_forward_closes_nothing(self) -> None: + """An NTP correction is not fifteen minutes of idleness (F8).""" + write_process(self.proc, 500, worker_cmdline(), own=40) + self.assertEqual([], self.run_sweep(now=0.0)) + + self.assertEqual([], self.run_sweep(now=48 * HOUR)) + self.assertEqual([], self.kills) + + def test_a_reboot_discards_the_previous_boots_decisions(self) -> None: + """Start ticks are counted from boot, so pid:start_ticks is not\nunique across one. A persisted TERM must never land on a stranger.""" + (self.proc / "sys" / "kernel" / "random").mkdir(parents=True) + boot = self.proc / "sys" / "kernel" / "random" / "boot_id" + boot.write_text("11111111-1111-4111-8111-111111111111\n") + write_process(self.proc, 500, worker_cmdline(), own=40) + self.run_sweep(now=0.0) + self.run_sweep(now=15 * MINUTE) # TERM committed for THIS boot + self.assertEqual([(500, signal.SIGTERM)], self.kills) + + boot.write_text("22222222-2222-4222-8222-222222222222\n") + self.kills.clear() + actions = self.run_sweep(now=15 * MINUTE + 60) + + self.assertEqual([], actions) # not an inherited KILL + self.assertEqual([], self.kills) + + +class TheSwitchesReachThePassThatCloses(SweepHarness): + """Round 2, lane A F4: a setting they cannot apply is not a setting.""" + + def test_the_window_file_reaches_a_pass_with_no_environment(self) -> None: + """The systemd user manager inherits none of their shell environment,\nso the file beside the off switch is the switch that arrives.""" + write_process(self.proc, 500, worker_cmdline(), own=40) + (self.state_dir / subagent_sweep.IDLE_WINDOW_FILE).write_text("120\n") + + self.assertEqual([], self.sweep_until(60 * MINUTE)) + self.assertEqual([], self.kills) + + def test_a_malformed_window_file_disables_rather_than_guesses(self) -> None: + write_process(self.proc, 500, worker_cmdline(), own=40) + (self.state_dir / subagent_sweep.IDLE_WINDOW_FILE).write_text("half an hour\n") + + self.assertEqual([], self.sweep_until(3 * HOUR)) + self.assertEqual([], self.kills) + + def test_the_environment_still_outranks_the_file(self) -> None: + write_process(self.proc, 500, worker_cmdline(), own=40) + (self.state_dir / subagent_sweep.IDLE_WINDOW_FILE).write_text("120\n") + + actions = self.sweep_until( + 30 * MINUTE, {"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "10"} + ) + + self.assertEqual(["SIGTERM"], [action["signal"] for action in actions]) + + +class ThePassThatDeliversIt(unittest.TestCase): + """A window is only as sharp as the pass that applies it. + + Idle is measured between passes, so the cadence is half the ruling. On the + hourly reaper timer a fifteen-minute rule would have meant up to about + seventy-five minutes -- the rule would have been a lie. + """ + + def timer(self) -> str: + return (REPO / "systemd" / "session-kit-subagent-sweep.timer").read_text( + encoding="utf-8" + ) + + def service(self) -> str: + return (REPO / "systemd" / "session-kit-subagent-sweep.service").read_text( + encoding="utf-8" + ) + + def test_the_sweep_runs_every_five_minutes(self) -> None: + self.assertIn("OnUnitActiveSec=5min", self.timer()) + + def test_the_cadence_actually_delivers_the_fifteen_minute_promise(self) -> None: + """Worst case is the window plus one pass interval.""" + interval_minutes = 5 + worst_case = subagent_sweep.DEFAULT_IDLE_MINUTES + interval_minutes + self.assertLessEqual(worst_case, 20) + self.assertIn(f"OnUnitActiveSec={interval_minutes}min", self.timer()) + + def test_the_five_minute_pass_sweeps_and_nothing_else(self) -> None: + """It must not build the guard-live inventory or close a terminal. + + That work is expensive and destructive, and its own thresholds are + measured in days -- which is why this is a separate timer rather than + a faster shpool-reaper.timer. + """ + self.assertIn("--sweep-subagents", self.service()) + self.assertNotIn("--auto-close", self.service()) + + reaper = (REPO / "bin" / "shpool_reaper").read_text(encoding="utf-8") + self.assertIn("--sweep-subagents)", reaper) + # The sweep-only mode returns before anything expensive is reached. + early_exit = reaper.index("if [[ ${MODE:-report} == sweep ]]; then") + self.assertLess(early_exit, reaper.index("guard-live status command unavailable")) + self.assertLess(early_exit, reaper.index("SK_REAPER_AUTO_CLOSE_HOURS")) + + +class TheOffSwitchStopsTheClosingPass(unittest.TestCase): + """Round 2, lane A F1/F6: proved by RUNNING the pass, not by reading it. + + The old assertions compared `str.index()` positions in the script, so they + passed while the operator's off switch was being skipped. This starts the + real program with a pinned state root and a fixture /proc, and looks at + what it did. + """ + + def setUp(self) -> None: + scratch = tempfile.TemporaryDirectory(prefix=".sentinel-") + self.addCleanup(scratch.cleanup) + self.base = Path(scratch.name) + self.home = self.base / "home" + self.state = self.base / "state" + self.proc = self.base / "proc" + for path in (self.home, self.state, self.proc): + path.mkdir(mode=0o700) + self.pid = int( + Path("/proc/sys/kernel/pid_max").read_text(encoding="utf-8").strip() + ) + 1 + self.assertFalse(Path("/proc", str(self.pid)).exists()) + write_process(self.proc, self.pid, worker_cmdline(), own=40) + + def reaper(self, **extra: str) -> subprocess.CompletedProcess[str]: + environment = dict(os.environ) + environment.update( + { + "HOME": os.fspath(self.home), + "CLAUDE_CONFIG_DIR": os.fspath(self.home / ".claude"), + "SESSION_KIT_ACCOUNT_ROOT": os.fspath(self.base / "accounts"), + "SESSION_KIT_STATE_DIR": os.fspath(self.state), + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_PROC_ROOT": os.fspath(self.proc), + "SESSION_KIT_NONINTERACTIVE": "1", + # Three seconds, with passes about a second apart: a real + # cadence in miniature. Passes must be closer together than + # the window or the sweep starts its clocks over, which is + # the install-day rule doing its job. + "SESSION_KIT_SUBAGENT_IDLE_MINUTES": "0.05", + } + ) + environment.update(extra) + return subprocess.run( + [os.fspath(REPO / "bin" / "shpool_reaper"), "--sweep-subagents"], + env=environment, + text=True, + capture_output=True, + check=False, + ) + + def swept(self) -> list[dict]: + log = self.state / "subagent-sweep.log" + if not log.exists(): + return [] + return [json.loads(line) for line in log.read_text().splitlines() if line] + + def run_cadence(self, passes: int = 6, **extra: str) -> None: + """Passes about a second apart until one acts, or `passes` are spent.""" + for _ in range(passes): + self.reaper(**extra) + if self.swept(): + return + time.sleep(1.05) + + def test_the_pass_acts_when_the_switch_is_absent(self) -> None: + """The control: without a switch this really does reach the worker. + + Delivery then refuses, because the fixture pid does not exist and the + identity is re-read at signal time -- `already-gone` is the sweep + acting. Without this control, a switch test proves nothing: a pass + that never reached anything would pass it too. + """ + self.run_cadence() + + acted = self.swept() + self.assertEqual(1, len(acted), acted) + self.assertEqual(self.pid, acted[0]["pid"]) + self.assertIn(acted[0]["signal"], {"SIGTERM", "already-gone"}) + + def test_no_shpool_reaper_stops_the_sub_agent_sweep(self) -> None: + """Their escape hatch. It covered this at base; the five-minute entry + point walked out from behind it, and that is what round 1 shipped.""" + self.reaper() + time.sleep(1.05) + self.reaper() + time.sleep(1.05) + (self.home / ".no_shpool_reaper").touch() + + result = self.reaper() + + self.assertEqual(3, result.returncode, result.stderr) + self.assertIn("disabled by", result.stderr) + self.assertEqual([], self.swept()) + + def test_the_sweep_switch_and_the_off_file_also_stop_it(self) -> None: + for label, extra, marker in ( + ("SESSION_KIT_SUBAGENT_SWEEP=off", {"SESSION_KIT_SUBAGENT_SWEEP": "off"}, None), + ("window of zero", {"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "0"}, None), + ("malformed window", {"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "junk"}, None), + ("subagent-sweep-off file", {}, "subagent-sweep-off"), + ): + with self.subTest(switch=label): + for stale in (self.state / "subagent-sweep.log", + self.state / "subagent-sweep.state.json"): + stale.unlink(missing_ok=True) + (self.state / "subagent-sweep-off").unlink(missing_ok=True) + if marker: + (self.state / marker).touch() + + self.run_cadence(**extra) + + self.assertEqual([], self.swept(), label) + + def test_the_hourly_pass_still_sweeps_as_a_backstop(self) -> None: + """An install that never enabled the new timer must not stop sweeping.""" + reaper = (REPO / "bin" / "shpool_reaper").read_text(encoding="utf-8") + auto = reaper.index("if [[ $MODE == auto ]]; then") + self.assertIn("sweep_subagents", reaper[auto:]) + + def test_the_units_ship_and_are_enabled(self) -> None: + listed = (REPO / "bin" / "session-kit").read_text(encoding="utf-8") + self.assertIn("session-kit-subagent-sweep.timer", listed) + self.assertIn("session-kit-subagent-sweep.service", listed) + services = (REPO / "lib" / "sh" / "session_kit_services.sh").read_text( + encoding="utf-8" + ) + self.assertIn("session-kit-subagent-sweep.timer", services) + + +class TheWindowIsPartOfTheEvidence(SweepHarness): + """Round 3: the guarantee must come from the RULE, not from a lucky format. + + "Nothing is closed on evidence gathered before the rule that judges it" was + delivered by noticing that the six-hour release's state file carried no + `last_pass` field. That is true, and it is an accident of format: it holds + on install day and nowhere else. The same estate is destroyed by any LATER + narrowing -- a future default, or the operator lowering the window himself + -- because the passes are five minutes apart and the gap rule never fires. + So the window the evidence was gathered under travels WITH the evidence. + """ + + def armed_under(self, window_minutes: float, *, idle_minutes: float, now: float, + record_window: bool = True) -> int: + """A worker watched to `idle_minutes` by a healthy pass at `window_minutes`.""" + pid = 700 + cmdline = worker_cmdline("carried@team") + write_process(self.proc, pid, cmdline, own=40) + # The state key carries the boot (round 3, Codex 1/3), so a fixture that + # hand-writes one has to build it the way the module does. + boot = subagent_sweep._boot_id(self.proc) + document = { + "version": subagent_sweep.STATE_VERSION, + "tracked": { + f"{boot}:{pid}:1000": { + **output_state(self.proc, cmdline), + "last_active": now - idle_minutes * MINUTE, + "term_sent": False, + } + }, + "boot_id": subagent_sweep._boot_id(self.proc), + # A pass five minutes ago: the cadence was healthy, no gap, no + # reboot, no clock jump. Every other re-arm trigger is silent here + # on purpose, so only the window change can explain the result. + "last_pass": now - 5 * MINUTE, + } + if record_window: + document["window_seconds"] = window_minutes * 60 + (self.state_dir / "subagent-sweep.state.json").write_text(json.dumps(document)) + return pid + + def test_shortening_the_window_starts_the_clocks_over(self) -> None: + now = 10_000.0 + self.armed_under(120, idle_minutes=90, now=now) + + actions = self.run_sweep(now=now, environ={}) + + self.assertEqual([], actions, "90 idle minutes were gathered while the " + "rule was two hours; the new rule has to " + "watch for itself") + self.assertEqual([], self.kills) + + def test_and_then_closes_after_a_full_window_of_its_own(self) -> None: + """Fresh clocks are a delay, not an amnesty -- the same as install day.""" + now = 10_000.0 + self.armed_under(120, idle_minutes=90, now=now) + self.assertEqual([], self.run_sweep(now=now)) + + actions = self.sweep_until(now + 20 * MINUTE, start=now + 5 * MINUTE) + + self.assertEqual(["SIGTERM"], [a["signal"] for a in actions]) + + def test_widening_the_window_keeps_the_evidence(self) -> None: + """A wider window can only ever close LESS, so its evidence still counts.""" + now = 10_000.0 + self.armed_under(15, idle_minutes=20, now=now) + + actions = self.run_sweep( + now=now, environ={"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "30"} + ) + + self.assertEqual([], actions, "20 idle minutes is inside a 30-minute window") + stored = json.loads( + (self.state_dir / "subagent-sweep.state.json").read_text() + ) + # Not re-armed: the clock it inherited is still running. + boot = subagent_sweep._boot_id(self.proc) + self.assertEqual( + now - 20 * MINUTE, + stored["tracked"][f"{boot}:700:1000"]["last_active"], + ) + + def test_a_state_file_that_never_recorded_its_window_is_not_trusted(self) -> None: + """Unknown provenance is untrusted provenance. + + This is every state file written before this change, including the one + the operator has on disk right now. + """ + now = 10_000.0 + self.armed_under(15, idle_minutes=90, now=now, record_window=False) + + self.assertEqual([], self.run_sweep(now=now)) + self.assertEqual([], self.kills) + + def test_a_window_it_cannot_compare_is_a_window_it_does_not_know(self) -> None: + """A hand-edited or corrupt state file must not buy trust. + + A NaN compares false against everything and would slip straight past the + shorter-than check; zero and negatives are not windows at all, and a + pass only saves state while the sweep is ON, so a stored zero cannot + have come from this code. Every one of them takes the same answer as a + window that was never recorded. + """ + now = 10_000.0 + for corrupt in ("NaN", "-1", "0", "true", '"900"', "null"): + with self.subTest(window_seconds=corrupt): + self.setUp() + pid = self.armed_under(120, idle_minutes=90, now=now) + path = self.state_dir / "subagent-sweep.state.json" + document = json.loads(path.read_text()) + document["window_seconds"] = json.loads(corrupt) + path.write_text(json.dumps(document)) + + self.assertEqual([], self.run_sweep(now=now)) + self.assertEqual([], self.kills) + self.assertTrue(pid) + + def test_the_pass_records_the_window_it_judged_by(self) -> None: + write_process(self.proc, 701, worker_cmdline(), own=40) + + self.run_sweep(now=1000.0, environ={"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "45"}) + + stored = json.loads( + (self.state_dir / "subagent-sweep.state.json").read_text() + ) + self.assertEqual(45 * 60, stored["window_seconds"]) + + +class APassThatDoesNothingSaysSo(SweepHarness): + """Lane B F9. Silence is the symptom this whole change exists to remove. + + A skipped pass and a pass that ran and found nothing are both "no output + and exit 0", and they are not the same fact. If the state directory lock is + ever held for good -- a stuck process, a debugger, a hand-run pass that hung + -- the fifteen-minute rule retires permanently and nothing anywhere says so. + """ + + def test_a_held_lock_leaves_a_trace(self) -> None: + write_process(self.proc, 800, worker_cmdline(), own=40) + holder = os.open(self.state_dir, os.O_RDONLY) + self.addCleanup(os.close, holder) + fcntl.flock(holder, fcntl.LOCK_EX | fcntl.LOCK_NB) + + with unittest.mock.patch("sys.stderr", new_callable=_Capture) as err: + actions = self.run_sweep(now=1000.0) + + self.assertEqual([], actions) + self.assertEqual([], self.kills) + self.assertIn("skipped", err.text()) + self.assertIn(str(self.state_dir), err.text()) + self.assertIn("nothing was closed", err.text()) + + def test_a_state_directory_it_cannot_open_leaves_a_trace(self) -> None: + missing = self.state_dir / "gone" + with unittest.mock.patch("sys.stderr", new_callable=_Capture) as err: + actions = subagent_sweep.sweep( + proc=self.proc, state_dir=missing, environ={}, + now=1000.0, kill=self.kill, + ) + + self.assertEqual([], actions) + self.assertIn("skipped", err.text()) + + def test_a_completed_pass_records_when_it_ran(self) -> None: + """The durable half of the trace: doctor reads this to find a quiet sweep.""" + write_process(self.proc, 801, worker_cmdline(), own=40) + + self.run_sweep(now=4242.0) + + stored = json.loads( + (self.state_dir / "subagent-sweep.state.json").read_text() + ) + self.assertEqual(4242.0, stored["last_pass"]) + + +class _Capture: + """A stderr stand-in that keeps what was written to it.""" + + def __init__(self) -> None: + self.chunks: list[str] = [] + + def write(self, text: str) -> int: + self.chunks.append(text) + return len(text) + + def flush(self) -> None: + pass + + def text(self) -> str: + return "".join(self.chunks) + + +def _unit_directives(name: str) -> tuple[list[str], dict[str, str]]: + """(ExecStart argv, Environment map) read out of a shipped unit file.""" + text = (REPO / "systemd" / name).read_text(encoding="utf-8") + exec_start: list[str] = [] + environment: dict[str, str] = {} + for line in text.splitlines(): + line = line.strip() + if line.startswith("ExecStart="): + exec_start = line[len("ExecStart="):].split() + elif line.startswith("Environment="): + key, _, value = line[len("Environment="):].partition("=") + environment[key] = value + return exec_start, environment + + +class TheUnitCommandLineIsTheThingThatRuns(unittest.TestCase): + """Brief item 4, proved end to end instead of by reading the unit file. + + This starts the EXACT `ExecStart=` argv from the shipped + `session-kit-subagent-sweep.service`, through the real launcher shim, in + the environment systemd builds for a user unit: the manager's own + environment plus the unit's `Environment=` lines, and nothing else. The + caller's exported variables are not passed, because systemd does not pass + them -- that is the defect being closed, not an artefact of the test. + + Boundary, stated rather than papered over: this cannot start the operator's + systemd user manager, so the manager's contribution is MEASURED instead of + assumed. `systemctl --user show-environment` on this box carries no + `SESSION_KIT_*` variable at all, so the unit's `Environment=` lines are the + entire environment this pass gets -- and the unit declares no + `SESSION_KIT_*` line, so no variable route to the timer exists. The + sandbox pins (HOME, state, proc root, testing flag) are the fixture, and + they are the only additions. + """ + + def setUp(self) -> None: + scratch = tempfile.TemporaryDirectory() + self.addCleanup(scratch.cleanup) + base = Path(scratch.name) + self.home = base / "home" + self.state_dir = base / "state" + self.proc = base / "proc" + for directory in (self.home / ".local" / "bin", self.state_dir, self.proc): + directory.mkdir(parents=True) + + # A release tree the launcher shim can resolve, laid out the way an + # install lays one out. + self.root = base / "kitroot" + release = self.root / "releases" / ("a" * 40) + release.mkdir(parents=True) + for part in ("bin", "lib"): + shutil.copytree(REPO / part, release / part, symlinks=True) + (self.root / "current").symlink_to(release) + shim = self.home / ".local" / "bin" / "shpool_reaper" + shutil.copy2(REPO / "deploy" / "session-kit-launcher", shim) + shim.chmod(0o755) + + # A pid that cannot be alive, so the pass has something to decide about + # and nothing it could ever signal. Verified, not assumed. + self.pid = int( + Path("/proc/sys/kernel/pid_max").read_text(encoding="utf-8").strip() + ) + 1 + self.assertFalse(Path("/proc", str(self.pid)).exists()) + write_process(self.proc, self.pid, worker_cmdline("unit@team"), own=40) + + def seed_state(self, *, idle_minutes: float, now: float) -> None: + """A healthy cadence at the default window, so one pass can decide.""" + cmdline = worker_cmdline("unit@team") + (self.state_dir / "subagent-sweep.state.json").write_text( + json.dumps( + { + "version": subagent_sweep.STATE_VERSION, + "tracked": { + f"{subagent_sweep._boot_id(self.proc)}:{self.pid}:1000": { + **output_state(self.proc, cmdline), + "last_active": now - idle_minutes * MINUTE, + "term_sent": False, + } + }, + "boot_id": subagent_sweep._boot_id(self.proc), + "last_pass": now - MINUTE, + "window_seconds": 15 * MINUTE, + } + ) + ) + + def run_the_unit(self) -> tuple[int, str]: + argv, unit_environment = _unit_directives( + "session-kit-subagent-sweep.service" + ) + self.assertTrue(argv, "the unit must declare an ExecStart") + self.assertFalse( + [key for key in unit_environment if key.startswith("SESSION_KIT_")], + "the unit declares no SESSION_KIT_* variable, so there is no " + "environment route to this pass at all", + ) + argv = [part.replace("%h", str(self.home)) for part in argv] + environ = { + key: value.replace("%h", str(self.home)) + for key, value in unit_environment.items() + } + environ.update( + { + "HOME": str(self.home), + "SESSION_KIT_ROOT": str(self.root), + "SESSION_KIT_STATE_DIR": str(self.state_dir), + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_PROC_ROOT": str(self.proc), + } + ) + child = subprocess.run( + argv, env=environ, capture_output=True, text=True, timeout=120 + ) + log = self.state_dir / "subagent-sweep.log" + return child.returncode, log.read_text(encoding="utf-8") if log.exists() else "" + + def test_the_unit_reaches_a_finished_worker(self) -> None: + """The control. Without it, every switch test below proves nothing.""" + self.seed_state(idle_minutes=16, now=time.time()) + + status, log = self.run_the_unit() + + self.assertEqual(0, status) + self.assertIn(str(self.pid), log) + + def test_the_window_file_reaches_the_unit_and_an_exported_variable_cannot( + self, + ) -> None: + self.seed_state(idle_minutes=16, now=time.time()) + (self.state_dir / "subagent-sweep-minutes").write_text("120\n") + # Exported in this process, exactly as they would export it in a shell. + # It is not passed to the child, because systemd does not pass it. + with unittest.mock.patch.dict( + os.environ, {"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "1"} + ): + status, log = self.run_the_unit() + + self.assertEqual(0, status) + self.assertEqual("", log, "a 120-minute window closes nothing at 16 minutes") + + def test_the_off_file_reaches_the_unit(self) -> None: + self.seed_state(idle_minutes=16, now=time.time()) + (self.state_dir / "subagent-sweep-off").write_text("") + + status, log = self.run_the_unit() + + self.assertEqual(0, status) + self.assertEqual("", log) + + def test_the_sentinel_reaches_the_unit_and_it_says_so(self) -> None: + """Their oldest brake, through the timer's own command line.""" + self.seed_state(idle_minutes=16, now=time.time()) + (self.home / ".no_shpool_reaper").write_text("") + + status, log = self.run_the_unit() + + self.assertEqual(3, status, "3 is the documented disabled-by-sentinel status") + self.assertEqual("", log) + + +class TheDoctorAndTheSweepReadTheSameWindow(unittest.TestCase): + """`session-kit doctor` reports the window; the sweep applies it. + + Doctor cannot import the module (it runs as an embedded program with the + kit's lib off its path), so it carries its own copy of the precedence + rules. Two copies of a rule drift, and a doctor that reports a window the + sweep is not using is worse than no doctor at all -- so the copies are + pinned against one matrix here. The doctor's functions are lifted out of + the shell heredoc and EXECUTED, not pattern-matched. + """ + + ENV_MATRIX = ( + ({"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "45"}, 45.0), + ({"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "0"}, 0.0), + ({"SESSION_KIT_SUBAGENT_IDLE_HOURS": "24"}, 1440.0), + ({"SESSION_KIT_SUBAGENT_IDLE_HOURS": "0"}, 0.0), + ({"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "junk"}, None), + ({"SESSION_KIT_SUBAGENT_IDLE_MINUTES": ""}, None), + ({"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "-5"}, None), + ({"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "nan"}, None), + ({"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "inf"}, None), + ({"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "1h"}, None), + ({"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "junk", + "SESSION_KIT_SUBAGENT_IDLE_HOURS": "0"}, None), + ({"SESSION_KIT_SUBAGENT_IDLE_HOURS": "6h"}, None), + ({"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "30", + "SESSION_KIT_SUBAGENT_IDLE_HOURS": "24"}, 30.0), + ) + + def doctor_readers(self, state_dir: Path) -> dict[str, object]: + """The doctor's own window readers, lifted out and made callable.""" + shell = (REPO / "lib" / "sh" / "session_kit_doctor.sh").read_text( + encoding="utf-8" + ) + block = shell.split("<<'PY'\n", 1)[1].split("\nPY\n", 1)[0] + module = ast.parse(block) + wanted = [ + node + for node in module.body + if isinstance(node, ast.FunctionDef) + and node.name + in ("sweep_usable_duration", "sweep_window_from_file", "sweep_window_from_env") + ] + self.assertEqual( + 3, len(wanted), "doctor must carry exactly these three window readers" + ) + namespace: dict[str, object] = { + "os": os, + "Path": Path, + "kit_state_root": state_dir, + } + exec( # noqa: S102 - the source is this repo's own doctor + compile(ast.Module(body=wanted, type_ignores=[]), "<doctor>", "exec"), + namespace, + ) + return namespace + + def setUp(self) -> None: + scratch = tempfile.TemporaryDirectory() + self.addCleanup(scratch.cleanup) + self.state_dir = Path(scratch.name) + self.readers = self.doctor_readers(self.state_dir) + # The sweep says why it is standing down, which is the point of it -- + # but a green suite should not print thirteen refusals. + self.noise = io.StringIO() + redirect = contextlib.redirect_stderr(self.noise) + redirect.__enter__() + self.addCleanup(redirect.__exit__, None, None, None) + + def test_the_two_readers_agree_on_every_variable(self) -> None: + for environ, expected_minutes in self.ENV_MATRIX: + with self.subTest(environ=environ): + with unittest.mock.patch.dict(os.environ, environ, clear=True): + doctor_minutes, _source = self.readers["sweep_window_from_env"]() + sweep_minutes = ( + subagent_sweep._env_idle_hours(environ, self.state_dir) * 60 + ) + # The sweep expresses "unreadable" as a window of 0, because a + # window of 0 disables it; doctor expresses it as None so it can + # name the reason. Both mean the sweep does not run. + self.assertEqual(expected_minutes, doctor_minutes) + self.assertAlmostEqual( + 0.0 if expected_minutes is None else expected_minutes, + sweep_minutes, + places=6, + ) + + def test_the_two_readers_agree_on_the_window_file(self) -> None: + for raw, expected in (("120\n", 120.0), ("0", 0.0), ("junk", None)): + with self.subTest(raw=raw): + (self.state_dir / "subagent-sweep-minutes").write_text(raw) + with unittest.mock.patch.dict(os.environ, {}, clear=True): + doctor_minutes, _source = self.readers["sweep_window_from_file"]() + self.assertEqual(expected, doctor_minutes) + self.assertAlmostEqual( + 0.0 if expected is None else expected, + subagent_sweep._env_idle_hours({}, self.state_dir) * 60, + places=6, + ) + + def test_doctor_reports_the_window_the_TIMER_gets_not_this_shell(self) -> None: + """The green line that understated the closer by eight times. + + Doctor used to resolve its reported window from `os.environ`. An + operator with `SESSION_KIT_SUBAGENT_IDLE_MINUTES=120` exported would + read `ok ... 120-minute window` while the timer -- which inherits none + of their shell -- was closing at fifteen. A wrong number on an `ok` line + is worse than no line. + """ + with unittest.mock.patch.dict( + os.environ, {"SESSION_KIT_SUBAGENT_IDLE_MINUTES": "120"}, clear=True + ): + reported, source = self.readers["sweep_window_from_file"]() + shell_only, shell_source = self.readers["sweep_window_from_env"]() + + self.assertEqual(15.0, reported) + self.assertEqual("the built-in default", source) + # And the variable is still surfaced, as the caveat it is. + self.assertEqual(120.0, shell_only) + self.assertEqual("SESSION_KIT_SUBAGENT_IDLE_MINUTES", shell_source) + + def test_one_stray_byte_cannot_blind_either_reader(self) -> None: + """`read_text` decodes strictly, and UnicodeDecodeError is a ValueError. + + An `except OSError` does not catch it, so a single non-UTF-8 byte in + this hand-editable file used to raise out of the doctor program -- and + the bash caller discards the ENTIRE extended audit when that happens, + not just this check. The same byte crashed every sweep pass. + """ + (self.state_dir / "subagent-sweep-minutes").write_bytes(b"\xff15\n") + + with unittest.mock.patch.dict(os.environ, {}, clear=True): + doctor_minutes, reason = self.readers["sweep_window_from_file"]() + + self.assertIsNone(doctor_minutes) + self.assertIn("subagent-sweep-minutes", reason) + # And the sweep itself refuses rather than raising, which closes nothing. + self.assertEqual( + 0.0, subagent_sweep._env_idle_hours({}, self.state_dir) + ) + + +class TheSweepCannotReachAProcessHeStarted(SweepHarness): + """Round 3, lane finding 4 -- destroy class 1, reproduced then closed. + + The old rule accepted ANY executable with a path component named `claude` + (`"claude" in executable.parts`) plus the two generic flags. A review lane + launched a real sleeper with that argv shape and the isolated sweep sent it + SIGTERM after sixteen minutes. A tool of the operator's, in a path + containing `claude`, run with those flags, was closed automatically. + + Pre-existing at the base -- and in scope anyway, because this branch runs + that closer twelve times as often, which multiplies the exposure of an + inherited wrong-target kill. + """ + + HIS_OWN_TOOL = "/home/user/fixture/claude/operator-tool.js" + + def test_the_predicate_refuses_the_operators_tool(self) -> None: + argv = [self.HIS_OWN_TOOL, "--agent-id", "operator-job", + "--parent-session-id", "manual"] + self.assertFalse(subagent_sweep._is_worker(argv)) + + def test_the_sweep_never_selects_the_operators_tool(self) -> None: + write_process(self.proc, 950, [self.HIS_OWN_TOOL, "--agent-id", + "operator-job", "--parent-session-id", + "manual"], own=40) + self.assertEqual([], subagent_sweep.find_subagents(self.proc)) + + def test_the_sweep_never_CLOSES_the_operators_tool(self) -> None: + """End to end, past the window the lane watched it die at.""" + write_process(self.proc, 950, [self.HIS_OWN_TOOL, "--agent-id", + "operator-job", "--parent-session-id", + "manual"], own=40) + + actions = self.sweep_until(60 * MINUTE) + + self.assertEqual([], actions) + self.assertEqual([], self.kills, "the lane watched this get SIGTERM") + + def test_and_a_real_worker_beside_it_is_still_closed(self) -> None: + """The control. Tightening that closed everything would also pass. + + Both processes are idle past the window; exactly one is the provider. + """ + write_process(self.proc, 950, [self.HIS_OWN_TOOL, "--agent-id", + "operator-job", "--parent-session-id", + "manual"], own=40) + write_process(self.proc, 951, worker_cmdline("real@team"), own=40) + + actions = self.sweep_until(60 * MINUTE) + + self.assertEqual(["SIGTERM"], [a["signal"] for a in actions]) + self.assertEqual([(951, signal.SIGTERM)], self.kills) + + def test_every_shape_the_provider_ACTUALLY_ships_is_still_selected(self) -> None: + """Measured on the live process table 2026-08-15, not assumed. + + All fourteen genuine workers ran as + `/home/<user>/.local/share/claude/versions/2.1.233` -- the version + DIRECTORY is the executable. Requiring the name `claude` would have + refused every one of them and quietly retired the sweep, so this pins + the real shapes against exactly that mistake. + """ + shapes = [ + "/home/user/.local/share/claude/versions/2.1.233", # the live one + "/home/user/.local/share/claude/versions/2.1.231", # older version + "/home/user/.local/share/claude/versions/2.1.231/claude", + "/usr/local/bin/claude", + "claude", + ] + for pid, executable in enumerate(shapes, start=960): + write_process( + self.proc, + pid, + [executable, "--agent-id", f"w{pid}@t", "--parent-session-id", "p"], + own=5, + ) + + found = subagent_sweep.find_subagents(self.proc) + + self.assertEqual(len(shapes), len(found), "a real worker shape was lost") + + +class ADecisionFromAnotherBootIsNeverInherited(SweepHarness): + """Round 3, Codex 1/3: `pid:start_ticks` is not unique across a reboot. + + Start ticks are counted FROM BOOT, so the same pair names a different + process on the other side of one. A persisted `term_sent` inherited by that + stranger is an immediate SIGKILL of a brand-new worker -- the lane + reproduced exactly that, delivering SIGKILL to `brand-new@team`. + + The whole-document boot check discards a previous boot's decisions. This + pins the SECOND guard: the boot is in the state key, so an entry that did + not come from this boot cannot even be looked up if the first check is ever + defeated -- an unreadable boot id, a hand-edited file, a future edit to the + re-arm chain. + """ + + def boot(self, value: str) -> None: + random = self.proc / "sys" / "kernel" / "random" + random.mkdir(parents=True, exist_ok=True) + (random / "boot_id").write_text(value + "\n") + + def test_a_term_from_a_previous_boot_cannot_become_a_kill(self) -> None: + self.boot("boot-B") + now = 10_000.0 + write_process(self.proc, 900, worker_cmdline("brand-new@team"), own=40) + snapshot = output_state(self.proc, worker_cmdline("brand-new@team")) + # The document says this boot -- so the reboot check is SILENT here on + # purpose, and only the key binding can save the new worker. The entry + # is keyed the way the previous boot keyed it, carrying a TERM that was + # delivered to a DIFFERENT process that happened to hold pid 900. + (self.state_dir / "subagent-sweep.state.json").write_text( + json.dumps( + { + "version": subagent_sweep.STATE_VERSION, + "tracked": { + "900:1000": { + **snapshot, + "last_active": now - 30 * MINUTE, + "term_sent": True, + } + }, + "boot_id": "boot-B", + "last_pass": now - MINUTE, + "window_seconds": 15 * MINUTE, + } + ) + ) + + actions = self.run_sweep(now=now) + + self.assertEqual([], actions, "a stranger inherited a TERM and was KILLED") + self.assertEqual([], self.kills) + + def test_the_key_carries_the_boot(self) -> None: + self.boot("boot-A") + write_process(self.proc, 901, worker_cmdline(), own=40) + + self.run_sweep(now=1000.0) + + stored = json.loads( + (self.state_dir / "subagent-sweep.state.json").read_text() + ) + self.assertEqual(["boot-A:901:1000"], list(stored["tracked"])) + + def test_an_actual_reboot_still_discards_everything(self) -> None: + """The first guard, unchanged, proved beside the second.""" + self.boot("boot-A") + write_process(self.proc, 902, worker_cmdline(), own=40) + self.assertEqual([], self.run_sweep(now=1000.0)) + self.boot("boot-B") + + actions = self.run_sweep(now=1000.0 + 30 * MINUTE) + + self.assertEqual([], actions) + self.assertEqual([], self.kills) + + +class EveryTestInThisRepositoryActuallyRuns(unittest.TestCase): + """Lane B F6, generalised: a test that does not run is not a test. + + `tests/run` uses discovery and finds everything, so the whole suite looks + green -- while anyone running one file directly silently loses every class + defined after its `if __name__ == "__main__":` block. On this branch that + was sixteen of this module's fifty-three tests, and it was exactly the + sixteen guarding the off switch, the install-day rule, and the window + parsing. This is the same family as the trap that failed five branches, so + it is checked for every test module rather than for this one. + """ + + def test_no_test_class_is_defined_after_the_main_block(self) -> None: + offenders: list[str] = [] + for path in sorted((REPO / "tests").glob("*.py")): + module = ast.parse(path.read_text(encoding="utf-8")) + main_line = None + for node in module.body: + if ( + isinstance(node, ast.If) + and isinstance(node.test, ast.Compare) + and isinstance(node.test.left, ast.Name) + and node.test.left.id == "__name__" + ): + main_line = node.lineno + if main_line is None: + continue + stranded = [ + node.name + for node in module.body + if isinstance(node, (ast.ClassDef, ast.FunctionDef)) + and node.lineno > main_line + ] + if stranded: + offenders.append(f"{path.name}: {', '.join(stranded)}") + self.assertEqual( + [], + offenders, + "these are skipped by `python3 <file>` and only run under " + "discovery: " + "; ".join(offenders), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tab_ifs_rule.py b/tests/test_tab_ifs_rule.py new file mode 100644 index 0000000..b0936c7 --- /dev/null +++ b/tests/test_tab_ifs_rule.py @@ -0,0 +1,179 @@ +"""The tab-IFS review rule, enforced instead of remembered. + +The rule, in one line: **`IFS=$'\t' read` on a record with a possibly-empty +field is the husk bug again.** Tab is IFS whitespace, so a run of tabs +collapses to one separator and every empty field between them disappears, +the fields after it shift left, and a record armed perfectly is read as +garbage. The kit lost three launches in one night to exactly that, and the +fix is the `\034` translation idiom in `bashrc/shpool.bashrc` (translate tabs +to a non-whitespace delimiter first, then split on that). + +This test does not ban the read. It bans an *unreviewed* one: every site is +listed below with the reason it is safe, or listed as an open husk with the +repro that proves it is not. A new site fails until somebody writes down which +it is. `CONTRIBUTING.md` carries the rule in prose. +""" + +from __future__ import annotations + +import re +import subprocess +import unittest + +from tests.support import REPO + + +# `IFS=$'\t' read`, the whole class, in one greppable pattern. +TAB_READ = re.compile(r"IFS=\$'\\t'\s+read\s+(?:-[A-Za-z]+\s+)*(?P<first>\w+)") + +# Where shell lives. Extensionless helpers are included by name. +SHELL_GLOBS = ( + "*.sh", + "*.bashrc", + "bin/*", + "bashrc/*", + "tools/*", + "deploy/*", + "install.sh", + "tests/run", +) + +# Each site is keyed by (path, the first variable the read fills), which +# survives edits above it in the file the way a line number does not. +REVIEWED_SAFE = { + ("bashrc/shpool.bashrc", "__sk_start_provider"): ( + "start record: only the provider is read for meaning and it is never " + "empty (the writer validates claude|codex|shell); the rest of the line " + "lands in one catch-all" + ), + ("bashrc/shpool.bashrc", "__sk_account_provider"): ( + "account record: two fields, both validated non-empty by the regexes " + "immediately below the read" + ), + ("bin/session_kit_watchdog", "_"): ( + "automatic-move receipt: the writer always fills all seven fields, and " + "the provider, conversation UUID and token are rejected if empty" + ), + ("bin/session_kit_watchdog", "provider"): ( + "pending-notice row: provider, conversation UUID and token are always " + "non-empty; only the final sentence may be empty, so no later field shifts" + ), + ("lib/sh/sp_provider_bounce.sh", "refresh_pid"): ( + "two numeric fields from the platform helper, both checked against " + "^[0-9]+$ after the read" + ), + ("lib/sh/sp_picker.sh", "signal_pid"): ( + "two numeric fields from a proof record, both validated after the read" + ), + ("lib/sh/session_kit_doctor.sh", "linger_status"): ( + "status and detail; only the trailing detail can be empty, and a " + "trailing empty field survives the collapse unchanged" + ), + ("lib/sh/session_kit_doctor.sh", "shpool_status"): ( + "status and detail; only the trailing detail can be empty" + ), + ("lib/sh/session_kit_doctor.sh", "status"): ( + "check rows written by add_check, which always fills all three fields; " + "the reader also rejects a row with an empty detail" + ), + ("lib/sh/session_kit_checks.sh", "shpool_status"): ( + "status and detail; only the trailing detail can be empty" + ), + ("lib/sh/session_kit_checks.sh", "linger_status"): ( + "status and detail; only the trailing detail can be empty" + ), + ("lib/sh/shpool_login_render.sh", "section"): ( + "help table generated in the same file; section and key are never " + "empty and the loop skips a row without a key" + ), + ("lib/sh/shpool_login_actions.sh", "token"): ( + "repair index rows: token and index, both non-empty by construction " + "and both validated in the loop" + ), + ("lib/sh/shpool_login_actions.sh", "number"): ( + "attention index rows: two numeric fields, both validated in the loop" + ), + ("tools/install-matrix", "distro"): ( + "matrix summary written three fields at a time by the same tool; no " + "field can be empty" + ), +} + +# Sites that ARE the husk, with the evidence. Listed so the guardrail stays +# green while the owner fixes them, and so a fix cannot be forgotten: the +# staleness test below fails the day an entry stops matching. +OPEN_HUSKS: dict[tuple[str, str], str] = {} + + +def shell_files(): + listing = subprocess.run( + ["git", "-C", str(REPO), "ls-files", *SHELL_GLOBS], + capture_output=True, + text=True, + check=True, + ).stdout.split() + for name in sorted(set(listing)): + path = REPO / name + if not path.is_file(): + continue + try: + text = path.read_text(encoding="utf-8") + except (UnicodeDecodeError, OSError): + continue + yield name, text + + +def tab_reads(): + """Every `IFS=$'\t' read` in shell code, keyed by file and first variable.""" + found = {} + for name, text in shell_files(): + for number, line in enumerate(text.splitlines(), start=1): + if line.lstrip().startswith("#"): + continue # the comment explaining the bug is not the bug + match = TAB_READ.search(line) + if match: + found.setdefault((name, match.group("first")), number) + return found + + +class TabIfsRuleTests(unittest.TestCase): + def test_every_tab_read_is_reviewed(self) -> None: + found = tab_reads() + known = set(REVIEWED_SAFE) | set(OPEN_HUSKS) + unreviewed = sorted(key for key in found if key not in known) + self.assertEqual( + [], + unreviewed, + "New `IFS=$'\\t' read` site(s):\n" + + "\n".join(f" {path}:{found[(path, var)]} ({var})" for path, var in unreviewed) + + "\n\nTab is IFS whitespace: a run of tabs collapses and empty fields\n" + "vanish. Either translate tabs to \\034 first (see the launch path in\n" + "bashrc/shpool.bashrc), or add the site to REVIEWED_SAFE in\n" + "tests/test_tab_ifs_rule.py with the reason no field can be empty.", + ) + + def test_the_review_list_is_not_stale(self) -> None: + found = tab_reads() + gone = sorted(key for key in set(REVIEWED_SAFE) | set(OPEN_HUSKS) if key not in found) + self.assertEqual( + [], + gone, + "Reviewed site(s) no longer present; delete the entry:\n" + + "\n".join(f" {path} ({var})" for path, var in gone), + ) + + def test_every_reviewed_site_carries_a_reason(self) -> None: + for key, reason in {**REVIEWED_SAFE, **OPEN_HUSKS}.items(): + with self.subTest(site=key): + self.assertGreater(len(reason.split()), 6, f"{key} needs a real reason") + + def test_the_launch_path_still_translates_before_it_splits(self) -> None: + """The idiom the rule points at has to exist to be pointed at.""" + text = (REPO / "bashrc" / "shpool.bashrc").read_text(encoding="utf-8") + self.assertIn("//$'\\t'/$'\\034'", text) + self.assertIn("IFS=$'\\034' read -r __sk_launch_provider", text) + self.assertIn("IFS=$'\\034' read -r __sk_provider", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tab_title_ownership.py b/tests/test_tab_title_ownership.py new file mode 100644 index 0000000..05f7602 --- /dev/null +++ b/tests/test_tab_title_ownership.py @@ -0,0 +1,733 @@ +"""The kit owns the terminal tab name, on both providers (K3). + +The design rests on one measurement, made live on 2026-08-13 against Claude +Code 2.1.229 with ``tools/title-ownership-drill``: +``CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1`` does not silence only the vendor's own +titles, it silences the window's title writes entirely -- the kit hook's +``sessionTitle`` and a hook-emitted ``terminalSequence`` included. With the +switch unset both mechanisms produced ``ESC]0;<glyph> <kit title>BEL``; with it +set the same session wrote no title sequence at all. + +So the kit never sets that variable, writes the tab name itself at the moments +no provider hook fires, and hands Codex the item list it deployed. These tests +pin all three halves plus the single kill switch. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import pty +import re +import shutil +import subprocess +import tempfile + +try: + import tomllib +except ImportError: # Python 3.10 is supported and has no tomllib. + tomllib = None # type: ignore[assignment] +import unittest + +from tests.support import REPO + + +# Item identifiers codex-cli 0.145.0 accepts for tui.terminal_title, enumerated +# live on 2026-08-13, one id per run, with its own doctor as the oracle: an id +# it does not know produces `terminal title invalid items "<id>"` and the Notes +# line `terminal title configuration contains unknown item identifiers`, and an +# accepted id produces `✓ title configured`. Raw doctor output for every id is +# archived beside the drill evidence. +# +# `cwd`, `account` and `none` are NOT here on purpose: an earlier pass listed +# them as valid because its probe treated "the error string is absent" as +# acceptance, so a run that ended early read as a pass. Codex rejects all three. +# Any future change to this set is re-probed the same way, with the doctor +# output kept, and an inconclusive run is never read as acceptance. +CODEX_TITLE_ITEMS = { + "spinner", + "activity", + "thread", + "thread-title", + "project", + "project-name", + "model", + "model-with-reasoning", + "git-branch", + "context-used", + "weekly-limit", + "five-hour-limit", +} +CODEX_TITLE_ITEMS_REJECTED = {"cwd", "account", "none"} + +OSC_TITLE = re.compile(rb"\x1b\]0;([^\x07]*)\x07") + +SHIPPED_TREES = ("bin", "lib", "bashrc", "config", "deploy", "systemd", "macos") + + +def run_on_pty(script: str, env: dict[str, str] | None = None) -> bytes: + """Run a bash snippet with a real terminal on stdout, return what it wrote. + + ``sk_tab_title`` deliberately refuses to write when stdout is not a + terminal, so a plain pipe would prove nothing. + """ + parent, child = pty.openpty() + environment = dict(os.environ) + environment.setdefault("SESSION_KIT_CODEX_AUTOTITLE", "0") + environment.update(env or {}) + process = subprocess.Popen( + ["bash", "-c", script], + stdin=subprocess.DEVNULL, + stdout=child, + stderr=subprocess.DEVNULL, + env=environment, + ) + os.close(child) + collected = b"" + try: + while True: + try: + chunk = os.read(parent, 65536) + except OSError: + break + if not chunk: + break + collected += chunk + finally: + process.wait(timeout=30) + os.close(parent) + return collected + + +def drill_module(): + """Load the drill script (no .py suffix) as a module.""" + import importlib.machinery + import importlib.util + import sys + + if "sk_title_drill" in sys.modules: + return sys.modules["sk_title_drill"] + path = REPO / "tools" / "title-ownership-drill" + loader = importlib.machinery.SourceFileLoader("sk_title_drill", os.fspath(path)) + spec = importlib.util.spec_from_loader(loader.name, loader) + assert spec is not None + module = importlib.util.module_from_spec(spec) + sys.modules[loader.name] = module + loader.exec_module(module) + return module + + +def drill_run(mode: str, *, marker: bool, titles: int = 1) -> dict: + written = [f"✳ {'SKDRILLMARKER' if marker else 'something else'}-{mode}"] + return { + "mode": mode, + "disable_env": mode.endswith("-off"), + "hook_events_seen": ["SessionStart"], + "titles_written": written[:titles], + "marker_present": marker and titles > 0, + "vendor_titles": [], + "capture": f"pty-{mode}.bin", + "capture_bytes": 3700, + } + + +class DrillVerdictTests(unittest.TestCase): + """Each verdict cites the run it came from. + + The drill is the instrument for re-answering item 18 after a vendor + upgrade. An earlier version read the terminalSequence verdict out of the + switch-UNSET run, so it printed "survives" while its own switch-set capture + held no title at all. + """ + + def verdict(self, runs: list) -> tuple[str, int]: + import contextlib + import io + + module = drill_module() + out = io.StringIO() + with contextlib.redirect_stdout(out): + status = module.report(runs, Path("/tmp/evidence")) + return out.getvalue(), status + + def test_a_suppressed_sequence_is_never_reported_as_surviving(self) -> None: + text, status = self.verdict( + [ + drill_run("title-on", marker=True), + drill_run("title-off", marker=False, titles=0), + drill_run("sequence-on", marker=True), + drill_run("sequence-off", marker=False, titles=0), + ] + ) + self.assertEqual(0, status) + self.assertIn("terminalSequence: control sequence-on", text) + self.assertIn("sequence-off wrote 0, marker suppressed", text) + self.assertNotIn("marker survives", text) + self.assertIn("suppresses the kit hook title too", text) + + def test_a_surviving_sequence_is_reported_from_its_own_run(self) -> None: + text, status = self.verdict( + [ + drill_run("title-on", marker=True), + drill_run("title-off", marker=False, titles=0), + drill_run("sequence-on", marker=True), + drill_run("sequence-off", marker=True), + ] + ) + self.assertEqual(0, status) + self.assertIn("terminalSequence:", text) + self.assertIn("marker survives", text) + self.assertIn("suppresses some kit titles but not all", text) + self.assertIn("surviving: terminalSequence", text) + + def test_a_control_that_wrote_nothing_is_inconclusive(self) -> None: + text, status = self.verdict( + [ + drill_run("title-on", marker=True), + drill_run("title-off", marker=False, titles=0), + drill_run("sequence-on", marker=False, titles=0), + drill_run("sequence-off", marker=False, titles=0), + ] + ) + self.assertEqual(2, status) + self.assertIn("inconclusive", text) + self.assertIn("sequence-on", text) + + def test_the_borrowed_credential_is_deleted_even_on_failure(self) -> None: + source = (REPO / "tools" / "title-ownership-drill").read_text(encoding="utf-8") + body = source.split("def run_variant(", 1)[1].split("\ndef ", 1)[0] + self.assertIn("finally:", body) + self.assertIn("borrowed.unlink()", body) + # The unlink has to sit in the finally, not before it. + self.assertLess(body.index("finally:"), body.index("borrowed.unlink()")) + + +class TabTitleOwnershipTests(unittest.TestCase): + def test_kit_never_sets_the_claude_title_off_switch(self) -> None: + """The switch that silences the vendor also silences us. + + Turning it on would leave every kit-launched Claude window with a tab + the kit cannot write either -- the exact failure the drill measured. + Documentation and the drill itself may name the variable; nothing that + ships may set it. + """ + offenders: list[str] = [] + pattern = re.compile( + r"(?:export\s+|^\s*|;\s*|&&\s*|\benv\s+)CLAUDE_CODE_DISABLE_TERMINAL_TITLE\s*=" + ) + for tree in SHIPPED_TREES: + root = REPO / tree + if not root.is_dir(): + continue + for path in sorted(root.rglob("*")): + if not path.is_file() or path.is_symlink(): + continue + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + for number, line in enumerate(text.splitlines(), 1): + if pattern.search(line): + offenders.append(f"{path.relative_to(REPO)}:{number}") + self.assertEqual( + offenders, + [], + "CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 silences the kit's own title " + "hook too (drill, 2026-08-13, Claude Code 2.1.229). Set here: " + + ", ".join(offenders), + ) + + def test_tab_title_writes_the_name_as_an_osc_sequence(self) -> None: + written = run_on_pty( + f'source "{REPO}/bin/session_kit_common"; sk_tab_title "Log Review"' + ) + self.assertIn(b"\x1b]0;Log Review\x07", written) + + def test_tab_title_kill_switch_writes_nothing(self) -> None: + written = run_on_pty( + f'source "{REPO}/bin/session_kit_common"; sk_tab_title "Log Review"', + env={"SESSION_KIT_TAB_TITLE": "off"}, + ) + self.assertEqual(OSC_TITLE.findall(written), []) + + def test_tab_title_scrubs_control_bytes_and_bounds_the_name(self) -> None: + """A session title is provider metadata; it can carry terminal commands.""" + written = run_on_pty( + f'source "{REPO}/bin/session_kit_common"; ' + "sk_tab_title \"$(printf 'ev\\007il\\033]0;pwned\\007 %s' " + "'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')\"" + ) + titles = OSC_TITLE.findall(written) + self.assertEqual(len(titles), 1, written) + self.assertNotIn(b"\x1b", titles[0]) + self.assertNotIn(b"\x07", titles[0]) + self.assertLessEqual(len(titles[0]), 64) + + def test_tab_title_stays_silent_without_a_terminal(self) -> None: + result = subprocess.run( + [ + "bash", + "-c", + f'source "{REPO}/bin/session_kit_common"; sk_tab_title "Name"', + ], + capture_output=True, + ) + self.assertEqual(result.stdout, b"") + + def test_attach_puts_the_kit_name_on_the_tab(self) -> None: + """`sp go`, `sp takeover` and a fresh `sp new` all end in attach_id.""" + source = (REPO / "lib" / "sh" / "sp_core.sh").read_text(encoding="utf-8") + attach = source.split("attach_id() {", 1)[1].split("\n}", 1)[0] + self.assertIn("sk_tab_title", attach) + self.assertIn("SK_TITLE", attach) + + def test_no_surface_writes_a_title_behind_the_kill_switch(self) -> None: + """One writer, so one switch covers every surface. + + The login picker used to write the tab with its own `printf ESC]0;`, + which honoured neither the kill switch nor the scrub -- so the docs + claimed a coverage the code did not have. + """ + offenders: list[str] = [] + for tree in ("bin", "lib", "bashrc"): + for path in sorted((REPO / tree).rglob("*")): + if not path.is_file() or path.is_symlink(): + continue + if path.name == "session_kit_common": + continue # the one writer + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + for number, line in enumerate(text.splitlines(), 1): + if "\\033]0;" in line or "\\e]0;" in line or "\\x1b]0;" in line: + offenders.append(f"{path.relative_to(REPO)}:{number}") + self.assertEqual( + offenders, [], "raw tab-title writes bypass the kill switch and the scrub" + ) + + +class TabTitleSurfaceTests(unittest.TestCase): + """Every way into a session writes the kit's name, on both providers. + + These drive the real shell functions on a real pty behind stubs, rather + than reading the source: a claim about which surfaces are covered is worth + exactly as much as the run that shows it. + """ + + def surface_script( + self, base: Path, *, function: str, provider: str, title: str, number: str + ) -> str: + stub = base / "fake-shpool" + stub.write_text("#!/usr/bin/env bash\nexit 0\n") + stub.chmod(0o755) + core = base / "fake-core" + core.write_text("#!/usr/bin/env bash\nexit 0\n") + core.chmod(0o755) + state = base / "state" + state.mkdir(exist_ok=True) + return ( + f'export SESSION_KIT_SHPOOL_CMD="{stub}"\n' + f'export SESSION_KIT_INVENTORY_CORE="{core}"\n' + f'export SESSION_KIT_STATE_DIR="{state}"\n' + f'source "{REPO}/bin/session_kit_common"\n' + f'INVENTORY_CORE="{core}"\n' + f'source "{REPO}/lib/sh/sp_core.sh"\n' + f'source "{REPO}/lib/sh/sp_picker.sh"\n' + f'SK_TITLE="{title}"; SK_PROVIDER="{provider}"; SK_NUMBER="{number}"\n' + f'SK_PROOF_PROVIDER="{provider}"; SK_PROOF_UUID=""\n' + f"{function} drill-session /tmp\n" + ) + + def run_surface(self, **kwargs) -> list[bytes]: + env = kwargs.pop("env", None) + with tempfile.TemporaryDirectory(prefix=".tab-surface-", dir=REPO) as raw: + written = run_on_pty(self.surface_script(Path(raw), **kwargs), env=env) + return OSC_TITLE.findall(written) + + def test_open_and_attach_write_the_name_on_both_providers(self) -> None: + for provider in ("claude", "codex"): + with self.subTest(provider=provider, surface="sp go / sp new / attach"): + titles = self.run_surface( + function="attach_id", + provider=provider, + title="Log Review", + number="3", + ) + self.assertEqual([b"session 3 (Log Review)"], titles) + + def test_picker_open_and_takeover_write_the_name_on_both_providers(self) -> None: + for provider in ("claude", "codex"): + with self.subTest(provider=provider, surface="picker open / takeover"): + titles = self.run_surface( + function="picker_attach_id", + provider=provider, + title="Log Review", + number="3", + ) + self.assertEqual([b"session 3 (Log Review)"], titles) + + def test_an_unnamed_session_still_gets_a_tab_it_can_be_found_by(self) -> None: + titles = self.run_surface( + function="attach_id", provider="codex", title="", number="7" + ) + self.assertEqual([b"session 7"], titles) + + def test_rename_writes_no_tab_title(self) -> None: + """Renaming is done from a different terminal than the session's. + + `sp name` runs at a prompt, not inside the window it renames, so a tab + write there would label the wrong terminal. The name reaches the live + window through the provider's own hook and, when that cannot apply it, + through the one safe restart. Completing the surface matrix: this is + the moment the kit deliberately does NOT write. + """ + commands = (REPO / "lib" / "sh" / "sp_commands.sh").read_text(encoding="utf-8") + rename = commands.split("name_target() {", 1)[1].split("\n}\n", 1)[0] + self.assertNotIn("sk_tab_title", rename) + self.assertIn("sk_refresh_provider_title", rename) + + def test_restore_writes_no_tab_title(self) -> None: + """A restore ends at the picker or a prompt, not inside the session.""" + sessions = (REPO / "lib" / "sh" / "sp_sessions.sh").read_text(encoding="utf-8") + restore = sessions.split("restore_exact() {", 1)[1] + self.assertNotIn("sk_tab_title", restore) + # What it does instead: both identity writes before the launch. + self.assertIn('"$INVENTORY_CORE" alias push', restore) + self.assertIn('"$INVENTORY_CORE" color propagate', restore) + + def test_the_kill_switch_covers_every_surface(self) -> None: + for function in ("attach_id", "picker_attach_id"): + with self.subTest(function=function): + titles = self.run_surface( + function=function, + provider="claude", + title="Log Review", + number="3", + env={"SESSION_KIT_TAB_TITLE": "off"}, + ) + self.assertEqual([], titles) + + def test_the_kill_switch_is_read_the_same_way_everywhere(self) -> None: + """Doctor calls it off case-insensitively; so does the writer.""" + for value in ("off", "OFF", "Off", " off "): + with self.subTest(value=value): + written = run_on_pty( + f'source "{REPO}/bin/session_kit_common"; sk_tab_title "Name"', + env={"SESSION_KIT_TAB_TITLE": value}, + ) + self.assertEqual([], OSC_TITLE.findall(written)) + + def test_a_crash_reopen_carries_the_same_title_items(self) -> None: + """Otherwise that one window repaints its tab from the personal config. + + A Codex provider that crashes is reopened in place, and the reopen + builds its own command line. Without the kit's items, Codex writes the + personal config's title over the name attach_id had just set, and that + window disagrees with the picker until it is closed and opened again. + """ + import importlib.util + import sys + + if "session_inventory" in sys.modules: + core = sys.modules["session_inventory"] + else: + spec = importlib.util.spec_from_file_location( + "session_inventory", REPO / "lib" / "session_inventory.py" + ) + assert spec is not None and spec.loader is not None + core = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = core + spec.loader.exec_module(core) + + with tempfile.TemporaryDirectory(prefix=".reopen-title-", dir=REPO) as raw: + codex_home = Path(raw) / "codex" + (codex_home / "session-kit").mkdir(parents=True, mode=0o700) + environment = {"SESSION_KIT_CODEX_HOME": os.fspath(codex_home)} + # No template deployed: the built-in value. + self.assertEqual( + '["activity", "thread"]', core._codex_title_items(environment) + ) + # A deployed template decides instead. + (codex_home / "session-kit" / "terminal-title.toml").write_text( + '[tui]\nterminal_title = ["thread", "project"]\n', encoding="utf-8" + ) + self.assertEqual( + '["thread", "project"]', core._codex_title_items(environment) + ) + # A damaged template falls back rather than breaking the reopen. + (codex_home / "session-kit" / "terminal-title.toml").write_text( + '[tui]\nterminal_title = ["thread]\n', encoding="utf-8" + ) + self.assertEqual( + '["activity", "thread"]', core._codex_title_items(environment) + ) + # And the kill switch reaches this path too. + self.assertEqual( + "", + core._codex_title_items( + {**environment, "SESSION_KIT_TAB_TITLE": "off"} + ), + ) + + source = (REPO / "lib" / "session_inventory.py").read_text(encoding="utf-8") + reopen = source.split('lifecycle_action == "reopen"', 1)[1] + self.assertIn("tui.terminal_title=", reopen.split("subprocess.run", 1)[0]) + + def test_the_login_picker_restores_its_own_name_through_the_writer(self) -> None: + actions = (REPO / "lib" / "sh" / "shpool_login_actions.sh").read_text( + encoding="utf-8" + ) + self.assertIn('sk_tab_title "$title"', actions) + self.assertIn('sk_tab_title "session kit"', actions) + + def test_a_c1_control_byte_cannot_end_the_title_string(self) -> None: + """0x9C is ST and 0x9B is CSI: C1 bytes are terminal commands too.""" + written = run_on_pty( + f'source "{REPO}/bin/session_kit_common"; ' + "sk_tab_title \"$(printf 'A\\234\\2330;pwned\\234 B')\"" + ) + titles = OSC_TITLE.findall(written) + self.assertEqual(1, len(titles), written) + for byte in (0x9C, 0x9B, 0x1B, 0x07): + self.assertNotIn(bytes([byte]), titles[0]) + + def test_codex_launch_carries_the_kit_title_override(self) -> None: + bashrc = (REPO / "bashrc" / "shpool.bashrc").read_text(encoding="utf-8") + # Command lines wrap; judge each whole command, not each screen line. + joined = bashrc.replace("\\\n", " ") + invocations = [ + line + for line in joined.splitlines() + if "__sk_codex_theme[@]" in line and "codex " in line + ] + self.assertTrue(invocations) + for line in invocations: + self.assertIn( + "__sk_codex_title[@]", + line, + "every kit Codex launch passes the kit-owned title items", + ) + + def test_the_item_set_excludes_what_codex_rejects(self) -> None: + """Three ids Codex rejects were once listed here as valid.""" + self.assertTrue(CODEX_TITLE_ITEMS.isdisjoint(CODEX_TITLE_ITEMS_REJECTED)) + doctor = (REPO / "lib" / "sh" / "session_kit_doctor.sh").read_text( + encoding="utf-8" + ) + shipped = doctor.split("CODEX_TITLE_ITEMS = {", 1)[1].split("}", 1)[0] + shipped_items = { + piece.strip().strip('"').strip("'") + for piece in shipped.replace("\n", " ").split(",") + if piece.strip() + } + self.assertEqual(CODEX_TITLE_ITEMS, shipped_items) + for rejected in CODEX_TITLE_ITEMS_REJECTED: + self.assertNotIn(rejected, shipped_items) + + @unittest.skipIf(tomllib is None, "tomllib arrived in Python 3.11") + def test_deployed_template_names_only_items_codex_accepts(self) -> None: + template = REPO / "config" / "codex" / "terminal-title.toml" + parsed = tomllib.loads(template.read_text(encoding="utf-8")) + items = parsed["tui"]["terminal_title"] + self.assertTrue(items) + for item in items: + self.assertIn(item, CODEX_TITLE_ITEMS) + self.assertIn("thread", items, "the tab has to carry the kit's name") + + def launcher_parse(self, text: str) -> subprocess.CompletedProcess: + """Run the launcher's own extraction against one template.""" + bashrc = (REPO / "bashrc" / "shpool.bashrc").read_text(encoding="utf-8") + program = bashrc.split("<<'SKTITLE' 2>/dev/null\n", 1)[1].split("SKTITLE", 1)[0] + with tempfile.NamedTemporaryFile( + "w", suffix=".toml", dir=REPO, delete=False + ) as handle: + handle.write(text) + path = handle.name + try: + return subprocess.run( + ["python3", "-c", program, path], capture_output=True, text=True + ) + finally: + os.unlink(path) + + def test_a_malformed_template_never_reaches_the_command_line(self) -> None: + """`["thread]` passes any regex and stops Codex from starting.""" + for broken in ( + 'terminal_title = ["thread]\n', + '[tui]\nterminal_title = ["thread]\n', + '[tui]\nterminal_title = "thread"\n', + "[tui]\nterminal_title = []\n", + '[tui]\nterminal_title = ["thread", 7]\n', + '[tui]\nterminal_title = ["thread; rm -rf /"]\n', + '[tui]\nterminal_title = ["THREAD"]\n', + "not toml at all\n", + ): + with self.subTest(template=broken.strip()[:40]): + result = self.launcher_parse(broken) + self.assertNotEqual(0, result.returncode, result.stdout) + self.assertEqual("", result.stdout.strip()) + + def test_a_good_template_is_re_emitted_canonically(self) -> None: + result = self.launcher_parse( + "# a comment\n[tui]\nterminal_title = [ 'activity' , 'thread' ]\n" + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual('["activity", "thread"]', result.stdout.strip()) + + def test_the_shipped_template_parses_through_the_launcher(self) -> None: + result = self.launcher_parse( + (REPO / "config" / "codex" / "terminal-title.toml").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual('["activity", "thread"]', result.stdout.strip()) + + def test_launcher_reads_the_deployed_template(self) -> None: + bashrc = (REPO / "bashrc" / "shpool.bashrc").read_text(encoding="utf-8") + self.assertIn("session-kit/terminal-title.toml", bashrc) + self.assertIn("import tomllib", bashrc) + + @unittest.skipIf(tomllib is None, "tomllib arrived in Python 3.11") + def test_installer_deploys_the_template_without_touching_config_toml(self) -> None: + with tempfile.TemporaryDirectory(prefix=".tab-install-", dir=REPO) as raw: + base = Path(raw) + release = base / "install" / "releases" / "r1" + (release / "config" / "codex").mkdir(parents=True) + shutil.copyfile( + REPO / "config" / "codex" / "terminal-title.toml", + release / "config" / "codex" / "terminal-title.toml", + ) + codex_home = base / "codex" + codex_home.mkdir(mode=0o700) + personal = codex_home / "config.toml" + personal.write_text('[tui]\nterminal_title = ["spinner", "cwd"]\n') + before = personal.read_text() + script = ( + f'install_root="{base / "install"}"\n' + f'source "{REPO}/lib/sh/session_kit_install.sh"\n' + "install_codex_terminal_title r1\n" + ) + subprocess.run( + ["bash", "-c", script], + check=True, + capture_output=True, + env={ + **os.environ, + "SESSION_KIT_CODEX_HOME": os.fspath(codex_home), + "HOME": os.fspath(base), + }, + ) + deployed = codex_home / "session-kit" / "terminal-title.toml" + self.assertTrue(deployed.is_file()) + self.assertEqual( + tomllib.loads(deployed.read_text())["tui"]["terminal_title"], + tomllib.loads( + (REPO / "config" / "codex" / "terminal-title.toml").read_text() + )["tui"]["terminal_title"], + ) + self.assertEqual(personal.read_text(), before) + + def test_installer_leaves_an_unowned_or_shared_destination_alone(self) -> None: + """The refusal rule it states, enforced: type alone was checked.""" + with tempfile.TemporaryDirectory(prefix=".tab-modes-", dir=REPO) as raw: + base = Path(raw) + release = base / "install" / "releases" / "r1" + (release / "config" / "codex").mkdir(parents=True) + shutil.copyfile( + REPO / "config" / "codex" / "terminal-title.toml", + release / "config" / "codex" / "terminal-title.toml", + ) + codex_home = base / "codex" + (codex_home / "session-kit").mkdir(parents=True, mode=0o700) + destination = codex_home / "session-kit" / "terminal-title.toml" + destination.write_text("# somebody else's file\n") + destination.chmod(0o666) + script = ( + f'install_root="{base / "install"}"\n' + f'source "{REPO}/lib/sh/session_kit_install.sh"\n' + "install_codex_terminal_title r1\n" + ) + subprocess.run( + ["bash", "-c", script], + check=True, + capture_output=True, + env={ + **os.environ, + "SESSION_KIT_CODEX_HOME": os.fspath(codex_home), + "HOME": os.fspath(base), + }, + ) + self.assertEqual("# somebody else's file\n", destination.read_text()) + + def test_a_template_that_cannot_be_written_fails_the_install(self) -> None: + with tempfile.TemporaryDirectory(prefix=".tab-fail-", dir=REPO) as raw: + base = Path(raw) + release = base / "install" / "releases" / "r1" + (release / "config" / "codex").mkdir(parents=True) + shutil.copyfile( + REPO / "config" / "codex" / "terminal-title.toml", + release / "config" / "codex" / "terminal-title.toml", + ) + codex_home = base / "codex" + deployed = codex_home / "session-kit" + deployed.mkdir(parents=True, mode=0o700) + script = ( + f'install_root="{base / "install"}"\n' + f'source "{REPO}/lib/sh/session_kit_install.sh"\n' + "install_codex_terminal_title r1\n" + ) + try: + deployed.chmod(0o500) # readable, not writable: mkstemp fails + result = subprocess.run( + ["bash", "-c", script], + capture_output=True, + text=True, + env={ + **os.environ, + "SESSION_KIT_CODEX_HOME": os.fspath(codex_home), + "HOME": os.fspath(base), + }, + ) + finally: + deployed.chmod(0o700) + self.assertNotEqual(0, result.returncode, result.stdout) + self.assertIn("could not be written", result.stderr) + + def test_installer_leaves_a_symlinked_destination_alone(self) -> None: + with tempfile.TemporaryDirectory(prefix=".tab-symlink-", dir=REPO) as raw: + base = Path(raw) + release = base / "install" / "releases" / "r1" + (release / "config" / "codex").mkdir(parents=True) + shutil.copyfile( + REPO / "config" / "codex" / "terminal-title.toml", + release / "config" / "codex" / "terminal-title.toml", + ) + codex_home = base / "codex" + (codex_home / "session-kit").mkdir(parents=True, mode=0o700) + elsewhere = base / "elsewhere.toml" + elsewhere.write_text("# not ours\n") + link = codex_home / "session-kit" / "terminal-title.toml" + link.symlink_to(elsewhere) + script = ( + f'install_root="{base / "install"}"\n' + f'source "{REPO}/lib/sh/session_kit_install.sh"\n' + "install_codex_terminal_title r1\n" + ) + subprocess.run( + ["bash", "-c", script], + check=True, + capture_output=True, + env={ + **os.environ, + "SESSION_KIT_CODEX_HOME": os.fspath(codex_home), + "HOME": os.fspath(base), + }, + ) + self.assertTrue(link.is_symlink()) + self.assertEqual(elsewhere.read_text(), "# not ours\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_testing_gates.py b/tests/test_testing_gates.py new file mode 100644 index 0000000..cb12b81 --- /dev/null +++ b/tests/test_testing_gates.py @@ -0,0 +1,437 @@ +"""Production must never honour a test-only hook. + +Every variable exercised here replaces evidence the kit would otherwise gather +from the machine: which platform this is, whether a provider process really +started, which sessions the provider reports, whether an install should abort. +Setting an environment variable is not a privilege, so each hook is armed only +when ``SESSION_KIT_TESTING=1`` is also set. These tests assert both directions +-- ignored without the gate, honoured with it -- because a gate that is only +tested in its armed state is a gate nobody has proven closed. +""" + +from __future__ import annotations + +import os +import platform +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from tests.support import REPO, run + +sys.path.insert(0, os.fspath(REPO / "lib")) + +from sessionkit_inventory import common as inventory_common # noqa: E402 + + +COMMON = REPO / "bin" / "session_kit_common" +LIFECYCLE = REPO / "lib" / "sh" / "session_kit_lifecycle.sh" +COMMANDS = REPO / "lib" / "sh" / "sp_commands.sh" + +# A PID that exists (this process) paired with start ticks that cannot be its +# own, so the real evidence path always answers "not present". Only the +# override can turn this into a success, which is exactly what makes it a +# usable probe for whether the override was honoured. +UNPROVABLE_START_TICKS = "999999999999" + + +def sandbox_env(**extra: str) -> dict[str, str]: + environment = { + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "HOME": os.environ.get("HOME", "/tmp"), + "PYTHONDONTWRITEBYTECODE": "1", + } + environment.update(extra) + return environment + + +class ProviderPresenceOverrideGateTests(unittest.TestCase): + """`sk_provider_process_present` is the entire proof that a launch worked.""" + + def presence(self, **extra: str) -> subprocess.CompletedProcess[str]: + return run( + [ + "bash", + "-c", + 'source "$1"; ' + f'sk_provider_process_present "$$" {UNPROVABLE_START_TICKS} codex', + "presence-gate-test", + COMMON, + ], + env=sandbox_env(**extra), + check=False, + ) + + def test_override_is_ignored_without_the_testing_gate(self) -> None: + result = self.presence(SESSION_KIT_PROVIDER_PRESENCE_OVERRIDE="present") + + self.assertNotEqual(0, result.returncode) + self.assertIn( + "ignoring SESSION_KIT_PROVIDER_PRESENCE_OVERRIDE", result.stderr + ) + self.assertIn("reserved for isolated tests", result.stderr) + + def test_override_is_honoured_under_the_testing_gate(self) -> None: + result = self.presence( + SESSION_KIT_TESTING="1", + SESSION_KIT_PROVIDER_PRESENCE_OVERRIDE="present", + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertNotIn("ignoring", result.stderr) + + def test_an_absent_override_still_refuses_under_the_gate(self) -> None: + """The gate decides whether the hook is read, not what it may say.""" + result = self.presence( + SESSION_KIT_TESTING="1", + SESSION_KIT_PROVIDER_PRESENCE_OVERRIDE="absent", + ) + + self.assertNotEqual(0, result.returncode) + + def test_an_unset_override_never_notes_anything(self) -> None: + result = self.presence() + + self.assertNotEqual(0, result.returncode) + self.assertEqual("", result.stderr) + + +class PlatformOverrideGateTests(unittest.TestCase): + def platform_of(self, **extra: str) -> subprocess.CompletedProcess[str]: + return run( + ["bash", "-c", 'source "$1"; sk_platform', "platform-gate-test", COMMON], + env=sandbox_env(**extra), + check=False, + ) + + def test_platform_override_is_ignored_without_the_testing_gate(self) -> None: + result = self.platform_of(SESSION_KIT_TEST_PLATFORM="Darwin") + + self.assertEqual(platform.system(), result.stdout.strip()) + self.assertIn("ignoring SESSION_KIT_TEST_PLATFORM", result.stderr) + + def test_platform_override_is_honoured_under_the_testing_gate(self) -> None: + result = self.platform_of( + SESSION_KIT_TESTING="1", SESSION_KIT_TEST_PLATFORM="Darwin" + ) + + self.assertEqual("Darwin", result.stdout.strip()) + self.assertEqual("", result.stderr) + + +class LifecycleFailpointGateTests(unittest.TestCase): + """An ungated failpoint lets any process abort every install.""" + + def failpoint(self, name: str, **extra: str) -> subprocess.CompletedProcess[str]: + return run( + [ + "bash", + "-c", + 'die() { printf "die: %s\\n" "$*" >&2; exit 9; }; ' + 'source "$1"; lifecycle_failpoint "$2"; echo SURVIVED', + "failpoint-gate-test", + LIFECYCLE, + name, + ], + env=sandbox_env(**extra), + check=False, + ) + + def test_failpoint_is_ignored_without_the_testing_gate(self) -> None: + result = self.failpoint("themes", SESSION_KIT_TEST_FAILPOINT="themes") + + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("SURVIVED", result.stdout) + + def test_kill_mode_is_ignored_without_the_testing_gate(self) -> None: + result = self.failpoint( + "themes", + SESSION_KIT_TEST_FAILPOINT="themes", + SESSION_KIT_TEST_FAILPOINT_MODE="kill", + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("SURVIVED", result.stdout) + + def test_failpoint_is_honoured_under_the_testing_gate(self) -> None: + result = self.failpoint( + "themes", SESSION_KIT_TESTING="1", SESSION_KIT_TEST_FAILPOINT="themes" + ) + + self.assertEqual(9, result.returncode) + self.assertIn("isolated test failpoint after themes", result.stderr) + self.assertNotIn("SURVIVED", result.stdout) + + def test_a_different_failpoint_name_never_fires(self) -> None: + result = self.failpoint( + "themes", + SESSION_KIT_TESTING="1", + SESSION_KIT_TEST_FAILPOINT="theme-copy", + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("SURVIVED", result.stdout) + + def test_the_theme_copy_failpoint_shares_the_one_gate(self) -> None: + """`session_kit_install.sh` asks the same helper, so it cannot drift.""" + source = (REPO / "lib/sh/session_kit_install.sh").read_text(encoding="utf-8") + theme_copy = source.split("install_codex_themes() {", 1)[1].split( + "install_codex_terminal_title() {", 1 + )[0] + + self.assertIn("lifecycle_failpoint_armed theme-copy", theme_copy) + self.assertNotIn("SESSION_KIT_TEST_FAILPOINT", theme_copy) + + +class ProviderSnapshotFixtureGateTests(unittest.TestCase): + """The fixture hooks replace the provider's own answer about what exists.""" + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.fixture = Path(self.temporary.name) / "agents.json" + self.fixture.write_text( + '[{"sessionId": "00000000-0000-4000-8000-000000000001"}]', + encoding="utf-8", + ) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def command_json(self, environ: dict[str, str]) -> object: + called: list[list[str]] = [] + + def runner(argv, timeout): # noqa: ANN001 - mirrors the injected Runner + called.append(list(argv)) + return "[]" + + payload = inventory_common._command_json( + fixture_env="SESSION_KIT_CLAUDE_JSON_FILE", + command_env="SESSION_KIT_CLAUDE_CMD", + default_command=("claude", "agents", "--json"), + runner=runner, + timeout=1.0, + environ=environ, + load_json_file=lambda path: {"fixture": str(path)}, + command_from_env=lambda name, default: [ + *environ.get(name, default).split() + ], + ) + return payload, called + + def test_snapshot_fixture_is_ignored_without_the_testing_gate(self) -> None: + payload, called = self.command_json( + {"SESSION_KIT_CLAUDE_JSON_FILE": str(self.fixture)} + ) + + self.assertEqual([], payload) + self.assertEqual([["claude", "agents", "--json"]], called) + + def test_snapshot_fixture_is_honoured_under_the_testing_gate(self) -> None: + payload, called = self.command_json( + { + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_CLAUDE_JSON_FILE": str(self.fixture), + } + ) + + self.assertEqual({"fixture": str(self.fixture)}, payload) + self.assertEqual([], called) + + +class ProcRootGateTests(unittest.TestCase): + """`/proc` is where every identity proof in the kit gets its answer.""" + + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.fake_proc = Path(self.temporary.name) / "proc" + self.fake_proc.mkdir() + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_fixture_root_is_ignored_without_the_testing_gate(self) -> None: + chosen = inventory_common.proc_root( + {"SESSION_KIT_PROC_ROOT": os.fspath(self.fake_proc)} + ) + + self.assertEqual(Path("/proc"), chosen) + + def test_fixture_root_is_honoured_under_the_testing_gate(self) -> None: + chosen = inventory_common.proc_root( + { + "SESSION_KIT_TESTING": "1", + "SESSION_KIT_PROC_ROOT": os.fspath(self.fake_proc), + } + ) + + self.assertEqual(self.fake_proc, chosen) + + def test_an_unset_fixture_root_is_the_real_one(self) -> None: + self.assertEqual(Path("/proc"), inventory_common.proc_root({})) + self.assertEqual( + Path("/proc"), inventory_common.proc_root({"SESSION_KIT_TESTING": "1"}) + ) + + def test_the_reaper_reads_the_real_proc_without_the_gate(self) -> None: + result = run( + [ + "bash", + "-c", + 'set -a; source /dev/stdin <<<"$(sed -n "1,/^SENTINEL=/p" "$1")"; ' + 'printf "%s\\n" "$PROC_ROOT"', + "reaper-proc-root-test", + REPO / "bin" / "shpool_reaper", + ], + env=sandbox_env(SESSION_KIT_PROC_ROOT=os.fspath(self.fake_proc)), + check=False, + ) + + self.assertEqual("/proc", result.stdout.strip(), result.stderr) + + def test_the_reaper_reads_the_fixture_proc_under_the_gate(self) -> None: + result = run( + [ + "bash", + "-c", + 'set -a; source /dev/stdin <<<"$(sed -n "1,/^SENTINEL=/p" "$1")"; ' + 'printf "%s\\n" "$PROC_ROOT"', + "reaper-proc-root-test", + REPO / "bin" / "shpool_reaper", + ], + env=sandbox_env( + SESSION_KIT_TESTING="1", + SESSION_KIT_PROC_ROOT=os.fspath(self.fake_proc), + ), + check=False, + ) + + self.assertEqual(os.fspath(self.fake_proc), result.stdout.strip(), result.stderr) + + +class HistoryExecutableOverrideGateTests(unittest.TestCase): + """History tool substitutions are isolated-test hooks, never production input.""" + + OVERRIDES = ( + "SESSION_KIT_JOURNAL_RENDER_TOOL", + "SESSION_KIT_TRANSCRIPT_TEXT_TOOL", + "SESSION_KIT_HISTORY_SEARCH_TOOL", + ) + + def selected_path( + self, name: str, **extra: str + ) -> subprocess.CompletedProcess[str]: + return run( + [ + "bash", + "-c", + 'source "$1"; source "$2"; sk_history_tool_path "$3" /trusted/tool', + "history-tool-gate-test", + COMMON, + COMMANDS, + name, + ], + env=sandbox_env(**{name: "/untrusted/tool"}, **extra), + check=False, + ) + + def test_overrides_are_ignored_without_the_testing_gate(self) -> None: + for name in self.OVERRIDES: + with self.subTest(name=name): + result = self.selected_path(name) + self.assertEqual("/trusted/tool", result.stdout.strip()) + self.assertIn(f"ignoring {name}", result.stderr) + self.assertIn("reserved for isolated tests", result.stderr) + + def test_overrides_are_honoured_under_the_testing_gate(self) -> None: + for name in self.OVERRIDES: + with self.subTest(name=name): + result = self.selected_path(name, SESSION_KIT_TESTING="1") + self.assertEqual("/untrusted/tool", result.stdout.strip()) + self.assertEqual("", result.stderr) + + +class NoUngatedTestHookRemainsTests(unittest.TestCase): + """A regression net: a new ungated hook fails here, not in production.""" + + HOOKS = ( + "SESSION_KIT_PROVIDER_PRESENCE_OVERRIDE", + "SESSION_KIT_TEST_FAILPOINT", + "SESSION_KIT_TEST_FAILPOINT_MODE", + "SESSION_KIT_TEST_MONOTONIC_NS", + "SESSION_KIT_TEST_PLATFORM", + "SESSION_KIT_PROC_ROOT", + "SESSION_KIT_SHPOOL_JSON_FILE", + "SESSION_KIT_CLAUDE_JSON_FILE", + "SESSION_KIT_JOURNAL_RENDER_TOOL", + "SESSION_KIT_TRANSCRIPT_TEXT_TOOL", + "SESSION_KIT_HISTORY_SEARCH_TOOL", + ) + # The files that own a gate. Every other reader has to sit beside one. + GATEKEEPERS = { + "bin/session_kit_common", + "bin/shpool_reaper", + "lib/sh/session_kit_lifecycle.sh", + "lib/sessionkit_inventory/common.py", + } + + def production_files(self) -> list[Path]: + listed = subprocess.run( + ["git", "ls-files"], + cwd=REPO, + text=True, + stdout=subprocess.PIPE, + check=True, + ).stdout.split() + keep = [] + for name in listed: + if name.startswith(("tests/", "docs/", ".github/")): + continue + path = REPO / name + if path.suffix in {".md", ".json", ".txt", ".patch", ".tmTheme"}: + continue + if path.is_file(): + keep.append(path) + return keep + + def test_every_test_hook_read_sits_next_to_the_testing_gate(self) -> None: + offenders = [] + for path in self.production_files(): + relative = path.relative_to(REPO).as_posix() + if relative in self.GATEKEEPERS: + continue + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + continue + for number, line in enumerate(lines, 1): + if not any(hook in line for hook in self.HOOKS): + continue + # The gate may sit on this line or within six lines either + # side, which covers the longest multi-line condition in the + # tree while still requiring the hook and gate to be adjacent. + window = "\n".join(lines[max(0, number - 7) : number + 6]) + if "SESSION_KIT_TESTING" in window: + continue + if any( + marker in line + for marker in ( + "sk_test_hook", + "sk_history_tool_path", + "failpoint_armed", + "proc_root(", + # Names the variable for `_command_json`, which is + # where that hook's gate lives. + "fixture_env=", + ) + ): + continue + if line.lstrip().startswith(("#", "``", '"')): + continue + offenders.append(f"{relative}:{number}: {line.strip()}") + self.assertEqual([], offenders) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_transcript_reach.py b/tests/test_transcript_reach.py new file mode 100644 index 0000000..f5871b4 --- /dev/null +++ b/tests/test_transcript_reach.py @@ -0,0 +1,243 @@ +"""Two promises the rebuild dropped: a Codex history, and a doctor that checks. + +`sp history` falls back to the conversation's own transcript when a session was +never recorded, and did it for Claude only, so a Codex session from the same +weeks answered "no live history" while its rollout sat on this disk unread. + +The doctor's transcript-reachability check went with the supervisor that used +to own the resolver. It is the only thing that notices when a conversation this +machine recorded can no longer be read here, the failure that has already +happened once, silently, under a rotated account profile. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +from tests.support import REPO + +sys.path.insert(0, os.fspath(REPO / "lib")) + +from sessionkit_inventory import transcript_text, transcripts # noqa: E402 + +CORE = REPO / "lib" / "session_inventory.py" +ONE = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" +TWO = "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff" + + +def rollout_line(role: str, text: str, kind: str = "input_text") -> str: + return json.dumps( + { + "timestamp": "2026-08-09T20:10:17.758Z", + "type": "response_item", + "payload": { + "type": "message", + "role": role, + "content": [{"type": kind, "text": text}], + }, + } + ) + + +class ResolverTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".transcripts-", dir=REPO) + self.addCleanup(self.temp.cleanup) + self.home = Path(self.temp.name) + self.environ = {"HOME": str(self.home)} + + def claude_transcript(self, uuid: str, root: Path | None = None) -> Path: + base = root or self.home / ".claude" + projects = base / "projects" / "srv-project" + projects.mkdir(parents=True, exist_ok=True) + path = projects / f"{uuid}.jsonl" + path.write_text("{}\n", encoding="utf-8") + return path + + def codex_rollout(self, uuid: str, root: Path | None = None) -> Path: + base = root or self.home / ".codex" + sessions = base / "sessions" / "2026" / "08" / "12" + sessions.mkdir(parents=True, exist_ok=True) + path = sessions / f"rollout-2026-08-12T10-00-00-{uuid}.jsonl" + path.write_text(rollout_line("user", "hello") + "\n", encoding="utf-8") + return path + + def test_a_claude_transcript_is_found_in_the_default_profile(self) -> None: + path = self.claude_transcript(ONE) + self.assertEqual( + path, transcripts.locate_transcript("claude", ONE, environ=self.environ) + ) + + def test_a_transcript_under_a_rotated_account_profile_is_found_too(self) -> None: + """The exact blindness the check exists for.""" + root = self.home / ".local/share/session-kit/accounts/claude/work" + path = self.claude_transcript(ONE, root) + self.assertEqual( + path, transcripts.locate_transcript("claude", ONE, environ=self.environ) + ) + + def test_a_codex_rollout_is_found_by_its_conversation(self) -> None: + path = self.codex_rollout(ONE) + self.assertEqual( + path, transcripts.locate_transcript("codex", ONE, environ=self.environ) + ) + + def test_a_conversation_with_no_record_resolves_to_nothing(self) -> None: + self.assertIsNone( + transcripts.locate_transcript("claude", ONE, environ=self.environ) + ) + self.assertIsNone( + transcripts.locate_transcript("shell", ONE, environ=self.environ) + ) + self.assertIsNone( + transcripts.locate_transcript("claude", "not-a-uuid", environ=self.environ) + ) + + +class CodexHistoryTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".codex-history-", dir=REPO) + self.addCleanup(self.temp.cleanup) + self.home = Path(self.temp.name) + self.rollout = ( + self.home / ".codex" / "sessions" / "2026" / "08" / "12" + ) + self.rollout.mkdir(parents=True) + self.path = self.rollout / f"rollout-2026-08-12T10-00-00-{ONE}.jsonl" + self.path.write_text( + "\n".join( + ( + json.dumps({"type": "session_meta", "payload": {"id": ONE}}), + rollout_line( + "user", + "<permissions instructions>\nnoise</permissions instructions>", + ), + rollout_line("user", "Fix the blueprint"), + rollout_line("assistant", "Fixed it.", kind="output_text"), + json.dumps({"type": "event_msg", "payload": {"type": "task_started"}}), + ) + ) + + "\n", + encoding="utf-8", + ) + + def test_a_rollout_reads_as_the_conversation_it_holds(self) -> None: + lines = transcript_text.render_rollout(self.path) + body = "\n".join(lines) + self.assertIn("══ OPERATOR", body) + self.assertIn("Fix the blueprint", body) + self.assertIn("● Fixed it.", body) + # The harness preamble is not something anybody said. + self.assertNotIn("permissions instructions", body) + + def test_the_tool_renders_a_codex_conversation_by_its_uuid(self) -> None: + shown = subprocess.run( + [ + sys.executable, + os.fspath(REPO / "lib" / "sessionkit_inventory" / "transcript_text.py"), + "codex", + ONE, + ], + env={**os.environ, "HOME": os.fspath(self.home)}, + capture_output=True, + text=True, + ) + self.assertEqual(0, shown.returncode, shown.stderr) + self.assertIn("Fix the blueprint", shown.stdout) + + def test_a_codex_conversation_with_no_rollout_says_so(self) -> None: + shown = subprocess.run( + [ + sys.executable, + os.fspath(REPO / "lib" / "sessionkit_inventory" / "transcript_text.py"), + "codex", + TWO, + ], + env={**os.environ, "HOME": os.fspath(self.home)}, + capture_output=True, + text=True, + ) + self.assertEqual(1, shown.returncode) + self.assertIn("no transcript found", shown.stderr) + + +class ReachabilityReportTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".reach-", dir=REPO) + self.addCleanup(self.temp.cleanup) + self.home = Path(self.temp.name) + self.state = self.home / "state" + self.state.mkdir(mode=0o700) + + def write_list(self, *rows: dict) -> None: + path = self.state / "inventory.json" + path.write_text( + json.dumps( + { + "schema_version": 1, + "generated_at": "2026-08-12T00:00:00Z", + "sessions": list(rows), + } + ), + encoding="utf-8", + ) + path.chmod(0o600) + + def report(self) -> dict: + shown = subprocess.run( + [sys.executable, os.fspath(CORE), "transcript", "reachability"], + env={ + **os.environ, + "HOME": os.fspath(self.home), + "SESSION_KIT_STATE_DIR": os.fspath(self.state), + }, + capture_output=True, + text=True, + ) + self.assertEqual(0, shown.returncode, shown.stderr) + return json.loads(shown.stdout) + + def test_no_list_is_a_warning_about_the_check_not_the_sessions(self) -> None: + value = self.report() + self.assertEqual("warn", value["status"]) + self.assertEqual(0, value["checked"]) + self.assertIn("nothing was checked", value["detail"]) + + def test_a_list_with_no_provider_session_is_fine(self) -> None: + self.write_list({"provider": "shell", "identity": {"uuid": None}}) + value = self.report() + self.assertEqual("ok", value["status"]) + self.assertEqual(0, value["checked"]) + + def test_a_conversation_this_machine_cannot_read_is_named(self) -> None: + self.write_list( + { + "provider": "claude", + "identity": {"uuid": ONE}, + "title": "Orphaned Record", + } + ) + value = self.report() + self.assertEqual("warn", value["status"]) + self.assertEqual(1, value["checked"]) + self.assertEqual(["Orphaned Record"], value["unreadable"]) + + def test_a_readable_conversation_passes(self) -> None: + projects = self.home / ".claude" / "projects" / "srv-project" + projects.mkdir(parents=True) + (projects / f"{ONE}.jsonl").write_text("{}\n", encoding="utf-8") + self.write_list({"provider": "claude", "identity": {"uuid": ONE}}) + value = self.report() + self.assertEqual("ok", value["status"]) + self.assertEqual(1, value["checked"]) + self.assertEqual([], value["unreadable"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_transcript_text.py b/tests/test_transcript_text.py new file mode 100644 index 0000000..e1b4477 --- /dev/null +++ b/tests/test_transcript_text.py @@ -0,0 +1,291 @@ +"""Unit contracts for the provider-neutral conversation renderer.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest + +from tests.support import REPO + +sys.path.insert(0, os.fspath(REPO / "lib")) +from sessionkit_inventory import transcript_text # noqa: E402 + + +def codex(payload: dict) -> str: + return json.dumps({"type": "response_item", "payload": payload}) + + +class TranscriptTextTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".transcript-text-", dir=REPO) + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + + def write(self, name: str, *records: str) -> Path: + path = self.root / name + path.write_text("\n".join(records) + "\n", encoding="utf-8") + return path + + def test_claude_and_codex_use_one_visual_language(self) -> None: + claude = self.write( + "claude.jsonl", + json.dumps( + {"type": "user", "message": {"content": "Find the chorus"}} + ), + json.dumps( + { + "type": "assistant", + "message": {"content": [ + {"type": "tool_use", "name": "Search", "input": {"pattern": "chorus"}}, + {"type": "text", "text": "Found it."}, + ]}, + } + ), + json.dumps( + {"type": "user", "message": {"content": [ + {"type": "tool_result", "content": "line 1\nline 2"} + ]}} + ), + ) + codex_path = self.write( + "codex.jsonl", + codex({"type": "message", "role": "user", "content": [ + {"type": "input_text", "text": "Find the chorus"} + ]}), + codex({"type": "function_call", "name": "Search", "call_id": "1", + "arguments": json.dumps({"pattern": "chorus"})}), + codex({"type": "message", "role": "assistant", "content": [ + {"type": "output_text", "text": "Found it."} + ]}), + codex({"type": "function_call_output", "call_id": "1", + "output": "line 1\nline 2"}), + ) + self.assertEqual( + transcript_text.render_transcript(claude), + transcript_text.render_rollout(codex_path), + ) + + def test_both_codex_tool_families_and_block_outputs_render(self) -> None: + path = self.write( + "tools.jsonl", + codex({"type": "custom_tool_call", "name": "exec", "call_id": "a", + "input": "run tests"}), + codex({"type": "custom_tool_call_output", "call_id": "a", "output": [ + {"type": "input_text", "text": "54 passed"} + ]}), + codex({"type": "function_call", "name": "open", "call_id": "b", + "arguments": "{"}), + codex({"type": "function_call_output", "call_id": "b", "output": "done"}), + ) + self.assertEqual( + ["⏺ exec: run tests", " │ 54 passed", "⏺ open: {", " │ done"], + transcript_text.render_rollout(path), + ) + + def test_tool_output_is_bounded_and_empty_output_disappears(self) -> None: + long_output = "\n".join(f"line {number}" for number in range(13)) + path = self.write( + "bounded.jsonl", + codex({"type": "function_call_output", "call_id": "a", "output": long_output}), + codex({"type": "function_call_output", "call_id": "b", "output": ""}), + "not json", + ) + lines = transcript_text.render_rollout(path) + self.assertEqual(1, len(lines)) + self.assertIn(" … (1 more lines)", lines[0]) + self.assertNotIn("line 12", lines[0]) + + def test_scaffolding_records_disappear_but_markup_prompts_remain(self) -> None: + path = self.write( + "scaffold.jsonl", + codex({"type": "message", "role": "user", "content": [{ + "type": "input_text", + "text": "<recommended_plugins>noise</recommended_plugins>\n" + "# AGENTS.md instructions for /srv/project\n\n" + "<INSTRUCTIONS>entire rulebook</INSTRUCTIONS>\n" + "<environment_context>cwd</environment_context>", + }]}), + codex({"type": "message", "role": "user", "content": [{ + "type": "input_text", "text": "<tag>Keep this request</tag>" + }]}), + ) + body = "\n".join(transcript_text.render_rollout(path)) + self.assertNotIn("rulebook", body) + self.assertNotIn("/srv/project", body) + self.assertIn("<tag>Keep this request</tag>", body) + + def test_attributed_harness_envelopes_split_away_from_operator_text(self) -> None: + path = self.write( + "attributed-scaffold.jsonl", + codex({"type": "message", "role": "user", "content": [{ + "type": "input_text", + "text": '<codex_internal_context source="goal">machine continuation' + "</codex_internal_context>", + }]}), + codex({"type": "message", "role": "user", "content": [{ + "type": "input_text", + "text": '<hook_prompt hook_run_id="stop:15:internal-id">machine hook' + "</hook_prompt>\nFix the real request.", + }]}), + ) + + body = "\n".join(transcript_text.render_rollout(path)) + + self.assertNotIn("machine continuation", body) + self.assertNotIn("machine hook", body) + self.assertNotIn("internal-id", body) + self.assertIn("Fix the real request.", body) + + def test_real_rollout_subagent_final_answer_is_a_bounded_result(self) -> None: + # Structurally exact, redacted excerpt from rollout 019ffa14-5b4c. + result_lines = [f"finding {number}" for number in range(14)] + path = self.write( + "real-agent-result.jsonl", + codex({ + "type": "agent_message", + "author": "/root/history_review", + "recipient": "/root", + "content": [{ + "type": "input_text", + "text": "Message Type: FINAL_ANSWER\n" + "Task name: /root/history_review\n" + "Sender: /root/history_review\nPayload:\n" + + "\n".join(result_lines), + }], + "internal_chat_message_metadata_passthrough": { + "turn_id": "internal-turn-id" + }, + }), + codex({ + "type": "agent_message", + "author": "/root", + "recipient": "/root/worker", + "content": [{ + "type": "input_text", + "text": "Message Type: NEW_TASK\nTask name: /root/worker\n" + "Sender: /root\nPayload:\n", + }, { + "type": "encrypted_content", "encrypted_content": "ciphertext" + }], + }), + ) + + body = "\n".join(transcript_text.render_rollout(path)) + + self.assertIn("⏺ sub-agent result: /root/history_review", body) + self.assertIn(" │ finding 0", body) + self.assertIn(" … (2 more lines)", body) + self.assertNotIn("finding 12", body) + self.assertNotIn("internal-turn-id", body) + self.assertNotIn("ciphertext", body) + self.assertNotIn("NEW_TASK", body) + + def test_subagent_result_bounds_a_single_long_line(self) -> None: + path = self.write( + "long-agent-result.jsonl", + codex({ + "type": "agent_message", + "author": "/root/worker", + "recipient": "/root", + "content": [{ + "type": "input_text", + "text": "Message Type: FINAL_ANSWER\nTask name: /root/worker\n" + "Sender: /root/worker\nPayload:\n" + + ("x" * (transcript_text.MAX_AGENT_RESULT_CHARS + 500)), + }], + }), + ) + + lines = transcript_text.render_rollout(path) + + self.assertEqual(2, len(lines)) + self.assertLess(len(lines[1]), transcript_text.MAX_AGENT_RESULT_CHARS + 100) + self.assertIn("… (more text)", lines[1]) + + def test_real_rollout_compaction_restores_and_labels_the_beginning(self) -> None: + # Structurally exact, redacted excerpt from rollout 019fb04f-29ea. + opening = {"type": "message", "role": "user", "content": [{ + "type": "input_text", "text": "Original overnight request" + }]} + overlap = {"type": "message", "role": "user", "content": [{ + "type": "input_text", "text": "Formerly first visible request" + }]} + compacted = json.dumps({ + "type": "compacted", + "payload": { + "message": "", + "replacement_history": [ + opening, + {"type": "message", "role": "developer", "content": [{ + "type": "input_text", "text": "private harness rulebook" + }]}, + overlap, + {"type": "compaction", "encrypted_content": "ciphertext"}, + ], + "window_number": 1, + "window_id": "internal-window-id", + }, + }) + path = self.write( + "real-compacted-history.jsonl", + codex(overlap), + codex({"type": "function_call", "name": "exec", "call_id": "1", + "arguments": json.dumps({"cmd": "pre-compaction check"})}), + compacted, + codex({"type": "message", "role": "user", "content": [{ + "type": "input_text", "text": "Request after compaction" + }]}), + compacted, + ) + + body = "\n".join(transcript_text.render_rollout(path)) + + self.assertTrue(body.lstrip().startswith("══ COMPACTED HISTORY"), body) + self.assertLess(body.index("Original overnight request"), + body.index("Formerly first visible request")) + self.assertEqual(1, body.count("Original overnight request")) + self.assertEqual(1, body.count("Formerly first visible request")) + self.assertEqual(1, body.count("══ COMPACTED HISTORY")) + self.assertIn("══ CONTINUED HISTORY", body) + self.assertIn("⏺ exec: pre-compaction check", body) + self.assertLess(body.index("Formerly first visible request"), + body.index("Request after compaction")) + self.assertNotIn("private harness rulebook", body) + self.assertNotIn("internal-window-id", body) + self.assertNotIn("ciphertext", body) + + def test_request_after_agents_scaffolding_is_preserved(self) -> None: + path = self.write( + "scaffold-with-request.jsonl", + codex({"type": "message", "role": "user", "content": [{ + "type": "input_text", + "text": "# AGENTS.md instructions for /srv/project\n\n" + "<INSTRUCTIONS>entire rulebook</INSTRUCTIONS>\n" + "<environment_context>cwd</environment_context>\n" + "Fix the broken chorus search.", + }]}), + ) + body = "\n".join(transcript_text.render_rollout(path)) + self.assertNotIn("rulebook", body) + self.assertNotIn("environment_context", body) + self.assertIn("Fix the broken chorus search.", body) + + def test_operator_use_of_agents_heading_is_preserved(self) -> None: + path = self.write( + "quoted-heading.jsonl", + codex({"type": "message", "role": "user", "content": [{ + "type": "input_text", + "text": "# AGENTS.md instructions\n" + "Explain what this heading means in the rendered history.", + }]}), + ) + body = "\n".join(transcript_text.render_rollout(path)) + self.assertIn("# AGENTS.md instructions", body) + self.assertIn("Explain what this heading means", body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tui_acceptance.py b/tests/test_tui_acceptance.py new file mode 100644 index 0000000..23f646a --- /dev/null +++ b/tests/test_tui_acceptance.py @@ -0,0 +1,1230 @@ +"""The picker as a terminal actually renders it. + +The rest of the suite tests the frame model, which is what a person would see +if a terminal drew exactly what it was told. This file tests what a terminal +does draw: the bytes go through a screen model that tracks a cell grid and the +attributes on every cell, the same way the terminal in front of a person does. + +That difference matters. A title too long for its column pushed its own detail +right; every splitlines-based assertion passed, because the *line* was still +correct, only the *columns* were wrong. Reconstructing the grid is the check +that sees it. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import errno +import fcntl +import json +import os +from pathlib import Path +import pty +import re +import select +import shutil +import signal +import struct +import tempfile +import termios +import time +import unittest + +from tests.tui_support import REPO, document, row, write_executable + +PICKER = REPO / "bin" / "shpool_login_tui" +CTRL_D = b"\x04" +ESC = b"\x1b" +ENTER = b"\r" +DOWN = b"\x1bOB" + +LINES, COLUMNS = 30, 120 + +# The terminals to prove against. Ghostty is what the operator runs; it is +# skipped rather than faked where its terminfo is not installed. +TERMINALS = ("xterm-256color", "xterm-ghostty") + +# A colour, as opposed to "whatever this terminal calls default". 39 and 49 +# are the default-foreground and default-background codes: they are in the +# colour family but they set no colour, and a screen that emits only those is +# a screen with no colour on it. +COLOR_SGR = re.compile( + rb"\x1b\[(?P<params>[0-9;]*)m" +) +COLOR_PARAMS = re.compile(r"(?:^|;)(3[0-7]|9[0-7]|4[0-7]|10[0-7]|38|48)(?:;|$)") + +# The exact shared palette from shpool_login_render.sh. Curses installs these +# at stable custom indices so the cell grid proves both identity and RGB. +SESSION_PALETTE = { + "red": (16, (237, 93, 93)), + "blue": (17, (97, 166, 240)), + "green": (18, (63, 221, 115)), + "yellow": (19, (249, 215, 108)), + "purple": (20, (173, 115, 239)), + "orange": (21, (242, 144, 81)), + "pink": (22, (240, 113, 177)), + "cyan": (23, (64, 216, 209)), + "lime": (24, (170, 230, 70)), + "magenta": (25, (255, 95, 255)), + "silver": (26, (205, 210, 220)), + "sand": (27, (214, 178, 130)), + "sky": (28, (150, 205, 255)), + "sea": (29, (95, 235, 170)), +} + + +def has_color_bytes(payload: bytes) -> bool: + for match in COLOR_SGR.finditer(payload): + params = match.group("params").decode("ascii", "replace") + if COLOR_PARAMS.search(params): + return True + return False + + +# --------------------------------------------------------------------------- +# A screen, as the terminal keeps one. + + +@dataclass +class Cell: + char: str = " " + bold: bool = False + dim: bool = False + reverse: bool = False + foreground: int | None = None + background: int | None = None + + def blank(self) -> "Cell": + return Cell() + + +@dataclass +class Emulator: + """Enough of a terminal to hold what ncurses sends it. + + Cursor addressing, erasing, insertion, deletion, scrolling regions, and + the attributes in force when each cell was written. Not a terminal, a + faithful enough model of one to assert against. + """ + + rows: int = LINES + columns: int = COLUMNS + row: int = 0 + column: int = 0 + top: int = 0 + bottom: int = LINES - 1 + bold: bool = False + dim: bool = False + reverse: bool = False + foreground: int | None = None + background: int | None = None + saved: tuple[int, int] = (0, 0) + pending: bytes = b"" + grid: list[list[Cell]] = field(default_factory=list) + palette: dict[int, tuple[int, int, int]] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.bottom = self.rows - 1 + self.grid = [[Cell() for _ in range(self.columns)] for _ in range(self.rows)] + + # -- reading it back ---------------------------------------------------- + + def line(self, index: int) -> str: + return "".join(cell.char for cell in self.grid[index]).rstrip() + + def lines(self) -> list[str]: + return [self.line(index) for index in range(self.rows)] + + def text(self) -> str: + return "\n".join(self.lines()) + + def find(self, needle: str) -> int: + for index in range(self.rows): + if needle in self.line(index): + return index + return -1 + + def colored_columns(self, index: int, foreground: int) -> list[int]: + return [ + column + for column, cell in enumerate(self.grid[index]) + if cell.foreground == foreground and cell.char != " " + ] + + def colored_cells(self, index: int) -> list[Cell]: + return [ + cell + for cell in self.grid[index] + if cell.foreground is not None and cell.char != " " + ] + + def any_color(self) -> bool: + return any( + cell.foreground is not None or cell.background is not None + for line in self.grid + for cell in line + ) + + # -- writing into it ---------------------------------------------------- + + def _cell(self) -> Cell: + return Cell( + " ", + self.bold, + self.dim, + self.reverse, + self.foreground, + self.background, + ) + + def _put(self, character: str) -> None: + if self.column >= self.columns: + self.column = 0 + self._index() + cell = self._cell() + cell.char = character + self.grid[self.row][self.column] = cell + self.column += 1 + + def _index(self) -> None: + if self.row == self.bottom: + self._scroll_up(1) + elif self.row < self.rows - 1: + self.row += 1 + + def _scroll_up(self, count: int) -> None: + for _ in range(count): + del self.grid[self.top] + self.grid.insert(self.bottom, [Cell() for _ in range(self.columns)]) + + def _scroll_down(self, count: int) -> None: + for _ in range(count): + del self.grid[self.bottom] + self.grid.insert(self.top, [Cell() for _ in range(self.columns)]) + + def _erase(self, index: int, start: int, stop: int) -> None: + for column in range(max(0, start), min(self.columns, stop)): + self.grid[index][column] = Cell() + + def feed(self, payload: bytes) -> None: + data = self.pending + payload + self.pending = b"" + position = 0 + length = len(data) + while position < length: + byte = data[position] + if byte == 0x1B: + consumed = self._escape(data, position) + if consumed is None: + self.pending = data[position:] + return + position += consumed + continue + if byte in (0x07,): + position += 1 + continue + if byte == 0x0D: + self.column = 0 + position += 1 + continue + if byte == 0x0A: + self._index() + position += 1 + continue + if byte == 0x08: + self.column = max(0, self.column - 1) + position += 1 + continue + if byte == 0x09: + self.column = min(self.columns - 1, (self.column // 8 + 1) * 8) + position += 1 + continue + if byte < 0x20: + position += 1 + continue + # One UTF-8 character, held back when the read cut it in half. + size = 1 + if byte >= 0xF0: + size = 4 + elif byte >= 0xE0: + size = 3 + elif byte >= 0xC0: + size = 2 + if position + size > length: + self.pending = data[position:] + return + self._put(data[position : position + size].decode("utf-8", "replace")) + position += size + + def _escape(self, data: bytes, position: int) -> int | None: + if position + 1 >= len(data): + return None + marker = data[position + 1] + if marker == 0x5B: # CSI + index = position + 2 + while index < len(data) and not 0x40 <= data[index] <= 0x7E: + index += 1 + if index >= len(data): + return None + self._csi(data[position + 2 : index].decode("ascii", "replace"), chr(data[index])) + return index - position + 1 + if marker == 0x5D: # OSC, ended by BEL or ST + index = position + 2 + while index < len(data): + if data[index] == 0x07: + self._osc(data[position + 2 : index]) + return index - position + 1 + if data[index] == 0x1B and index + 1 < len(data) and data[index + 1] == 0x5C: + self._osc(data[position + 2 : index]) + return index - position + 2 + index += 1 + return None + if marker in (0x28, 0x29, 0x2A, 0x2B, 0x25, 0x23): # charset designators + if position + 2 >= len(data): + return None + return 3 + if marker == 0x4D: # RI + if self.row == self.top: + self._scroll_down(1) + else: + self.row = max(0, self.row - 1) + return 2 + if marker == 0x44: # IND + self._index() + return 2 + if marker == 0x45: # NEL + self.column = 0 + self._index() + return 2 + if marker == 0x37: # DECSC + self.saved = (self.row, self.column) + return 2 + if marker == 0x38: # DECRC + self.row, self.column = self.saved + return 2 + return 2 + + def _osc(self, payload: bytes) -> None: + """Remember palette changes emitted by terminfo's initc capability.""" + + if payload == b"104": + self.palette.clear() + return + match = re.fullmatch( + rb"4;(?P<index>\d+);rgb:(?P<red>[0-9A-Fa-f]+)/" + rb"(?P<green>[0-9A-Fa-f]+)/(?P<blue>[0-9A-Fa-f]+)", + payload, + ) + if match is None: + return + + def channel(name: str) -> int: + raw = match.group(name) + value = int(raw, 16) + maximum = (1 << (4 * len(raw))) - 1 + return (value * 255 + maximum // 2) // maximum + + self.palette[int(match.group("index"))] = ( + channel("red"), + channel("green"), + channel("blue"), + ) + + def _csi(self, raw: str, final: str) -> None: + private = raw.startswith("?") or raw.startswith(">") + body = raw.lstrip("?>") + parts = [part for part in body.split(";")] + numbers = [int(part) if part.isdigit() else 0 for part in parts if part != ""] + + def first(default: int = 1) -> int: + return numbers[0] if numbers and numbers[0] else default + + if private and final in "hl": + return + if final == "m": + self._sgr(parts) + return + if final in "ht": + return + if final == "r": + self.top = (numbers[0] - 1) if numbers else 0 + self.bottom = (numbers[1] - 1) if len(numbers) > 1 else self.rows - 1 + self.top = max(0, min(self.rows - 1, self.top)) + self.bottom = max(self.top, min(self.rows - 1, self.bottom)) + self.row, self.column = 0, 0 + return + if final == "A": + self.row = max(0, self.row - first()) + elif final == "B": + self.row = min(self.rows - 1, self.row + first()) + elif final == "C": + self.column = min(self.columns - 1, self.column + first()) + elif final == "D": + self.column = max(0, self.column - first()) + elif final == "E": + self.row = min(self.rows - 1, self.row + first()) + self.column = 0 + elif final == "F": + self.row = max(0, self.row - first()) + self.column = 0 + elif final in "G`": + self.column = max(0, min(self.columns - 1, first() - 1)) + elif final == "d": + self.row = max(0, min(self.rows - 1, first() - 1)) + elif final in "Hf": + self.row = max(0, min(self.rows - 1, (numbers[0] - 1) if numbers else 0)) + self.column = max( + 0, min(self.columns - 1, (numbers[1] - 1) if len(numbers) > 1 else 0) + ) + elif final == "J": + mode = numbers[0] if numbers else 0 + if mode == 0: + self._erase(self.row, self.column, self.columns) + for index in range(self.row + 1, self.rows): + self._erase(index, 0, self.columns) + elif mode == 1: + self._erase(self.row, 0, self.column + 1) + for index in range(0, self.row): + self._erase(index, 0, self.columns) + else: + for index in range(self.rows): + self._erase(index, 0, self.columns) + elif final == "K": + mode = numbers[0] if numbers else 0 + if mode == 0: + self._erase(self.row, self.column, self.columns) + elif mode == 1: + self._erase(self.row, 0, self.column + 1) + else: + self._erase(self.row, 0, self.columns) + elif final == "L": + for _ in range(first()): + del self.grid[self.bottom] + self.grid.insert(self.row, [Cell() for _ in range(self.columns)]) + elif final == "M": + for _ in range(first()): + del self.grid[self.row] + self.grid.insert(self.bottom, [Cell() for _ in range(self.columns)]) + elif final == "P": + for _ in range(first()): + del self.grid[self.row][self.column] + self.grid[self.row].append(Cell()) + elif final == "@": + for _ in range(first()): + self.grid[self.row].insert(self.column, Cell()) + del self.grid[self.row][-1] + elif final == "X": + self._erase(self.row, self.column, self.column + first()) + elif final == "S": + self._scroll_up(first()) + elif final == "T": + self._scroll_down(first()) + + def _sgr(self, parts: list[str]) -> None: + if not parts or parts == [""]: + parts = ["0"] + index = 0 + while index < len(parts): + part = parts[index] + code = int(part) if part.isdigit() else 0 + if code == 0: + self.bold = self.dim = self.reverse = False + self.foreground = self.background = None + elif code == 1: + self.bold = True + elif code == 2: + self.dim = True + elif code == 7: + self.reverse = True + elif code == 22: + self.bold = self.dim = False + elif code == 27: + self.reverse = False + elif 30 <= code <= 37: + self.foreground = code - 30 + elif code == 39: + self.foreground = None + elif 40 <= code <= 47: + self.background = code - 40 + elif code == 49: + self.background = None + elif 90 <= code <= 97: + self.foreground = code - 90 + 8 + elif 100 <= code <= 107: + self.background = code - 100 + 8 + elif code in (38, 48): + mode = int(parts[index + 1]) if index + 1 < len(parts) else 5 + if mode == 5: + value = int(parts[index + 2]) if index + 2 < len(parts) else 0 + index += 2 + else: + value = 256 + index += 4 + if code == 38: + self.foreground = value + else: + self.background = value + index += 1 + + +# --------------------------------------------------------------------------- +# Driving the real program. + + +class Session: + """The picker running on a real pty, with a screen model beside it.""" + + def __init__( + self, + *records, + term: str = "xterm-256color", + environ=None, + projects=(), + closed=(), + transcripts=(), + fail_commands=(), + ): + self.base = Path(tempfile.mkdtemp(prefix="tui-accept.")) + self.snapshot = self.base / "inventory.json" + self.snapshot.write_text(json.dumps(document(*records)), encoding="utf-8") + self.sp_log = self.base / "sp.log" + status = write_executable( + self.base / "fake-status", + f'#!/usr/bin/env bash\ncat "{self.snapshot}"\n', + ) + failures = "|".join(re.escape(command) for command in fail_commands) or "__never__" + sp = write_executable( + self.base / "fake-sp", + "#!/usr/bin/env bash\n" + f'printf "%s\\n" "$*" >> "{self.sp_log}"\n' + "if [[ $1 == account && $2 == choices ]]; then\n" + " printf '%s\\n' '{\"choices\":[{\"alias\":\"primary\",\"eligible\":true},{\"alias\":\"spare\",\"eligible\":true}]}'\n" + " exit 0\n" + "fi\n" + f"[[ $1 =~ ^({failures})$ ]] && exit 2\n" + "exit 0\n", + ) + projects_file = self.base / "projects.tsv" + projects_file.write_text( + "".join(f"{alias}\tclaude\t{path}\n" for alias, path in projects), + encoding="utf-8", + ) + closed_file = self.base / "closed.jsonl" + closed_file.write_text( + "".join(json.dumps(record) + "\n" for record in closed), + encoding="utf-8", + ) + # A conversation can only come back if its record is still on this + # machine, and this screen offers a Restore, so the cases that expect + # one put the record there. + for provider, uuid in transcripts: + if provider == "codex": + folder = self.base / ".codex" / "sessions" / "2026" / "08" + folder.mkdir(parents=True, exist_ok=True) + (folder / f"rollout-2026-08-15T00-00-00-{uuid}.jsonl").write_text( + '{"type":"user"}\n', encoding="utf-8" + ) + continue + folder = self.base / ".claude" / "projects" / "-srv-project" + folder.mkdir(parents=True, exist_ok=True) + (folder / f"{uuid}.jsonl").write_text( + '{"type":"user"}\n', encoding="utf-8" + ) + # No COLUMNS and no LINES: the size has to come from the pty itself, + # which is where it comes from for a person. + self.environ = { + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "HOME": str(self.base), + "TERM": term, + "PYTHONDONTWRITEBYTECODE": "1", + "SESSION_KIT_STATUS_CMD": str(status), + "SESSION_KIT_SP_CMD": str(sp), + "SESSION_KIT_STATE_DIR": str(self.base / "state"), + "SESSION_KIT_CLOSED_LEDGER": str(self.base / "closed.jsonl"), + "SESSION_KIT_PROJECTS_FILE": str(self.base / "projects.tsv"), + "SESSION_KIT_TUI_MODELS": "claude-opus-5,claude-sonnet-5", + } + self.environ.update(environ or {}) + self.screen = Emulator(LINES, COLUMNS) + self.raw = bytearray() + self.status: int | None = None + self.pid, self.descriptor = pty.fork() + if self.pid == 0: + try: + os.chdir(self.base) + os.execve( + "/usr/bin/python3", ["python3", os.fspath(PICKER)], self.environ + ) + finally: + os._exit(127) + fcntl.ioctl( + self.descriptor, + termios.TIOCSWINSZ, + struct.pack("HHHH", LINES, COLUMNS, 0, 0), + ) + + # -- lifetime ----------------------------------------------------------- + + def close(self) -> None: + if self.status is None: + try: + os.kill(self.pid, signal.SIGKILL) + _, self.status = os.waitpid(self.pid, 0) + except (ProcessLookupError, ChildProcessError): + pass + try: + os.close(self.descriptor) + except OSError: + pass + shutil.rmtree(self.base, ignore_errors=True) + + def _pump(self, seconds: float) -> bool: + """Read whatever the program has said. False once it has ended.""" + + ready, _, _ = select.select([self.descriptor], [], [], seconds) + if not ready: + return True + try: + chunk = os.read(self.descriptor, 65536) + except OSError as failure: + if failure.errno != errno.EIO: + raise + return False + if not chunk: + return False + self.raw.extend(chunk) + self.screen.feed(chunk) + return True + + def expect(self, predicate, *, timeout: float = 15.0, what: str = "") -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(self.screen): + return + if not self._pump(0.05): + break + if predicate(self.screen): + return + raise AssertionError( + f"the screen never showed {what or predicate}\n--- screen ---\n" + + self.screen.text() + ) + + def type(self, keystrokes: bytes) -> None: + os.write(self.descriptor, keystrokes) + + def settle(self, seconds: float = 0.6) -> None: + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if not self._pump(0.05): + return + + def wait(self, timeout: float = 15.0) -> int: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + alive = self._pump(0.05) + waited, raw = os.waitpid(self.pid, os.WNOHANG) + if waited == self.pid: + self.status = raw + return os.waitstatus_to_exitcode(raw) + if not alive: + _, raw = os.waitpid(self.pid, 0) + self.status = raw + return os.waitstatus_to_exitcode(raw) + raise AssertionError( + "the picker never left\n--- screen ---\n" + self.screen.text() + ) + + def sp_calls(self) -> list[str]: + if not self.sp_log.exists(): + return [] + return [ + line for line in self.sp_log.read_text(encoding="utf-8").splitlines() if line + ] + + +def terminfo_directory(term: str) -> str | None: + """Where this terminal's description lives, if it lives anywhere. + + The picker runs with HOME pointed at a sandbox, so a description under + the real ~/.terminfo would vanish from under it. Finding the directory + here lets the child be told about it explicitly. + """ + + initial = term[0] + candidates = [ + Path(os.environ["HOME"]) / ".terminfo" if os.environ.get("HOME") else None, + Path("/etc/terminfo"), + Path("/usr/share/terminfo"), + Path("/lib/terminfo"), + ] + for root in candidates: + if root is None: + continue + for leaf in (root / initial / term, root / f"{ord(initial):02x}" / term): + if leaf.exists(): + return str(root) + return None + + +def estate(): + """One long title, one short, one open elsewhere, one machine session.""" + + return ( + row( + "Orphaned Record Audit", + number=3, + needs_you=True, + account_alias="primary", + model="opus-4.6", + subagents=2, + age_seconds=5400, + quiet_seconds=30, + display_color="red", + ), + row( + "Matrix", + number=17, + provider="codex", + account_alias="spare", + model="gpt-5-codex", + agent_status="running", + quiet_seconds=40, + age_seconds=18_000, + display_color="blue", + ), + row( + "A session title long enough to run past its own column and drag the " + "next one along with it", + number=54, + availability="attached", + account_alias="third", + agent_status="running", + quiet_seconds=20, + age_seconds=14_400, + display_color="sea", + ), + row("drill", number=71, origin="machine", display_color="green"), + ) + + +class TerminalCaseMixin: + term = "xterm-256color" + + @classmethod + def setUpClass(cls) -> None: + cls.terminfo = terminfo_directory(cls.term) + if cls.terminfo is None: + raise unittest.SkipTest(f"no terminfo for {cls.term}") + + def open(self, *records, environ=None, **keywords) -> Session: + passed = {"TERMINFO_DIRS": f"{self.terminfo}:"} + passed.update(environ or {}) + session = Session(*(records or estate()), term=self.term, environ=passed, **keywords) + self.addCleanup(session.close) + session.expect( + lambda screen: screen.find(" ready ·") >= 0, + what="the opening screen", + ) + session.expect( + lambda screen: screen.find("open #") >= 0, what="the footer" + ) + session.settle() + return session + + +class ColorTests(TerminalCaseMixin, unittest.TestCase): + def test_the_screen_is_drawn_in_colour(self) -> None: + session = self.open() + self.assertTrue( + has_color_bytes(bytes(session.raw)), + "no colour sequence reached the terminal", + ) + self.assertTrue(session.screen.any_color(), "no cell on the screen carries a colour") + + def test_there_is_no_attention_summary_line(self) -> None: + """Removed by operator ruling, 2026-08-15 -- on a real terminal.""" + session = self.open() + self.assertEqual(-1, session.screen.find("needs you:")) + + def test_the_footer_keys_are_coloured(self) -> None: + session = self.open() + index = session.screen.find("open #") + self.assertGreaterEqual(index, 0) + self.assertTrue( + session.screen.colored_cells(index), + f"the footer names no key in colour: {session.screen.line(index)!r}", + ) + + def test_the_session_numbers_and_provider_identities_are_coloured(self) -> None: + session = self.open() + index = session.screen.find("Orphaned Record Audit") + self.assertGreaterEqual(index, 0) + line = session.screen.line(index) + number = re.search(r"\b3\b", line) + self.assertIsNotNone(number, line) + self.assertTrue( + all( + cell.foreground is not None + for cell in session.screen.grid[index][number.start() : number.end()] + ), + "the session number has no colour", + ) + for title, provider, color in ( + ("Orphaned Record Audit", "CLD", "yellow"), + ("Matrix", "CDX", "cyan"), + ): + line_index = session.screen.find(title) + line = session.screen.line(line_index) + start = line.index(provider) + expected, _ = SESSION_PALETTE[color] + cells = session.screen.grid[line_index][start : start + len(provider)] + self.assertTrue(all(cell.foreground == expected for cell in cells), line) + + def test_titles_carry_three_exact_identity_colours(self) -> None: + session = self.open() + fixtures = ( + ("Orphaned Record Audit", "red"), + ("Matrix", "blue"), + ("A session title long", "sea"), + ) + seen = set() + for title, color in fixtures: + line_index = session.screen.find(title) + self.assertGreaterEqual(line_index, 0, session.screen.text()) + line = session.screen.line(line_index) + start = line.index(title) + expected_index, expected_rgb = SESSION_PALETTE[color] + cells = session.screen.grid[line_index][start : start + len(title)] + self.assertTrue(cells) + self.assertTrue( + all(cell.foreground == expected_index for cell in cells), + f"{title!r} is not colour {expected_index}: {line!r}", + ) + self.assertEqual(expected_rgb, session.screen.palette.get(expected_index)) + seen.add(cells[0].foreground) + self.assertEqual(3, len(seen)) + + def test_selected_title_keeps_its_colour_over_reverse_video(self) -> None: + session = self.open() + title = "Orphaned Record Audit" + line_index = session.screen.find(title) + line = session.screen.line(line_index) + start = line.index(title) + expected, _ = SESSION_PALETTE["red"] + cells = session.screen.grid[line_index][start : start + len(title)] + self.assertTrue(all(cell.foreground == expected and cell.reverse for cell in cells)) + padding = session.screen.grid[line_index][start + len(title)] + self.assertEqual(" ", padding.char, line) + self.assertTrue(padding.reverse, line) + self.assertNotEqual(expected, padding.foreground, line) + + def test_no_colour_reaches_a_terminal_that_asked_for_none(self) -> None: + session = self.open(environ={"NO_COLOR": "1"}) + self.assertFalse( + has_color_bytes(bytes(session.raw)), + "a colour sequence reached a NO_COLOR terminal", + ) + self.assertFalse(session.screen.any_color()) + self.assertEqual({}, session.screen.palette) + # Still readable: the same runs keep their weight. + self.assertGreaterEqual(session.screen.find("Orphaned Record Audit"), 0) + + def test_the_kit_s_own_switch_turns_colour_off_too(self) -> None: + session = self.open(environ={"SESSION_KIT_NO_COLOR": "1"}) + self.assertFalse(has_color_bytes(bytes(session.raw))) + + +class AlignmentTests(TerminalCaseMixin, unittest.TestCase): + def _provider_columns(self, screen) -> list[int]: + return [ + match.start(1) + for line in screen.lines() + if (match := re.search(r"\| (CLD|CDX|SHL) \|", line)) + ] + + def test_every_session_row_puts_its_provider_in_the_same_column(self) -> None: + session = self.open() + columns = self._provider_columns(session.screen) + self.assertGreaterEqual(len(columns), 3, session.screen.text()) + self.assertEqual( + 1, + len(set(columns)), + f"provider columns disagree: {columns}\n{session.screen.text()}", + ) + + def test_no_rendered_line_runs_past_the_terminal(self) -> None: + session = self.open() + for index, line in enumerate(session.screen.lines()): + self.assertLessEqual(len(line), COLUMNS, f"line {index}: {line!r}") + + def test_a_title_too_long_for_its_column_is_cut_not_carried(self) -> None: + session = self.open() + index = session.screen.find("A session title long") + self.assertGreaterEqual(index, 0) + self.assertIn("…", session.screen.line(index)) + + def test_one_long_title_does_not_cut_everyone_else_s_detail(self) -> None: + session = self.open() + index = session.screen.find("Orphaned Record Audit") + self.assertGreaterEqual(index, 0) + self.assertIn("opus-4.6", session.screen.line(index), session.screen.text()) + self.assertIn( + "needs you | 2 subagents | now", + session.screen.line(index), + ) + matrix = session.screen.line(session.screen.find("Matrix")) + self.assertIn("gpt-5-codex", matrix) + # Both rows carry the one compact time column starting at the same + # place; the nonzero count occupies its own aligned field before it. + self.assertTrue(matrix.rstrip().endswith("| now"), matrix) + self.assertEqual( + matrix.rindex("| now"), + session.screen.line(index).rindex("| now"), + ) + + def test_the_columns_hold_when_the_list_is_filtered(self) -> None: + session = self.open() + session.type(b"a") + session.expect(lambda screen: screen.find("Filter: a") >= 0, what="the filter") + session.settle() + columns = self._provider_columns(session.screen) + self.assertEqual(1, len(set(columns)), f"{columns}\n{session.screen.text()}") + + def test_account_state_and_age_fields_start_in_the_same_columns(self) -> None: + session = self.open() + starts = [] + for title in ( + "Orphaned Record Audit", + "Matrix", + "A session title long", + ): + line = session.screen.line(session.screen.find(title)) + # A blank last field leaves a visible trailing pipe after line() + # removes spaces. The pipe cells themselves still define every + # field boundary exactly as they do on a real terminal. + separators = [index for index, character in enumerate(line) if character == "|"] + self.assertGreaterEqual(len(separators), 5, line) + starts.append( + ( + separators[1] + 2, + separators[3] + 2, + separators[4] + 2, + ) + ) + self.assertEqual(1, len(set(starts)), f"field columns disagree: {starts}") + + +class InputTests(TerminalCaseMixin, unittest.TestCase): + def test_a_letter_filters_and_escape_clears_it(self) -> None: + session = self.open() + session.type(b"m") + session.expect(lambda screen: screen.find("Filter: m") >= 0, what="the filter") + session.type(ESC) + session.expect( + lambda screen: screen.find("Filter:") < 0, what="the filter cleared" + ) + self.assertGreaterEqual(session.screen.find("Orphaned Record Audit"), 0) + + def test_escape_on_an_empty_screen_leaves(self) -> None: + session = self.open() + session.type(b"m") + session.expect(lambda screen: screen.find("Filter: m") >= 0, what="the filter") + session.type(ESC) + session.expect(lambda screen: screen.find("Filter:") < 0, what="the filter cleared") + session.type(ESC) + self.assertEqual(0, session.wait()) + + def test_q_with_nothing_typed_leaves(self) -> None: + session = self.open() + session.type(b"q") + self.assertEqual(0, session.wait()) + + def test_q_while_filtering_is_an_ordinary_letter(self) -> None: + session = self.open() + session.type(b"a") + session.expect(lambda screen: screen.find("Filter: a") >= 0, what="the filter") + session.type(b"q") + session.expect(lambda screen: screen.find("Filter: aq") >= 0, what="the letter q") + + def test_ctrl_d_leaves_from_anywhere(self) -> None: + session = self.open() + session.type(b"al") + session.expect(lambda screen: screen.find("Filter: al") >= 0, what="the filter") + session.type(CTRL_D) + self.assertEqual(0, session.wait()) + + def test_a_digit_marks_a_row_instead_of_filtering(self) -> None: + session = self.open() + session.type(b"3") + session.expect(lambda screen: screen.find("Mark: 3") >= 0, what="the mark line") + index = session.screen.find("Orphaned Record Audit") + self.assertTrue( + session.screen.line(index).startswith("✓"), + f"row not ticked: {session.screen.line(index)!r}", + ) + self.assertLess(session.screen.find("Filter:"), 0) + + def test_the_footer_names_the_way_out(self) -> None: + session = self.open() + index = session.screen.find("open #") + self.assertIn("esc quit", session.screen.line(index)) + + +class SecondaryPageTests(TerminalCaseMixin, unittest.TestCase): + def one(self, **keywords) -> Session: + return self.open( + row( + "Orphaned Record Audit", + number=3, + needs_you=True, + account_alias="primary", + model="opus-4.6", + quiet_seconds=30, + display_color="red", + ), + **keywords, + ) + + def move_to(self, session: Session, offset: int, heading: str) -> None: + session.type(DOWN * offset + ENTER) + session.expect(lambda screen: screen.line(0).strip() == heading, what=heading) + session.settle() + + def assert_green(self, session: Session, line: int, text: str) -> None: + rendered = session.screen.line(line) + start = rendered.index(text) + expected, _ = SESSION_PALETTE["green"] + self.assertTrue( + all(cell.foreground == expected for cell in session.screen.grid[line][start : start + len(text)]), + rendered, + ) + + def test_help_is_the_old_picker_key_table_with_a_coloured_way_back(self) -> None: + session = self.one() + self.move_to(session, 4, "Picker help") + for heading in ("Sessions", "Needs you", "The list", "Quitting"): + self.assertGreaterEqual(session.screen.find(heading), 0, session.screen.text()) + rows = [session.screen.line(session.screen.find(key)) for key in ("Enter Open", "number Mark")] + self.assertEqual(1, len({line.index("Open") if "Open" in line else line.index("Mark") for line in rows})) + footer = session.screen.find("↵ back") + self.assertGreaterEqual(footer, 0) + self.assert_green(session, footer, "↵") + self.assertIn("ctrl-d quit", session.screen.line(footer)) + + def test_project_chooser_mirrors_the_numbered_old_project_step(self) -> None: + session = self.one(projects=(("main", "/srv/project"),)) + self.move_to(session, 2, "Project") + line = session.screen.find("main") + self.assertIn("main", session.screen.line(line)) + self.assertIn("| /srv/project", session.screen.line(line)) + path = session.screen.line(line).index("/srv/project") + self.assertTrue(all(cell.dim for cell in session.screen.grid[line][path : path + len("/srv/project")])) + footer = session.screen.find("esc back") + self.assert_green(session, footer, "esc") + + def test_a_conversation_with_no_transcript_is_not_offered_a_restore(self) -> None: + """The screen offers a Restore, so it may not offer one that fails. + + This screen reads the ledger straight off disk and answered the + question from the shape of the record -- a provider and a uuid -- so a + conversation whose transcript is gone was listed with no mark and a + Restore beside it. The other two surfaces had been taught to ask. + """ + closed = ({ + "provider": "codex", + "uuid": "00000000-0000-4000-8000-000000000004", + "title": "Transcript Gone", + "cwd": "/srv/project", + "closed_at_unix_ms": 1, + "account_alias": "spare", + },) + session = self.one(closed=closed) + self.move_to(session, 3, "Closed sessions") + line = session.screen.find("Transcript Gone") + self.assertIn("history only", session.screen.line(line)) + session.type(ENTER) + session.expect(lambda screen: screen.find("History") >= 0, what="closed actions") + self.assertEqual(-1, session.screen.find("Restore")) + self.assertGreaterEqual( + session.screen.find("transcript is no longer on this machine"), 0 + ) + + def test_closed_sessions_use_the_old_bracketed_provider_row(self) -> None: + closed = ({ + "provider": "codex", + "uuid": "00000000-0000-4000-8000-000000000003", + "title": "Closed Matrix Audit", + "cwd": "/srv/project", + "closed_at_unix_ms": 1, + "account_alias": "spare", + },) + session = self.one( + closed=closed, + transcripts=(("codex", "00000000-0000-4000-8000-000000000003"),), + ) + self.move_to(session, 3, "Closed sessions") + line = session.screen.find("Closed Matrix Audit") + self.assertIn("[CDX] Closed Matrix Audit [login time unknown]", session.screen.line(line)) + start = session.screen.line(line).index("CDX") + expected, _ = SESSION_PALETTE["cyan"] + self.assertTrue(all(cell.foreground == expected for cell in session.screen.grid[line][start : start + 3])) + footer = session.screen.find("↵ actions") + self.assert_green(session, footer, "↵") + self.assertIn("esc back", session.screen.line(footer)) + session.type(ENTER) + # Wait for the whole page, not its first word: a loaded runner can + # capture a frame where Restore has flushed and History has not. + session.expect( + lambda screen: screen.find("Restore") >= 0 + and screen.find("History") >= 0 + and screen.find("[CDX] login time unknown") >= 0, + what="closed actions", + ) + self.assertGreaterEqual(session.screen.find("History"), 0) + subtitle = session.screen.find("[CDX] login time unknown") + provider = session.screen.line(subtitle).index("CDX") + self.assertTrue( + all( + cell.foreground == expected + for cell in session.screen.grid[subtitle][provider : provider + 3] + ) + ) + self.assertIn("esc back", session.screen.line(session.screen.find("↵ choose"))) + + def test_action_and_close_flow_keep_identity_attention_and_back_roles(self) -> None: + session = self.open( + row( + "Orphaned Record Audit", + number=3, + availability="attached", + account_alias="primary", + model="opus-4.6", + display_color="red", + ) + ) + session.type(b"3" + ENTER) + session.expect(lambda screen: screen.find("Change account") >= 0, what="the action page") + session.expect(lambda screen: screen.find("↵ choose") >= 0, what="the action footer") + self.assertGreaterEqual(session.screen.find("Open elsewhere."), 0) + self.assertGreaterEqual(session.screen.find("Move it here"), 0) + title_line = session.screen.find("Orphaned Record Audit") + title = session.screen.line(title_line) + start = title.index("Orphaned Record Audit") + expected_red, _ = SESSION_PALETTE["red"] + self.assertTrue(all(cell.foreground == expected_red for cell in session.screen.grid[title_line][start : start + 21])) + close_line = session.screen.find("everything inside will end") + self.assertIn("| the shell and everything inside will end", session.screen.line(close_line)) + close_start = session.screen.line(close_line).index("Close") + expected_yellow, _ = SESSION_PALETTE["yellow"] + self.assertTrue(all(cell.foreground == expected_yellow for cell in session.screen.grid[close_line][close_start : close_start + 5])) + footer = session.screen.find("↵ choose") + self.assert_green(session, footer, "↵") + self.assertIn("esc back", session.screen.line(footer)) + + def test_rename_prompt_mirrors_the_old_prompt_and_names_back(self) -> None: + session = self.one() + session.type(b"3" + ENTER + DOWN * 3 + ENTER) + session.expect(lambda screen: screen.find("New name (↵ back) ❯") >= 0, what="rename") + line = session.screen.find("New name") + self.assert_green(session, line, "New name (↵ back) ❯") + self.assertIn("esc back", session.screen.line(session.screen.find("↵ choose"))) + + def test_account_model_and_color_choosers_share_the_picker_face(self) -> None: + for offset, heading, choice in ( + (1, "Change to account", "primary"), + (2, "Change model", "claude-opus-5"), + (4, "Color", "red"), + ): + with self.subTest(heading=heading): + session = self.one() + session.type(b"3" + ENTER + DOWN * offset + ENTER) + # A PTY read can split one atomic repaint after its title. + # Wait for the chosen panel's body as well, rather than + # inspecting the previous action list under the new title. + session.expect( + lambda screen, h=heading, c=choice: ( + screen.line(0).strip() == h and screen.find(c) >= 0 + ), + what=heading, + ) + line = session.screen.find(choice) + self.assertGreaterEqual(line, 0, session.screen.text()) + self.assertIn("↵ choose", session.screen.line(session.screen.find("↵ choose"))) + number = re.search(r"\b1\b", session.screen.line(line)) + self.assertIsNotNone(number, session.screen.line(line)) + self.assert_green(session, line, number.group()) + + def test_new_session_provider_page_uses_old_names_and_provider_colours(self) -> None: + session = self.one() + self.move_to(session, 1, "New session") + for label, color in (("Claude Code", "yellow"), ("Codex", "cyan"), ("Shell", "green")): + line = session.screen.find(label) + start = session.screen.line(line).index(label) + expected, _ = SESSION_PALETTE[color] + self.assertTrue(all(cell.foreground == expected for cell in session.screen.grid[line][start : start + len(label)])) + self.assertIn("esc back", session.screen.line(session.screen.find("↵ choose"))) + + def test_success_and_refusal_messages_have_distinct_colour_roles(self) -> None: + success = self.one() + success.type(b"3" + ENTER + ENTER) + success.expect(lambda screen: screen.find("Closed Orphaned Record Audit.") >= 0, what="close result") + line = success.screen.find("Closed Orphaned Record Audit.") + self.assert_green(success, line, "Closed Orphaned Record Audit.") + + refused = self.one(fail_commands=("picker-close",)) + refused.type(b"3" + ENTER + ENTER) + refused.expect(lambda screen: screen.find("Close was refused. Nothing changed.") >= 0, what="refusal") + line = refused.screen.find("Close was refused. Nothing changed.") + start = refused.screen.line(line).index("Close") + expected, _ = SESSION_PALETTE["yellow"] + self.assertTrue(all(cell.foreground == expected for cell in refused.screen.grid[line][start : start + len("Close was refused. Nothing changed.")])) + + def test_secondary_pages_emit_no_colour_when_asked_for_none(self) -> None: + session = self.one(environ={"NO_COLOR": "1"}) + self.move_to(session, 4, "Picker help") + self.assertFalse(has_color_bytes(bytes(session.raw))) + self.assertFalse(session.screen.any_color()) + + +class PasteTests(TerminalCaseMixin, unittest.TestCase): + def test_a_pasted_close_command_closes_nothing(self) -> None: + session = self.open() + session.type(b"k 17\n") + session.settle(1.0) + self.assertEqual([], session.sp_calls(), "a paste reached a session command") + self.assertGreaterEqual(session.screen.find("Filter: k 17"), 0, session.screen.text()) + session.type(CTRL_D) + self.assertEqual(0, session.wait()) + + def test_a_pasted_number_run_marks_and_runs_nothing_on_its_own(self) -> None: + session = self.open() + session.type(b"3,17") + session.expect(lambda screen: screen.find("Mark: 3,17") >= 0, what="the marks") + self.assertEqual([], session.sp_calls()) + session.type(CTRL_D) + self.assertEqual(0, session.wait()) + + +class GhosttyColorTests(ColorTests): + term = "xterm-ghostty" + + +class GhosttyAlignmentTests(AlignmentTests): + term = "xterm-ghostty" + + +class GhosttyInputTests(InputTests): + term = "xterm-ghostty" + + +class GhosttyPasteTests(PasteTests): + term = "xterm-ghostty" + + +class GhosttySecondaryPageTests(SecondaryPageTests): + term = "xterm-ghostty" + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tui_actions.py b/tests/test_tui_actions.py new file mode 100644 index 0000000..bd7b705 --- /dev/null +++ b/tests/test_tui_actions.py @@ -0,0 +1,565 @@ +"""What the screen runs, and what it does with the answer.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import shlex +import shutil +import signal +import stat +import subprocess +import tempfile +import time +import unittest + +from tests.tui_support import REPO, inventory, row, write_executable + +from sessionkit_inventory import closed_sessions as closed_ledger +from sessionkit_tui import actions +from sessionkit_tui.model import ClosedSession +from sessionkit_tui.runner import Runner, run_batch + +LAUNCHER = REPO / "bin" / "shpool_login_launcher" + + +class PlanTests(unittest.TestCase): + def _session(self, **keywords): + return inventory(row("Alpha", number=4, **keywords)).sessions[0] + + def test_every_action_names_the_proof_bound_command(self) -> None: + session = self._session() + self.assertEqual(("sp", "picker-open", "{proof}"), actions.open_session(session).argv) + self.assertEqual(("sp", "picker-history", "{proof}"), actions.history(session).argv) + self.assertEqual(("sp", "picker-close", "{proof}"), actions.close(session).argv) + self.assertEqual( + ("sp", "picker-name", "{proof}", "New"), actions.rename(session, "New").argv + ) + self.assertEqual( + ("sp", "picker-account-switch", "{proof}", "primary"), + actions.change_account(session, "primary").argv, + ) + + def test_open_uses_takeover_for_a_session_open_elsewhere(self) -> None: + session = self._session(availability="attached") + self.assertEqual("picker-takeover", actions.open_session(session).argv[1]) + + def test_the_verbs_that_take_no_proof_are_selected_by_number(self) -> None: + session = self._session() + self.assertEqual(("sp", "color", "4", "red"), actions.color(session, "red").argv) + self.assertEqual( + ("sp", "change-model", "4", "opus"), actions.change_model(session, "opus").argv + ) + + def test_change_model_answers_in_the_release_s_own_words(self) -> None: + session = self._session() + self.assertTrue(actions.change_model(session, "opus").quote_stderr) + self.assertFalse(actions.close(session).quote_stderr) + + def test_restore_carries_the_closed_conversation(self) -> None: + item = ClosedSession( + key="u1", + title="Blueprint", + provider="claude", + uuid="u1", + cwd="/srv/project", + account_alias="primary", + closed_at_unix_ms=1, + ) + self.assertEqual( + ("sp", "restore-exact", "claude", "u1", "/srv/project", "primary"), + actions.restore(item).argv, + ) + + def test_the_palettes_are_the_ones_each_provider_accepts(self) -> None: + self.assertEqual("red", actions.colors_for("claude")[0]) + self.assertEqual("lime", actions.colors_for("codex")[0]) + + +class ProofTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix="tui-proof.") + self.state = Path(self.temp.name) + self.runner = Runner( + sp_cmd="/bin/true", status_cmd="/bin/true", state_dir=self.state, environ={} + ) + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_a_proof_names_the_exact_session(self) -> None: + record = row("Alpha", number=1) + estate = inventory(record) + path = self.runner.write_proof(estate.sessions[0], estate) + self.assertIsNotNone(path) + proof = json.loads(Path(path).read_text(encoding="utf-8")) + self.assertEqual("session-kit-picker-session-v1", proof["proof_type"]) + self.assertEqual(record["shpool_id_raw"], proof["shpool_id"]) + self.assertEqual(record["identity"]["uuid"], proof["uuid"]) + self.assertEqual(record["shpool_shell"]["pid"], proof["shell_pid"]) + self.assertEqual(77, proof["daemon_pid"]) + + def test_a_proof_is_readable_only_by_its_owner(self) -> None: + estate = inventory(row("Alpha", number=1)) + path = self.runner.write_proof(estate.sessions[0], estate) + self.assertEqual(0o600, stat.S_IMODE(os.lstat(path).st_mode)) + + def test_a_cached_list_writes_no_proof(self) -> None: + estate = inventory(row("Alpha", number=1), stale=True) + self.assertIsNone(self.runner.write_proof(estate.sessions[0], estate)) + + +class ExecuteTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix="tui-run.") + self.base = Path(self.temp.name) + self.log = self.base / "sp.log" + self.sp = write_executable( + self.base / "sp", + "#!/usr/bin/env bash\n" + f'printf "%s\\n" "$*" >> "{self.log}"\n' + f'printf "confirm=%s\\n" "${{SESSION_KIT_CONFIRM_ID:-}}" >> "{self.log}"\n' + "exit 0\n", + ) + self.runner = Runner( + sp_cmd=str(self.sp), + status_cmd="/bin/true", + state_dir=self.base, + environ=dict(os.environ), + ) + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_an_action_runs_the_verb_with_a_proof_and_the_confirmation_id(self) -> None: + estate = inventory(row("Alpha", number=1)) + session = estate.sessions[0] + ok, note = self.runner.execute(actions.close(session), estate, session=session) + self.assertTrue(ok) + self.assertEqual("", note) + written = self.log.read_text(encoding="utf-8") + self.assertIn("picker-close", written) + self.assertIn(f"confirm={session.shpool_id}", written) + + def test_the_proof_file_is_removed_afterwards(self) -> None: + estate = inventory(row("Alpha", number=1)) + self.runner.execute(actions.close(estate.sessions[0]), estate, session=estate.sessions[0]) + self.assertEqual([], sorted(p.name for p in self.base.glob("picker-proof.*"))) + + def test_a_verb_the_release_does_not_carry_answers_in_its_own_words(self) -> None: + refusal = "session-kit: there is no command named change-model" + write_executable( + self.sp, + "#!/usr/bin/env bash\n" f'echo "{refusal}" >&2\n' "exit 2\n", + ) + estate = inventory(row("Alpha", number=1)) + ok, note = self.runner.execute( + actions.change_model(estate.sessions[0], "opus"), estate + ) + self.assertFalse(ok) + self.assertEqual(refusal, note) + + def test_a_batch_runs_every_plan_and_reports_the_first_refusal(self) -> None: + write_executable( + self.sp, + "#!/usr/bin/env bash\n" + f'printf "%s\\n" "$*" >> "{self.log}"\n' + "exit 74\n", + ) + estate = inventory(row("Alpha", number=1), row("Beta", number=2)) + plans = [actions.close(session) for session in estate.sessions] + ok, _ = run_batch(self.runner, estate, plans) + self.assertFalse(ok) + self.assertEqual(2, len(self.log.read_text(encoding="utf-8").strip().splitlines())) + + def test_an_action_on_a_session_that_is_gone_changes_nothing(self) -> None: + estate = inventory(row("Alpha", number=1)) + plan = actions.close(estate.sessions[0]) + ok, note = self.runner.execute(plan, inventory(), session=None) + self.assertFalse(ok) + self.assertEqual("Nothing changed.", note) + + +class ReadingTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix="tui-read.") + self.base = Path(self.temp.name) + self.state = self.base / "state" + self.data = self.base / "data" + binaries = self.base / "bin" + for path in (self.state, self.data, binaries): + path.mkdir(mode=0o700) + shpool = binaries / "shpool" + shpool.write_text("#!/usr/bin/env bash\nexit 70\n", encoding="utf-8") + shpool.chmod(0o700) + self.environ = { + **os.environ, + "PATH": f"{binaries}:/usr/local/bin:/usr/bin:/bin", + "HOME": str(self.base / "home"), + "SESSION_KIT_STATE_DIR": str(self.state), + "SESSION_KIT_DATA_DIR": str(self.data), + "XDG_STATE_HOME": str(self.base / "xdg-state"), + "XDG_DATA_HOME": str(self.base / "xdg-data"), + "SESSION_KIT_CONFIG": str(self.base / "session-kit.toml"), + "SESSION_KIT_SHPOOL_CMD": str(shpool), + "SESSION_KIT_NONINTERACTIVE": "1", + } + + def tearDown(self) -> None: + self.temp.cleanup() + + def _runner(self, **environ): + return Runner( + sp_cmd="/bin/true", + status_cmd="/bin/true", + state_dir=self.state, + environ={**self.environ, **environ}, + ) + + def test_the_closed_ledger_is_read_newest_first(self) -> None: + ledger = self.base / "closed-sessions.jsonl" + ledger.write_text( + "\n".join( + json.dumps(record) + for record in ( + { + "provider": "claude", + "uuid": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + "title": "Older", + "cwd": "/srv/a", + "closed_at_unix_ms": 1, + }, + { + "provider": "codex", + "uuid": "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", + "title": "Newer", + "cwd": "/srv/b", + "closed_at_unix_ms": 2, + }, + ) + ) + + "\n", + encoding="utf-8", + ) + found = self._runner(SESSION_KIT_CLOSED_LEDGER=str(ledger)).closed_sessions() + self.assertEqual(["Newer", "Older"], [item.title for item in found]) + + def test_closed_sessions_include_rows_before_the_old_four_mib_bound(self) -> None: + ledger = self.base / "closed-sessions.jsonl" + oldest = ( + json.dumps( + { + "provider": "codex", + "uuid": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + "title": "Old Closed Conversation", + "cwd": "/srv/a", + "closed_at_unix_ms": 1, + }, + sort_keys=True, + ) + + "\n" + ).encode("utf-8") + filler = ( + json.dumps( + { + "provider": "shell", + "uuid": "", + "title": "Later shell history", + "cwd": "", + "closed_at_unix_ms": 2, + "origin": "human", + "shpool_id": "filler", + "account_alias": "", + }, + sort_keys=True, + ) + + "\n" + ).encode("utf-8") + with ledger.open("wb") as handle: + handle.write(oldest) + while handle.tell() <= closed_ledger.MAX_LEDGER_BYTES + 2048: + handle.write(filler) + + found = self._runner(SESSION_KIT_CLOSED_LEDGER=str(ledger)).closed_sessions() + self.assertIn("Old Closed Conversation", [item.title for item in found]) + + def test_an_absent_ledger_is_an_empty_list_and_not_an_error(self) -> None: + self.assertEqual((), self._runner().closed_sessions()) + + def test_a_malformed_ledger_is_not_reported_as_empty(self) -> None: + ledger = self.data / "closed-sessions.jsonl" + ledger.write_text("{not-json}\n", encoding="utf-8") + with self.assertRaisesRegex(Exception, "malformed row"): + self._runner().closed_sessions() + + def test_projects_come_from_the_projects_file(self) -> None: + path = self.base / "projects.tsv" + path.write_text("main\tclaude\t/srv/project\nbad\tcodex\trelative\n", encoding="utf-8") + found = self._runner(SESSION_KIT_PROJECTS_FILE=str(path)).projects() + self.assertEqual((("main", "/srv/project"),), found) + + def test_models_come_from_configuration_and_never_from_a_guess(self) -> None: + self.assertEqual((), self._runner().models("claude")) + inline = self._runner( + SESSION_KIT_TUI_MODELS="claude-opus, claude-sonnet" + ).models("claude") + self.assertEqual(("claude-opus", "claude-sonnet"), inline) + path = self.base / "models.tsv" + path.write_text( + "claude\tclaude-opus\ncodex\tgpt-5\n*\tclaude-shared\n", + encoding="utf-8", + ) + from_file = self._runner(SESSION_KIT_MODELS_FILE=str(path)).models("claude") + self.assertEqual(("claude-opus", "claude-shared"), from_file) + + def test_configured_models_use_the_same_provider_gate_as_the_change_verb( + self, + ) -> None: + from sessionkit_inventory.worker_model import ( + IntakeError, + configured_models, + validate_requested_model, + ) + + configured = "claude-opus-5,gpt-5-codex,opus-4.6,claude-sonnet-5" + offered = configured_models( + "claude", {"SESSION_KIT_TUI_MODELS": configured} + ) + self.assertEqual(("claude-opus-5", "claude-sonnet-5"), offered) + for model in offered: + self.assertEqual(model, validate_requested_model("claude", model)) + for refused in ("gpt-5-codex", "opus-4.6"): + with self.assertRaises(IntakeError): + validate_requested_model("claude", refused) + + +class LauncherTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix="tui-launch.") + self.base = Path(self.temp.name) + self.log = self.base / "tui-fallback.log" + self.trace = self.base / "trace" + self.switch = "on" + + def tearDown(self) -> None: + self.temp.cleanup() + + def _run(self, exit_code: int, *, stderr: str = ""): + stderr_command = f"printf '%s' {shlex.quote(stderr)} >&2\n" if stderr else "" + tui = write_executable( + self.base / "tui", + ( + "#!/usr/bin/env bash\n" + f'echo tui >> "{self.trace}"\n' + f"{stderr_command}" + f"exit {exit_code}\n" + ), + ) + fallback = write_executable( + self.base / "old-picker", + "#!/usr/bin/env bash\n" f'echo fallback >> "{self.trace}"\n' "exit 0\n", + ) + environ = dict(os.environ) + environ.update( + SESSION_KIT_TUI=self.switch, + SESSION_KIT_TUI_CMD=str(tui), + SESSION_KIT_PICKER_FALLBACK_CMD=str(fallback), + SESSION_KIT_TUI_FALLBACK_LOG=str(self.log), + SESSION_KIT_STATE_DIR=str(self.base), + TMPDIR=str(self.base), + ) + return subprocess.run( + [str(LAUNCHER)], env=environ, capture_output=True, text=True + ) + + def test_missing_python_does_not_hang_the_login_launcher(self) -> None: + tui = write_executable(self.base / "no-python-tui", "#!/bin/bash\nexit 0\n") + fallback = write_executable(self.base / "no-python-fallback", "#!/bin/bash\nexit 0\n") + command_dir = self.base / "commands" + command_dir.mkdir() + for name in ( + "bash", + "date", + "dirname", + "chmod", + "mkdir", + "mkfifo", + "mktemp", + "mv", + "rm", + "rmdir", + "sleep", + "stat", + "tail", + "wc", + ): + target = shutil.which(name) + self.assertIsNotNone(target, name) + (command_dir / name).symlink_to(target) + environ = dict(os.environ) + environ.update( + PATH=str(command_dir), + SESSION_KIT_TUI="on", + SESSION_KIT_TUI_CMD=str(tui), + SESSION_KIT_PICKER_FALLBACK_CMD=str(fallback), + SESSION_KIT_TUI_FALLBACK_LOG=str(self.log), + SESSION_KIT_STATE_DIR=str(self.base), + TMPDIR=str(self.base), + ) + done = subprocess.run( + [str(LAUNCHER)], + env=environ, + capture_output=True, + text=True, + timeout=2, + ) + self.assertEqual(0, done.returncode, done.stderr) + + def test_picker_child_holding_stderr_does_not_hang_clean_exit(self) -> None: + child_pid = self.base / "child.pid" + tui = write_executable( + self.base / "lingering-child-tui", + ( + "#!/usr/bin/env bash\n" + "sleep 30 >/dev/null &\n" + f'printf "%s\\n" "$!" > "{child_pid}"\n' + "exit 0\n" + ), + ) + fallback = write_executable(self.base / "unused-fallback", "#!/bin/sh\nexit 1\n") + environ = dict(os.environ) + environ.update( + SESSION_KIT_TUI="on", + SESSION_KIT_TUI_CMD=str(tui), + SESSION_KIT_PICKER_FALLBACK_CMD=str(fallback), + SESSION_KIT_TUI_FALLBACK_LOG=str(self.log), + SESSION_KIT_STATE_DIR=str(self.base), + TMPDIR=str(self.base), + ) + try: + done = subprocess.run( + [str(LAUNCHER)], + env=environ, + capture_output=True, + text=True, + timeout=2, + ) + self.assertEqual(0, done.returncode, done.stderr) + self.assertFalse(self.log.exists()) + finally: + if child_pid.exists(): + try: + os.kill(int(child_pid.read_text(encoding="utf-8")), signal.SIGTERM) + except ProcessLookupError: + pass + + def test_a_deliberate_leave_passes_straight_through(self) -> None: + done = self._run(0) + self.assertEqual(0, done.returncode) + self.assertEqual(["tui"], self.trace.read_text(encoding="utf-8").split()) + self.assertFalse(self.log.exists()) + + def test_an_abnormal_exit_logs_one_line_and_hands_over(self) -> None: + done = self._run(3) + self.assertEqual(0, done.returncode) + self.assertEqual(["tui", "fallback"], self.trace.read_text(encoding="utf-8").split()) + recorded = self.log.read_text(encoding="utf-8").strip().splitlines() + self.assertEqual(1, len(recorded)) + self.assertIn("exit 3", recorded[0]) + self.assertIn("stderr=-", recorded[0]) + + def test_an_abnormal_exit_preserves_and_records_stderr(self) -> None: + failure = "session-kit: could not draw\nsecond line\t(detail)\n" + done = self._run(1, stderr=failure) + self.assertEqual(0, done.returncode) + self.assertEqual(failure, done.stderr) + recorded = self.log.read_text(encoding="utf-8").strip().splitlines() + self.assertEqual(1, len(recorded)) + self.assertIn( + r"stderr=session-kit: could not draw\nsecond line\t(detail)", + recorded[0], + ) + + def test_an_abnormal_exit_logs_only_the_last_four_kib_of_stderr(self) -> None: + failure = "discarded-prefix\n" + ("x" * 5000) + "\nuseful-tail\n" + done = self._run(1, stderr=failure) + self.assertEqual(0, done.returncode) + self.assertEqual(failure, done.stderr) + recorded = self.log.read_text(encoding="utf-8").strip().splitlines() + self.assertEqual(1, len(recorded)) + self.assertNotIn("discarded-prefix", recorded[0]) + self.assertIn("useful-tail", recorded[0]) + logged_stderr = recorded[0].split("\tstderr=", 1)[1] + self.assertLessEqual(len(logged_stderr), 4100) + + def test_stderr_capture_is_bounded_on_disk_while_tui_is_running(self) -> None: + marker = self.base / "noisy-ready" + tui = write_executable( + self.base / "noisy-tui", + ( + "#!/usr/bin/env bash\n" + "python3 -c 'import sys; sys.stderr.write(\"x\" * (6 * 1024 * 1024))'\n" + f'touch "{marker}"\n' + "sleep 1\n" + "exit 1\n" + ), + ) + fallback = write_executable(self.base / "bounded-fallback", "#!/bin/sh\nexit 0\n") + environ = dict(os.environ) + environ.update( + SESSION_KIT_TUI="on", + SESSION_KIT_TUI_CMD=str(tui), + SESSION_KIT_PICKER_FALLBACK_CMD=str(fallback), + SESSION_KIT_TUI_FALLBACK_LOG=str(self.log), + SESSION_KIT_STATE_DIR=str(self.base), + TMPDIR=str(self.base), + ) + forwarded = self.base / "forwarded-stderr" + with forwarded.open("wb") as stderr_stream: + process = subprocess.Popen( + [str(LAUNCHER)], + env=environ, + stdout=subprocess.DEVNULL, + stderr=stderr_stream, + ) + for _ in range(100): + if marker.exists(): + break + time.sleep(0.05) + self.assertTrue(marker.exists(), "noisy TUI did not reach its hold point") + captures = [ + path + for root in self.base.glob("tui-stderr.*") + for path in ([root] if root.is_file() else root.rglob("*")) + if path.is_file() + ] + self.assertTrue(captures or any(self.base.glob("tui-stderr.*"))) + self.assertTrue( + all(path.stat().st_size <= 4 * 1024 * 1024 for path in captures), + captures, + ) + self.assertEqual(0, process.wait(timeout=5)) + + def test_a_picker_that_cannot_run_also_hands_over(self) -> None: + done = self._run(1) + self.assertEqual(0, done.returncode) + self.assertEqual(["tui", "fallback"], self.trace.read_text(encoding="utf-8").split()) + + def test_nobody_gets_the_new_screen_without_turning_it_on(self) -> None: + self.switch = "" + done = self._run(0) + self.assertEqual(0, done.returncode) + self.assertEqual(["fallback"], self.trace.read_text(encoding="utf-8").split()) + self.assertFalse(self.log.exists()) + + def test_a_tui_on_file_in_the_state_directory_turns_it_on(self) -> None: + self.switch = "" + (self.base / "tui-on").touch() + done = self._run(0) + self.assertEqual(0, done.returncode) + self.assertEqual(["tui"], self.trace.read_text(encoding="utf-8").split()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tui_attention.py b/tests/test_tui_attention.py new file mode 100644 index 0000000..45d7141 --- /dev/null +++ b/tests/test_tui_attention.py @@ -0,0 +1,167 @@ +"""Attention truth: what the count means, and when it is allowed to exist. + +Three rules, all measured before they were written down. A session the person +is attached to and looking at is seen, never counted as waiting. Every counted +session is reachable from the count. Zero is reachable. + +The count is model state; it no longer prints a summary line above the footer +(operator ruling, 2026-08-15), so these assert the count itself. +""" + +from __future__ import annotations + +import unittest + +from tests.tui_support import Clock, inventory, picker, row + +from sessionkit_tui import rows as rowmod + + +def attention_of(screen) -> int: + """The count itself, from the helper that computes it. + + It used to be read off Screen.attention. Nothing in production read that + field once the summary line went, so it was recomputed on every frame for + the tests alone (found in review, 2026-08-15). The rule it encodes is real + and still tested -- here, against rows.attention_count directly. + """ + return rowmod.attention_count( + [item for item in screen.screen().rows if item.kind == "session"] + ) + + +def needs_you_rows(screen): + return [item.label for item in screen.visible_rows() if item.needs_you] + + +class AttachedIsSeenTests(unittest.TestCase): + def test_the_session_attached_in_this_window_is_never_counted(self) -> None: + here = row("Here", number=1, needs_you=True) + screen = picker( + here, + row("Elsewhere", number=2, needs_you=True), + self_session_id=here["shpool_id"], + ) + self.assertEqual(1, attention_of(screen)) + self.assertEqual(["Elsewhere"], needs_you_rows(screen)) + + def test_the_attached_session_still_appears_in_the_list(self) -> None: + here = row("Here", number=1, needs_you=True) + screen = picker(here, self_session_id=here["shpool_id"]) + labels = [item.label for item in screen.visible_rows()] + self.assertIn("Here", labels) + + def test_a_seen_session_no_longer_counts_as_needing_you(self) -> None: + # One word for one state: the row still SAYS needs you, because that + # is its state. Being seen removes the attention treatment, the + # count and the needs-you list, not the state word. + here = row("Here", number=1, needs_you=True) + screen = picker(here, self_session_id=here["shpool_id"]) + self.assertEqual(0, attention_of(screen)) + self.assertEqual([], needs_you_rows(screen)) + detail = screen.visible_rows()[0].detail + self.assertIn("needs you", detail) + + +class OpenedFromHereTests(unittest.TestCase): + def test_an_open_that_was_refused_leaves_the_session_counted(self) -> None: + clock = Clock() + screen = picker(row("Alpha", number=1, needs_you=True, quiet_seconds=10), clock=clock) + screen.action_finished(screen.enter(), ok=False) + self.assertEqual(1, attention_of(screen)) + + def test_a_session_opened_from_here_stops_being_counted(self) -> None: + clock = Clock() + screen = picker(row("Alpha", number=1, needs_you=True, quiet_seconds=10), clock=clock) + self.assertEqual(1, attention_of(screen)) + screen.action_finished(screen.enter(), ok=True) + self.assertEqual(0, attention_of(screen)) + + def test_it_counts_again_once_the_session_says_something_new(self) -> None: + clock = Clock() + screen = picker(row("Alpha", number=1, needs_you=True, quiet_seconds=10), clock=clock) + screen.action_finished(screen.enter(), ok=True) + clock.advance(60_000) + screen.set_inventory( + inventory(row("Alpha", number=1, needs_you=True, quiet_seconds=5)) + ) + self.assertEqual(1, attention_of(screen)) + + def test_it_stays_seen_while_the_session_stays_quiet(self) -> None: + clock = Clock() + screen = picker(row("Alpha", number=1, needs_you=True, quiet_seconds=10), clock=clock) + screen.action_finished(screen.enter(), ok=True) + clock.advance(60_000) + screen.set_inventory( + inventory(row("Alpha", number=1, needs_you=True, quiet_seconds=70)) + ) + self.assertEqual(0, attention_of(screen)) + + +class DrillableTests(unittest.TestCase): + def test_every_counted_session_is_the_first_thing_the_list_shows(self) -> None: + screen = picker( + row("Quiet", number=1, agent_status="running"), + row("Waiting", number=2, needs_you=True), + row("Also waiting", number=3, needs_you=True), + ) + count = attention_of(screen) + self.assertEqual(2, count) + sessions = [item for item in screen.visible_rows() if item.kind == rowmod.SESSION] + self.assertTrue(all(item.needs_you for item in sessions[:count])) + self.assertEqual({"Waiting", "Also waiting"}, {item.label for item in sessions[:count]}) + + def test_moving_to_the_count_lands_on_the_sessions_it_counted(self) -> None: + screen = picker( + row("Quiet", number=1, agent_status="running"), + row("Waiting", number=2, needs_you=True), + ) + self.assertEqual("Waiting", screen.current_row().label) + self.assertTrue(screen.current_row().needs_you) + + def test_a_machine_session_that_needs_you_is_counted_where_it_is_shown(self) -> None: + screen = picker( + row("Mine", number=1, agent_status="working"), + row("Drill", number=2, origin="machine", needs_you=True), + ) + self.assertEqual(0, attention_of(screen)) + self.assertIn( + "1 machine session · 1 needs you", + [item.label for item in screen.visible_rows()], + ) + screen.machine_expanded = True + self.assertEqual(1, attention_of(screen)) + self.assertEqual(["Drill"], needs_you_rows(screen)) + + +class ZeroTests(unittest.TestCase): + def test_at_zero_the_line_disappears_entirely(self) -> None: + screen = picker(row("Alpha", number=1)) + text = screen.frame(width=100, height=20).joined() + self.assertNotIn("need you", text) + self.assertNotIn("0 sessions", text) + + def test_the_count_goes_away_the_moment_the_last_one_is_seen(self) -> None: + clock = Clock() + screen = picker(row("Alpha", number=1, needs_you=True, quiet_seconds=10), clock=clock) + self.assertEqual(1, attention_of(screen)) + # No summary line at any count, before or after. + self.assertNotIn("needs you: 1", screen.frame(width=100, height=20).joined()) + screen.action_finished(screen.enter(), ok=True) + self.assertEqual(0, attention_of(screen)) + self.assertNotIn("need you", screen.frame(width=100, height=20).joined()) + + def test_a_filtered_list_counts_only_what_it_is_showing(self) -> None: + screen = picker( + row("Blueprint", number=1, needs_you=True), + row("Config Sweep", number=2, needs_you=True), + ) + self.assertEqual(2, attention_of(screen)) + for character in "blue": + screen.type_text(character) + self.assertEqual(1, attention_of(screen)) + self.assertEqual(["Blueprint"], needs_you_rows(screen)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tui_frame.py b/tests/test_tui_frame.py new file mode 100644 index 0000000..5c2391e --- /dev/null +++ b/tests/test_tui_frame.py @@ -0,0 +1,563 @@ +"""What a painted frame says, and what it must never say.""" + +from __future__ import annotations + +import re +import unittest + +from tests.tui_support import inventory, picker, row + +from sessionkit_tui import rows as rowmod, voice +from sessionkit_tui.frame import Panel, PanelItem, Screen, build_frame +from sessionkit_tui.model import format_age, parse_session + + +def frame_of(*records, **keywords): + screen = picker(*records, **keywords) + return screen, screen.frame(width=100, height=20) + + +class FooterTests(unittest.TestCase): + def test_every_frame_surface_has_no_decorated_key_at_probe_widths(self) -> None: + decorated = re.compile( + r"(?:<|‹|«)(?:n|number|numbers)(?:>|›|»)|[«»‹›]" + ) + base = picker(row("Alpha", number=1)) + screens = [base.screen()] + base.view = "help" + screens.append(base.screen()) + screens.extend( + ( + Screen( + panel=Panel( + "Alpha", + (PanelItem("open", "Open"), PanelItem("close", "Close")), + ) + ), + Screen( + panel=Panel( + "Rename", + (), + prompt="New name (↵ back) ❯", + entry="draft", + ) + ), + Screen(heading="Closed sessions", view_kind="closed"), + ) + ) + for width in (50, 80, 120): + for screen in screens: + with self.subTest(width=width, screen=screen): + drawn = build_frame(screen, width=width, height=40).joined() + self.assertIsNone(decorated.search(drawn), drawn) + + def test_the_footer_is_the_one_footer(self) -> None: + _, drawn = frame_of(row("Alpha", number=1)) + self.assertEqual(f" {voice.FOOTER}", drawn.lines[-1].text) + # Priority order, the same one the shell picker's footer drops from + # the end (operator ruling, 2026-08-16): history is the first luxury + # and `more` is a door, so `more m` outranks `history h #` here too. + self.assertEqual( + "↵ open · open # · kill k # · new n · more m · " + "help ? · history h # · esc quit", + voice.FOOTER, + ) + + def test_the_footer_keeps_the_old_verb_first_key_grammar(self) -> None: + _, drawn = frame_of(row("Alpha", number=1)) + footer = drawn.lines[-1] + self.assertNotIn("↑↓ move", footer.text) + for phrase in ( + "open #", + "kill k #", + "history h #", + "new n", + "more m", + "help ?", + "esc quit", + ): + self.assertIn(phrase, footer.text) + key_positions = ( + ("#", footer.text.index("open #") + len("open ")), + ("k #", footer.text.index("kill k #") + len("kill ")), + ("h #", footer.text.index("history h #") + len("history ")), + ("n", footer.text.index("new n") + len("new ")), + ("m", footer.text.index("more m") + len("more ")), + ("?", footer.text.index("help ?") + len("help ")), + ("esc", footer.text.index("esc quit")), + ) + for token, start in key_positions: + self.assertTrue( + any( + span.style == "key" + and span.start <= start + and span.start + span.length >= start + len(token) + for span in footer.spans + ), + (token, footer), + ) + + def test_every_frame_ends_with_the_footer_at_the_same_line(self) -> None: + screen, first = frame_of(row("Alpha", number=1), row("Beta", number=2)) + second = screen.frame(width=100, height=20) + self.assertEqual(len(first.lines), len(second.lines)) + self.assertEqual(20, len(first.lines)) + self.assertEqual(first.lines[-1].text, second.lines[-1].text) + + def test_the_screen_carries_no_other_key_glossary(self) -> None: + _, drawn = frame_of(row("Alpha", number=1)) + text = drawn.joined() + for jargon in ("[y/N]", "press q", "action 5", "i<n>", "d1"): + self.assertNotIn(jargon, text) + + +class IdentifierTests(unittest.TestCase): + def test_no_frame_ever_prints_a_shpool_id_or_a_uuid(self) -> None: + records = [ + row("Alpha", number=1), + row("Beta", number=2, provider="codex"), + row("Drill", number=3, origin="machine"), + ] + screen = picker(*records) + screen.machine_expanded = True + drawn = screen.frame(width=120, height=24) + text = drawn.joined() + for record in records: + self.assertNotIn(record["shpool_id"], text) + uuid = (record.get("identity") or {}).get("uuid") + if uuid: + self.assertNotIn(uuid, text) + + def test_the_action_panel_prints_no_identifier_either(self) -> None: + record = row("Alpha", number=1) + screen = picker(record) + screen.type_text("1") + screen.enter() + text = screen.frame(width=100, height=20).joined() + self.assertIn("Close", text) + self.assertNotIn(record["shpool_id"], text) + self.assertNotIn(record["identity"]["uuid"], text) + + +class MachineBucketTests(unittest.TestCase): + def test_expansion_counts_and_draws_only_activatable_machine_roots(self) -> None: + orphan = row("Orphan child", number=2, provider="codex") + orphan["is_subagent"] = True + machine = row("Machine root", number=3, provider="codex", origin="machine") + for width in (50, 80, 120): + with self.subTest(width=width): + screen = picker(row("Parent", number=1), orphan, machine) + screen.machine_expanded = True + drawn = screen.frame(width=width, height=24).joined() + self.assertIn("1 machine session", drawn) + self.assertNotIn("2 machine sessions", drawn) + self.assertIn("Machine root", drawn) + self.assertNotIn("Orphan child", drawn) + + def test_activation_refuses_a_subagent_row_that_reaches_the_tui(self) -> None: + orphan = row( + "Orphan child", number=2, provider="codex", origin="machine" + ) + orphan["is_subagent"] = True + session = parse_session(orphan) + self.assertIsNotNone(session) + unsafe_row = next( + item + for item in rowmod.build_rows((session,), machine_expanded=True) + if item.kind == rowmod.SESSION + ) + screen = picker(row("Parent", number=1)) + + self.assertIsNone(screen._activate(unsafe_row)) + self.assertEqual( + "Subagent sessions stay with their parent. Nothing changed.", + screen.status, + ) + + +class AttentionLineTests(unittest.TestCase): + """There is no attention summary line above the footer, at any count. + + Operator ruling, 2026-08-15 -- the same ruling that removed the older + picker's version. The count still exists as model state and the rows still + say "needs you"; what is gone is the headline. + """ + + def test_no_attention_summary_line_however_many_need_you(self) -> None: + screen, drawn = frame_of( + row("Alpha", number=1, needs_you=True), + row("Beta", number=2, needs_you=True), + row("Gamma", number=3, agent_status="working"), + ) + # joined(), not text(): the line was drawn in the tail, and asserting + # its ABSENCE on text() passed at the parent commit as well -- a test + # that proves nothing. joined() was verified to fail at 68fd53e8 and + # pass here, which is what makes this a differential. + text = drawn.joined() + self.assertNotIn("needs you: 2", text) + self.assertNotIn("needs you: 2 · 2 sessions", text) + # The rows themselves still carry it, which is the surface a person + # can actually see. (The count no longer exists as frame state; its + # rule is tested directly in tests/test_tui_attention.py.) + self.assertEqual( + 2, sum(1 for item in screen.screen().rows if getattr(item, "needs_you", False)) + ) + + def test_no_attention_summary_line_for_a_single_session(self) -> None: + screen, drawn = frame_of(row("Alpha", number=1, needs_you=True)) + self.assertNotIn("needs you: 1", drawn.joined()) + self.assertEqual( + 1, sum(1 for item in screen.screen().rows if getattr(item, "needs_you", False)) + ) + + def test_nothing_is_printed_at_zero_either(self) -> None: + _, drawn = frame_of(row("Alpha", number=1)) + text = drawn.joined() + self.assertNotIn("need you", text) + self.assertNotIn("0 session", text) + + +class SummaryAndGroupingTests(unittest.TestCase): + def test_the_header_summarizes_the_same_estate_as_the_old_picker(self) -> None: + _, drawn = frame_of( + row("Ready one", number=1), + row("Ready two", number=2, needs_you=True), + row("Elsewhere", number=3, availability="attached"), + ) + self.assertEqual( + " 3 sessions · 2 ready · 1 open elsewhere", + drawn.lines[0].text, + ) + self.assertNotIn("Session Kit", drawn.joined()) + + def test_ready_and_open_elsewhere_are_headings_not_row_details(self) -> None: + _, drawn = frame_of( + row("Ready one", number=1), + row("Elsewhere", number=2, availability="attached"), + ) + texts = list(drawn.text()) + ready_heading = texts.index(" Ready") + ready_row = next(index for index, text in enumerate(texts) if "Ready one" in text) + elsewhere_heading = texts.index(" Open elsewhere") + elsewhere_row = next(index for index, text in enumerate(texts) if "Elsewhere" in text) + self.assertLess(ready_heading, ready_row) + self.assertLess(ready_row, elsewhere_heading) + self.assertLess(elsewhere_heading, elsewhere_row) + self.assertNotIn("open elsewhere", texts[elsewhere_row].casefold()) + + +class RowPaintTests(unittest.TestCase): + def test_a_marked_row_carries_a_visible_tick(self) -> None: + screen = picker(row("Alpha", number=1), row("Beta", number=2)) + screen.type_text("2") + drawn = screen.frame(width=100, height=20) + marked = [line.text for line in drawn.lines if "Beta" in line.text][0] + unmarked = [line.text for line in drawn.lines if "Alpha" in line.text][0] + self.assertTrue(marked.startswith("✓")) + self.assertFalse(unmarked.startswith("✓")) + + def test_the_typed_input_is_labelled_for_what_it_does(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.type_text("1") + self.assertIn(" Mark: 1", screen.frame(width=100, height=20).text()) + screen.escape() + screen.type_text("al") + self.assertIn(" Filter: al", screen.frame(width=100, height=20).text()) + + def test_the_highlight_is_on_the_first_row_to_start_with(self) -> None: + screen, drawn = frame_of(row("Alpha", number=1), row("Beta", number=2)) + self.assertEqual(screen.visible_rows()[0].label, screen.current_row().label) + self.assertEqual(screen.visible_rows()[0].key, drawn.row_lines[drawn.cursor_line]) + + def test_a_narrow_terminal_truncates_rather_than_wraps(self) -> None: + screen = picker(row("A very long session title indeed", number=1)) + drawn = screen.frame(width=30, height=12) + for line in drawn.lines: + self.assertLessEqual(len(line.text), 30) + + def test_structural_rows_are_dim_so_sessions_dominate(self) -> None: + _, drawn = frame_of(row("Alpha", number=1)) + for label in ("New session", "Projects", "Closed sessions", "Help"): + line = next(line for line in drawn.lines if label in line.text) + start = line.text.index(label) + self.assertTrue( + any( + span.style == "dim" + and span.start <= start + and span.start + span.length >= start + len(label) + for span in line.spans + ), + (label, line), + ) + + +class ColumnTests(unittest.TestCase): + def test_the_title_column_never_pushes_the_detail_off_the_line(self) -> None: + long_title = "x" * 200 + screen = picker(row(long_title, number=1), row("Short", number=2)) + drawn = screen.frame(width=100, height=16) + starts = set() + for line in drawn.lines: + if "CLD" in line.text: + starts.add(line.text.index("CLD")) + self.assertEqual(1, len(starts), drawn.joined()) + long_line = next(line.text for line in drawn.lines if "xxx" in line.text) + self.assertIn("…", long_line[: long_line.index(" | CLD")]) + for line in drawn.lines: + self.assertLessEqual(len(line.text), 100) + + def test_every_detail_field_starts_in_one_shared_column(self) -> None: + screen = picker( + row( + "A", + number=1, + provider="claude", + account_alias="main", + model="opus", + agent_status="idle", + age_seconds=300, + ), + row( + "A medium title", + number=2, + provider="codex", + account_alias="secondary", + model="gpt-5-codex", + agent_status="running", + age_seconds=7200, + ), + row( + "A title that is much longer than either of the others", + number=3, + provider="shell", + agent_status="idle", + age_seconds=90000, + ), + ) + drawn = screen.frame(width=140, height=20) + session_lines = [ + line.text + for line in drawn.lines + if any(provider in line.text for provider in ("CLD", "CDX", "SHL")) + ] + self.assertEqual(3, len(session_lines), drawn.joined()) + separators = [ + tuple(index for index, char in enumerate(line) if char == "|") + for line in session_lines + ] + self.assertTrue(all(len(points) >= 5 for points in separators), session_lines) + self.assertEqual(1, len({points[:5] for points in separators}), session_lines) + for line in session_lines: + self.assertNotIn(" · ", line) + self.assertLessEqual(len(line), 140) + + def test_a_long_title_keeps_at_least_a_third_without_stealing_details(self) -> None: + width = 90 + screen = picker( + row( + "x" * 200, + number=1, + provider="codex", + account_alias="primary", + model="gpt-5-codex", + agent_status="running", + age_seconds=7200, + ), + row( + "Short", + number=2, + provider="claude", + account_alias="p", + model="opus", + agent_status="idle", + age_seconds=300, + ), + ) + from sessionkit_tui.frame import MIN_LABEL + + drawn = screen.frame(width=width, height=16) + session_lines = [line.text for line in drawn.lines if " | CLD" in line.text or " | CDX" in line.text] + self.assertEqual(2, len(session_lines), drawn.joined()) + first_pipes = {line.index("|") for line in session_lines} + self.assertEqual(1, len(first_pipes), session_lines) + # The title no longer claims a third of the line whatever the details + # need. It keeps a floor, and the details -- which is where the state + # and the one time live -- get the room they ask for. + title_cells = first_pipes.pop() - 1 - 7 + self.assertGreaterEqual(title_cells, MIN_LABEL) + self.assertTrue( + any("…" in line[: line.index("|")] for line in session_lines), + session_lines, + ) + for line in session_lines: + self.assertRegex(line, r"\| (?:CLD|CDX)\s+\|") + self.assertLessEqual(len(line), width) + # Every row still carries its identifiable compact time, uncut. + self.assertTrue(line.rstrip().endswith("| pending"), line) + + def test_the_details_keep_the_room_they_need(self) -> None: + from sessionkit_tui.frame import label_width + from sessionkit_tui.rows import build_rows + from sessionkit_tui.model import format_age + + sessions = inventory( + row("x" * 200, number=1, account_alias="primary", model="opus-4.6"), + row("Short", number=2, account_alias="primary", model="opus-4.6"), + ).sessions + drawn = build_rows(sessions, age_text=format_age) + column = label_width(drawn, 120) + longest_detail = max(len(item.detail) for item in drawn if item.detail) + self.assertLessEqual(7 + column + 3 + longest_detail, 120) + + def test_a_narrow_terminal_truncates_the_title_not_the_details(self) -> None: + """The title used to keep a third of the line whatever the details + needed, so the row was assembled wider than the terminal and the frame + hard-cut the tail -- the state and the time, which is what the row is + for. It keeps a floor now and no more.""" + from sessionkit_tui.frame import label_width, detail_widths, MIN_LABEL + from sessionkit_tui.rows import build_rows + from sessionkit_tui.model import format_age + + sessions = inventory( + row("x" * 200, number=1, account_alias="primary", model="opus-4.6") + ).sessions + drawn = build_rows(sessions, age_text=format_age) + # Where there is room for both, the details get theirs and the title + # takes what is left -- not a third of the line regardless. + for width in (100, 120, 160): + with self.subTest(width=width): + column = label_width(drawn, width) + self.assertGreaterEqual(column, MIN_LABEL) + self.assertLessEqual(7 + column + detail_widths(drawn).fixed, width) + # And where there is not, the title is still only its floor: it never + # grows into room the state and the time need. + self.assertEqual(MIN_LABEL, label_width(drawn, 80)) + + +class EmptyStateTests(unittest.TestCase): + def test_an_empty_list_says_so_once(self) -> None: + screen = picker() + drawn = screen.frame(width=80, height=16) + self.assertIn("New session", drawn.joined()) + screen.type_text("zzzz") + drawn = screen.frame(width=80, height=16) + self.assertIn(" Sessions: none.", drawn.text()) + + def test_closed_sessions_say_none_when_the_ledger_is_empty(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.view = "closed" + drawn = screen.frame(width=80, height=16) + self.assertIn(" Closed sessions: none.", drawn.text()) + + +class PanelPaintTests(unittest.TestCase): + def test_a_panel_draws_its_rows_and_highlights_the_default(self) -> None: + panel = Panel( + "Alpha", + (PanelItem("open", "Open"), PanelItem("close", "Close")), + index=1, + ) + drawn = build_frame(Screen(panel=panel), width=60, height=12) + self.assertIn(" 2 Close", drawn.text()) + self.assertEqual(" 2 Close", drawn.lines[drawn.cursor_line].text) + + def test_panel_details_share_one_pipe_column_and_name_the_way_back(self) -> None: + panel = Panel( + "Alpha", + ( + PanelItem("close", "Close", "everything inside will end"), + PanelItem("account", "Change account", "keeps this conversation"), + ), + identity_color="red", + ) + drawn = build_frame(Screen(panel=panel), width=80, height=12) + rows = [line.text for line in drawn.lines if " | " in line.text] + self.assertEqual(1, len({line.index("|") for line in rows}), rows) + self.assertEqual( + " ↵ choose · b back · esc back · ctrl-d quit", drawn.lines[-1].text + ) + self.assertTrue(any(span.style == "title:red" for span in drawn.lines[0].spans)) + + +class StaleTests(unittest.TestCase): + def test_a_cached_list_says_it_is_cached(self) -> None: + screen = picker() + screen.set_inventory(inventory(row("Alpha", number=1), stale=True)) + drawn = screen.frame(width=100, height=16) + self.assertIn( + " Showing the last confirmed list. Actions wait for a refresh.", drawn.text() + ) + + +class HelpTests(unittest.TestCase): + def test_help_mirrors_the_old_picker_key_table(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.view = "help" + drawn = screen.frame(width=200, height=40) + text = drawn.joined() + self.assertEqual(" Picker help", drawn.lines[0].text) + for section in ("Sessions", "Needs you", "The list", "Quitting"): + self.assertIn(f" {section}", text) + rows = [ + line + for line in drawn.lines + if any(line.text.startswith(f" {key.ljust(13)} ") for _, key, _ in voice.HELP_ROWS) + ] + self.assertEqual(len(voice.HELP_ROWS), len(rows)) + self.assertTrue(all(any(span.style == "key" for span in line.spans) for line in rows)) + self.assertTrue(all(line.text[15] == " " for line in rows)) + self.assertIn(" ↵ back · b back · esc back · ctrl-d quit", drawn.text()) + + +class ClosedPagePaintTests(unittest.TestCase): + def test_closed_rows_mirror_the_old_bracketed_provider_layout(self) -> None: + from sessionkit_tui.model import ClosedSession + + screen = picker(row("Alpha", number=1)) + screen.view = "closed" + screen.set_closed((ClosedSession("u", "Old Alpha", "codex", "u", "/srv", "primary", 1),)) + drawn = screen.frame(width=100, height=16) + line = next(line for line in drawn.lines if "Old Alpha" in line.text) + self.assertIn("[CDX] Old Alpha [login time unknown]", line.text) + self.assertTrue(any(span.style == "provider:codex" for span in line.spans)) + self.assertEqual(" ↵ actions · esc back · ctrl-d quit", drawn.lines[-1].text) + + +class ScrollTests(unittest.TestCase): + def test_the_window_follows_the_highlight(self) -> None: + records = [row(f"Session {number}", number=number) for number in range(1, 21)] + screen = picker(*records) + for _ in range(15): + screen.move(1) + drawn = screen.frame(width=100, height=12) + self.assertIsNotNone(drawn.cursor_line) + self.assertIn(screen.current_row().label, drawn.joined()) + + +class AgeTests(unittest.TestCase): + def test_one_time_phrase_with_one_shape_on_every_row(self) -> None: + self.assertEqual("last active just now", format_age(5)) + self.assertEqual("last active 3m ago", format_age(200)) + self.assertEqual("last active 1h 0m ago", format_age(3600)) + self.assertEqual("last active 1d 1h ago", format_age(90000)) + self.assertEqual("last active pending", format_age(None)) + # Every answer begins the same way, which is what lets the column line + # up: `opened 3 hr ago` beside `3 hr ago` never could. + for seconds in (None, 0, 5, 200, 3600, 90000, 8 * 86400): + self.assertTrue(format_age(seconds).startswith("last active ")) + + +class RowKindTests(unittest.TestCase): + def test_every_painted_row_maps_back_to_a_row(self) -> None: + screen = picker(row("Alpha", number=1)) + drawn = screen.frame(width=100, height=20) + keys = set(drawn.row_lines.values()) + self.assertEqual( + {item.key for item in screen.visible_rows()} & keys, + {item.key for item in screen.visible_rows()}, + ) + self.assertIn(rowmod.HELP, keys) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tui_rows.py b/tests/test_tui_rows.py new file mode 100644 index 0000000..5bf06bb --- /dev/null +++ b/tests/test_tui_rows.py @@ -0,0 +1,303 @@ +"""What the list contains and in what order.""" + +from __future__ import annotations + +import unittest + +from tests.tui_support import Clock, inventory, picker, row + +from sessionkit_tui import rows as rowmod +from sessionkit_tui.model import format_age + + +class MarkingGrammarTests(unittest.TestCase): + def test_digits_commas_and_ranges_mark_the_numbers_they_name(self) -> None: + self.assertEqual(((1, 4, 7, 8, 9), ()), rowmod.parse_marks("1,4,7-9")) + self.assertEqual(((5,), ()), rowmod.parse_marks("5")) + self.assertEqual(((3, 2), ()), rowmod.parse_marks("3-2")) + self.assertEqual(((2,), ()), rowmod.parse_marks("2,2")) + + def test_a_fragment_that_names_nothing_is_handed_back(self) -> None: + self.assertEqual(((1,), ("x",)), rowmod.parse_marks("1,x")) + + def test_spaces_between_numbers_change_nothing(self) -> None: + self.assertEqual(((1, 2), ()), rowmod.parse_marks("1, 2")) + + def test_one_letter_turns_the_whole_input_into_a_filter(self) -> None: + self.assertFalse(rowmod.is_filter("1,4")) + self.assertTrue(rowmod.is_filter("1a")) + self.assertTrue(rowmod.is_filter("cache")) + + def test_a_marked_row_carries_the_mark(self) -> None: + drawn = rowmod.build_rows( + inventory(row("Alpha", number=1), row("Beta", number=2)).sessions, + marks=(2,), + ) + marked = {item.label: item.marked for item in drawn if item.kind == rowmod.SESSION} + self.assertEqual({"Alpha": False, "Beta": True}, marked) + + +class FilterTests(unittest.TestCase): + def test_the_filter_finds_names_out_of_order(self) -> None: + self.assertTrue(rowmod.matches("orc", "orphaned record")) + self.assertFalse(rowmod.matches("zz", "orphaned record")) + + def test_the_filter_reaches_providers_accounts_and_projects(self) -> None: + sessions = inventory( + row("Alpha", number=1, provider="claude", account_alias="primary"), + row("Beta", number=2, provider="codex", account_alias="spare"), + ).sessions + by_provider = rowmod.build_rows(sessions, query="codex") + self.assertEqual( + ["Beta"], [item.label for item in by_provider if item.kind == rowmod.SESSION] + ) + by_account = rowmod.build_rows(sessions, query="primary") + self.assertEqual( + ["Alpha"], [item.label for item in by_account if item.kind == rowmod.SESSION] + ) + by_project = rowmod.build_rows(sessions, query="project") + self.assertEqual( + 2, len([item for item in by_project if item.kind == rowmod.SESSION]) + ) + + def test_the_filter_never_reaches_an_identifier(self) -> None: + record = row("Alpha", number=1) + sessions = inventory(record).sessions + drawn = rowmod.build_rows(sessions, query=record["shpool_id"]) + self.assertEqual([], [item for item in drawn if item.kind == rowmod.SESSION]) + + +class OrderTests(unittest.TestCase): + def test_ready_stays_together_before_open_elsewhere(self) -> None: + sessions = inventory( + row("Elsewhere", number=1, availability="attached", agent_status="running"), + row("Ready", number=2, agent_status="running"), + row("Waiting", number=3, needs_you=True), + row( + "Elsewhere waiting", + number=4, + availability="attached", + needs_you=True, + ), + ).sessions + drawn = rowmod.build_rows(sessions) + self.assertEqual( + ["Waiting", "Ready", "Elsewhere waiting", "Elsewhere"], + [item.label for item in drawn if item.kind == rowmod.SESSION], + ) + + def test_the_list_holds_still_while_the_cursor_is_moving(self) -> None: + first = inventory(row("Alpha", number=1), row("Beta", number=2)).sessions + order = [item.key for item in rowmod.order_sessions(first)] + moved = inventory( + row("Alpha", number=1), row("Beta", number=2, needs_you=True) + ).sessions + held = [item.key for item in rowmod.order_sessions(moved, pinned=order)] + self.assertEqual(order, held) + free = [item.title for item in rowmod.order_sessions(moved)] + self.assertEqual("Beta", free[0]) + + def test_a_new_session_lands_after_the_order_being_held(self) -> None: + first = inventory(row("Alpha", number=1)).sessions + order = [item.key for item in rowmod.order_sessions(first)] + later = inventory(row("Alpha", number=1), row("Beta", number=2)).sessions + held = rowmod.order_sessions(later, pinned=order) + self.assertEqual(["Alpha", "Beta"], [item.title for item in held]) + + +class MachineRowTests(unittest.TestCase): + def test_machine_sessions_stay_behind_one_counted_row(self) -> None: + sessions = inventory( + row("Mine", number=1), + row("Drill", number=2, origin="machine", agent_status="working"), + row("Worker", number=3, origin="machine", agent_status="working"), + ).sessions + drawn = rowmod.build_rows(sessions) + labels = [item.label for item in drawn] + self.assertIn("2 machine sessions", labels) + self.assertNotIn("Drill", labels) + self.assertNotIn("Worker", labels) + + def test_the_counted_row_expands_in_place(self) -> None: + sessions = inventory( + row("Mine", number=1), + row("Drill", number=2, origin="machine", agent_status="working"), + ).sessions + drawn = rowmod.build_rows(sessions, machine_expanded=True) + labels = [item.label for item in drawn] + self.assertEqual( + ["Mine", "1 machine session", "Drill"], labels[: labels.index("New session")] + ) + + def test_the_counted_row_says_how_many_of_them_need_you(self) -> None: + sessions = inventory( + row("Drill", number=1, origin="machine", needs_you=True), + row("Worker", number=2, origin="machine", agent_status="working"), + ).sessions + drawn = rowmod.build_rows(sessions) + self.assertIn("2 machine sessions · 1 needs you", [item.label for item in drawn]) + + def test_a_session_with_no_origin_is_one_a_person_started(self) -> None: + sessions = inventory(row("Mine", number=1)).sessions + self.assertFalse(sessions[0].is_machine) + drawn = rowmod.build_rows(sessions) + self.assertIn("Mine", [item.label for item in drawn]) + + def test_the_counted_row_is_gone_when_no_machine_session_exists(self) -> None: + drawn = rowmod.build_rows(inventory(row("Mine", number=1)).sessions) + self.assertEqual( + [], [item for item in drawn if item.kind == rowmod.MACHINE_COUNT] + ) + + +class RowContentTests(unittest.TestCase): + def test_a_row_carries_old_provider_and_age_words_in_column_order(self) -> None: + sessions = inventory( + row( + "Alpha", + number=1, + provider="codex", + account_alias="primary", + model="gpt-5-codex", + agent_status="running", + subagents=2, + age_seconds=3600, + quiet_seconds=300, + ) + ).sessions + drawn = rowmod.build_rows(sessions, age_text=format_age)[0] + self.assertEqual( + "CDX | primary | gpt-5-codex | working | 2 subagents | last active 5m ago", + drawn.detail, + ) + + def test_availability_is_a_heading_fact_not_a_row_detail(self) -> None: + sessions = inventory( + row("Alpha", number=1, availability="attached", agent_status="running"), + row("Beta", number=2), + ).sessions + drawn = rowmod.build_rows(sessions) + details = {item.label: item.detail for item in drawn if item.kind == rowmod.SESSION} + self.assertIn("working", details["Alpha"]) + self.assertNotIn("open elsewhere", details["Alpha"]) + self.assertNotIn("open elsewhere", details["Beta"]) + + def test_an_absent_model_says_why_rather_than_showing_a_dash(self) -> None: + sessions = inventory(row("Alpha", number=1)).sessions + drawn = rowmod.build_rows(sessions)[0] + # Both unpulled sections say pending, the operator rule: until a + # section has a valid read, it says pending, never a dash. + self.assertIn("CLD | pending | pending |", drawn.detail) + + def test_a_shell_row_says_it_has_no_model(self) -> None: + sessions = inventory(row("Alpha", number=1, provider="shell")).sessions + drawn = rowmod.build_rows(sessions)[0] + self.assertIn("SHL | pending | no model |", drawn.detail) + + def test_every_provider_uses_the_old_three_letter_form(self) -> None: + sessions = inventory( + row("Claude", number=1, provider="claude"), + row("Codex", number=2, provider="codex"), + row("Shell", number=3, provider="shell"), + row("Unknown", number=4, provider="other"), + ).sessions + details = { + item.label: item.detail + for item in rowmod.build_rows(sessions) + if item.kind == rowmod.SESSION + } + self.assertTrue(details["Claude"].startswith("CLD |")) + self.assertTrue(details["Codex"].startswith("CDX |")) + self.assertTrue(details["Shell"].startswith("SHL |")) + self.assertTrue(details["Unknown"].startswith("UNK |")) + + def test_a_waiting_row_uses_the_old_under_one_minute_wording(self) -> None: + sessions = inventory( + row( + "Alpha", + number=1, + provider="claude", + needs_you=True, + quiet_seconds=30, + age_seconds=3600, + ) + ).sessions + detail = rowmod.build_rows(sessions, age_text=format_age)[0].detail + # The state cell says whose turn it is; the time cell says when the + # session last did something. They are two facts and two columns. + self.assertIn("| needs you | last active just now", detail) + self.assertEqual(1, detail.count("needs you")) + + def test_a_quiet_working_session_reads_as_waiting_on_you(self) -> None: + sessions = inventory( + row("Alpha", number=1, agent_status="running", quiet_seconds=3000) + ).sessions + self.assertEqual("needs you", sessions[0].state_word(stall=2700)) + + def test_both_providers_say_one_word_for_one_situation(self) -> None: + """A Claude session at its prompt and a Codex session at its prompt. + + The vendors describe that one situation in two vocabularies -- Claude + raises `idle_prompt` and the collector records `needs your reply`, + Codex writes `task_complete` and the collector records `idle` -- and + the screen used to print both words. There is one situation, so there + is one word. + """ + sessions = inventory( + row( + "Claude at its prompt", + number=1, + provider="claude", + agent_status="needs your reply", + needs_you=True, + ), + row( + "Codex at its prompt", + number=2, + provider="codex", + agent_status="idle", + needs_you=False, + ), + ).sessions + self.assertEqual( + ["needs you", "needs you"], + [session.state_word() for session in sessions], + ) + + def test_ordinary_rows_close_the_list(self) -> None: + drawn = rowmod.build_rows(inventory(row("Alpha", number=1)).sessions) + self.assertEqual( + ["New session", "Projects", "Closed sessions", "Help"], + [item.label for item in drawn[-4:]], + ) + + +class PinnedThroughStateTests(unittest.TestCase): + def test_the_hold_expires_three_seconds_after_the_cursor_stops(self) -> None: + clock = Clock() + screen = picker( + row("Alpha", number=1), row("Beta", number=2), clock=clock + ) + held_order = [ + item.label for item in screen.visible_rows() if item.kind == rowmod.SESSION + ] + screen.move(1) + screen.set_inventory( + inventory(row("Alpha", number=1), row("Beta", number=2, needs_you=True)) + ) + self.assertEqual( + held_order, + [item.label for item in screen.visible_rows() if item.kind == rowmod.SESSION], + ) + clock.advance(3000) + expected = [ + item.title for item in rowmod.order_sessions(screen.inventory.sessions) + ] + self.assertEqual( + expected, + [item.label for item in screen.visible_rows() if item.kind == rowmod.SESSION], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tui_smoke.py b/tests/test_tui_smoke.py new file mode 100644 index 0000000..d7d2f11 --- /dev/null +++ b/tests/test_tui_smoke.py @@ -0,0 +1,208 @@ +"""The picker in a real terminal. + +Everything else in this suite tests the core without curses. These prove the +shell around it: the program starts, paints a list a person can read, and +leaves with the status the launcher reads. +""" + +from __future__ import annotations + +import errno +import fcntl +import json +import os +from pathlib import Path +import pty +import select +import signal +import struct +import tempfile +import termios +import time +import unittest + +from tests.tui_support import REPO, document, row, write_executable + +PICKER = REPO / "bin" / "shpool_login_tui" +CTRL_D = b"\x04" + + +class Terminal: + """A pty running the picker against a snapshot written on disk.""" + + def __init__(self, *records, term: str = "xterm", lines: int = 24, columns: int = 80): + self.temp = tempfile.TemporaryDirectory(prefix="tui-smoke.") + self.base = Path(self.temp.name) + self.snapshot = self.base / "inventory.json" + self.snapshot.write_text(json.dumps(document(*records)), encoding="utf-8") + self.status = write_executable( + self.base / "fake-status", + "#!/usr/bin/env bash\n" f'cat "{self.snapshot}"\n', + ) + self.sp = write_executable( + self.base / "fake-sp", "#!/usr/bin/env bash\nexit 0\n" + ) + self.environ = { + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "HOME": str(self.base), + "TERM": term, + "LINES": str(lines), + "COLUMNS": str(columns), + "PYTHONDONTWRITEBYTECODE": "1", + "SESSION_KIT_STATUS_CMD": str(self.status), + "SESSION_KIT_SP_CMD": str(self.sp), + "SESSION_KIT_STATE_DIR": str(self.base / "state"), + "SESSION_KIT_CLOSED_LEDGER": str(self.base / "closed.jsonl"), + "SESSION_KIT_PROJECTS_FILE": str(self.base / "projects.tsv"), + } + self.lines, self.columns = lines, columns + + def close(self) -> None: + self.temp.cleanup() + + def drive(self, keystrokes: bytes = CTRL_D, *, wait_for: str = "ready ·"): + pid, descriptor = pty.fork() + if pid == 0: + try: + os.chdir(self.base) + os.execve("/usr/bin/python3", ["python3", os.fspath(PICKER)], self.environ) + finally: + os._exit(127) + try: + fcntl.ioctl( + descriptor, + termios.TIOCSWINSZ, + struct.pack("HHHH", self.lines, self.columns, 0, 0), + ) + except OSError: + pass + output = bytearray() + deadline = time.monotonic() + 20 + marker = wait_for.encode() + drawn = False + try: + while time.monotonic() < deadline and not drawn: + ready, _, _ = select.select([descriptor], [], [], 0.1) + if not ready: + continue + try: + chunk = os.read(descriptor, 65536) + except OSError as failure: + if failure.errno != errno.EIO: + raise + break + if not chunk: + break + output.extend(chunk) + drawn = marker in output + if drawn and keystrokes: + os.write(descriptor, keystrokes) + status = None + while time.monotonic() < deadline: + ready, _, _ = select.select([descriptor], [], [], 0.1) + if ready: + try: + chunk = os.read(descriptor, 65536) + except OSError as failure: + if failure.errno != errno.EIO: + raise + chunk = b"" + if chunk: + output.extend(chunk) + continue + waited, raw = os.waitpid(pid, os.WNOHANG) + if waited == pid: + status = raw + break + if status is None: + os.kill(pid, signal.SIGKILL) + _, status = os.waitpid(pid, 0) + raise AssertionError( + "the picker did not leave; output=" + + output.decode(errors="replace") + ) + finally: + os.close(descriptor) + code = os.waitstatus_to_exitcode(status) + return code, output.decode(errors="replace") + + +class SmokeTests(unittest.TestCase): + def test_the_picker_draws_the_list_and_leaves_cleanly(self) -> None: + terminal = Terminal( + row("Blueprint", number=1, needs_you=True, account_alias="primary"), + row("Config Sweep", number=2, provider="codex"), + row("Drill", number=3, origin="machine"), + ) + try: + code, output = terminal.drive() + finally: + terminal.close() + self.assertEqual(0, code) + self.assertIn("sessions ·", output) + # No attention summary line above the footer (operator ruling + # 2026-08-15); the rows themselves still carry the state. + self.assertNotIn("needs you:", output) + self.assertIn("Blueprint", output) + self.assertIn("Config Sweep", output) + self.assertIn("1 machine session", output) + self.assertIn("New session", output) + self.assertIn("open ", output) + self.assertNotIn("Drill", output) + + def test_the_drawn_screen_prints_no_identifier(self) -> None: + record = row("Blueprint", number=1) + terminal = Terminal(record) + try: + _, output = terminal.drive() + finally: + terminal.close() + self.assertNotIn(record["shpool_id"], output) + self.assertNotIn(record["identity"]["uuid"], output) + + def test_typing_filters_the_list_in_place(self) -> None: + terminal = Terminal( + row("Blueprint", number=1), + row("Config Sweep", number=2), + ) + try: + code, output = terminal.drive(b"con" + CTRL_D) + finally: + terminal.close() + self.assertEqual(0, code) + self.assertIn("Filter:", output) + # Painted once in the opening list and again once the filter narrowed + # it: the screen answered the keystrokes rather than only echoing them. + self.assertGreaterEqual(output.count("Config Sweep"), 2) + self.assertEqual(1, output.count("Blueprint")) + + def test_a_terminal_the_screen_cannot_use_says_so_and_does_not_hang(self) -> None: + terminal = Terminal(row("Blueprint", number=1), term="dumb") + try: + code, output = terminal.drive(wait_for="") + finally: + terminal.close() + self.assertIn(code, (0, 1)) + if code == 1: + self.assertIn("session-kit:", output) + + +class WithoutATerminalTests(unittest.TestCase): + def test_the_picker_refuses_without_a_terminal_and_never_exits_two(self) -> None: + import subprocess + + environ = dict(os.environ) + environ["PYTHONDONTWRITEBYTECODE"] = "1" + done = subprocess.run( + ["python3", os.fspath(PICKER)], + capture_output=True, + text=True, + input="", + env=environ, + ) + self.assertEqual(1, done.returncode) + self.assertEqual("session-kit: the picker needs a terminal.", done.stderr.strip()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tui_state.py b/tests/test_tui_state.py new file mode 100644 index 0000000..9b6d5bf --- /dev/null +++ b/tests/test_tui_state.py @@ -0,0 +1,559 @@ +"""What keys, clicks, and refreshes do to the screen.""" + +from __future__ import annotations + +import unittest + +from tests.tui_support import Clock, inventory, panel_to, picker, row, row_to + +from sessionkit_tui import rows as rowmod, voice +from sessionkit_tui.model import ClosedSession + + +def session_labels(screen): + return [item.label for item in screen.visible_rows() if item.kind == rowmod.SESSION] + + +class MovementTests(unittest.TestCase): + def test_the_arrows_move_the_highlight(self) -> None: + screen = picker(row("Alpha", number=1), row("Beta", number=2)) + ordered = session_labels(screen) + self.assertEqual(ordered[0], screen.current_row().label) + screen.move(1) + self.assertEqual(ordered[1], screen.current_row().label) + screen.move(-1) + self.assertEqual(ordered[0], screen.current_row().label) + + def test_the_highlight_stops_at_both_ends(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.move(-5) + self.assertEqual("Alpha", screen.current_row().label) + screen.move(500) + self.assertEqual(voice.HELP, screen.current_row().label) + + def test_the_highlight_follows_the_session_across_a_reordering_refresh(self) -> None: + clock = Clock() + screen = picker( + row("Alpha", number=1, agent_status="running"), + row("Beta", number=2, agent_status="running"), + row("Gamma", number=3, agent_status="running"), + clock=clock, + ) + while screen.current_row().label != "Gamma": + screen.move(1) + self.assertEqual("Gamma", screen.current_row().label) + clock.advance(5000) + screen.set_inventory( + inventory( + row("Alpha", number=1, agent_status="running"), + row("Beta", number=2, agent_status="running"), + row("Gamma", number=3, needs_you=True), + ) + ) + labels = session_labels(screen) + self.assertEqual("Gamma", labels[0]) + self.assertEqual({"Alpha", "Beta"}, set(labels[1:])) + self.assertEqual("Gamma", screen.current_row().label) + + def test_a_session_that_goes_away_leaves_the_highlight_in_place(self) -> None: + screen = picker(row("Alpha", number=1), row("Beta", number=2)) + screen.move(1) + screen.set_inventory(inventory(row("Alpha", number=1))) + self.assertIsNotNone(screen.current_row()) + self.assertGreaterEqual(screen.cursor_index(), 0) + + +class MarkingTests(unittest.TestCase): + def test_typed_digits_mark_the_rows_they_name(self) -> None: + screen = picker(row("Alpha", number=1), row("Beta", number=2), row("Gamma", number=3)) + for character in "1,3": + screen.type_text(character) + self.assertEqual((1, 3), screen.marks) + marked = [item.label for item in screen.visible_rows() if item.marked] + self.assertEqual({"Alpha", "Gamma"}, set(marked)) + + def test_a_range_marks_every_row_it_covers(self) -> None: + screen = picker(*[row(f"S{n}", number=n) for n in range(1, 5)]) + for character in "2-4": + screen.type_text(character) + self.assertEqual((2, 3, 4), screen.marks) + + def test_a_number_that_is_not_on_the_screen_is_refused_in_one_line(self) -> None: + screen = picker(row("Alpha", number=1)) + for character in "20": + screen.type_text(character) + self.assertEqual( + "There is no session 20 on this screen. Numbers shown here work.", + screen.status, + ) + self.assertEqual((), screen.marks) + + def test_enter_on_a_refused_number_changes_nothing(self) -> None: + screen = picker(row("Alpha", number=1)) + for character in "20": + screen.type_text(character) + self.assertIsNone(screen.enter()) + self.assertEqual(voice.no_such_session(20), screen.status) + + def test_a_letter_turns_the_marks_into_a_filter(self) -> None: + screen = picker(row("Alpha", number=1), row("Beta", number=2)) + screen.type_text("1") + self.assertEqual((1,), screen.marks) + screen.type_text("b") + self.assertEqual((), screen.marks) + self.assertTrue(screen.is_filter) + + def test_escape_clears_what_was_typed(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.type_text("1") + screen.escape() + self.assertEqual("", screen.input_text) + self.assertEqual("", screen.status) + + +class LeavingTests(unittest.TestCase): + """The way out. A screen a person cannot leave is a trap, however good + the list on it is.""" + + def test_escape_with_nothing_typed_leaves(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.escape() + self.assertTrue(screen.leaving) + + def test_escape_clears_the_input_before_it_leaves(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.type_text("a") + screen.escape() + self.assertFalse(screen.leaving) + self.assertEqual("", screen.input_text) + screen.escape() + self.assertTrue(screen.leaving) + + def test_escape_closes_a_panel_before_it_leaves(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.type_text("1") + screen.enter() + screen.escape() + self.assertFalse(screen.leaving) + self.assertEqual(voice.NOTHING_CHANGED, screen.status) + + def test_escape_returns_from_help_before_it_leaves(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.view = "help" + screen.escape() + self.assertFalse(screen.leaving) + self.assertEqual("list", screen.view) + screen.escape() + self.assertTrue(screen.leaving) + + def test_q_leaves_when_nothing_is_typed(self) -> None: + screen = picker(row("Alpha", number=1)) + self.assertTrue(screen.quit_key()) + self.assertTrue(screen.leaving) + + def test_q_is_an_ordinary_letter_while_filtering(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.type_text("a") + self.assertFalse(screen.quit_key()) + self.assertFalse(screen.leaving) + + def test_q_is_an_ordinary_letter_while_a_panel_is_open(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.type_text("1") + screen.enter() + self.assertFalse(screen.quit_key()) + + def test_ctrl_d_leaves_from_anywhere(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.type_text("alpha") + screen.leave() + self.assertTrue(screen.leaving) + + def test_enter_never_leaves_because_it_always_means_open(self) -> None: + # Even an estate with no sessions still lists New session, Projects, + # Closed sessions, and Help, so there is always something under the + # highlight for Enter to mean. + screen = picker() + self.assertIsNotNone(screen.current_row()) + screen.enter() + self.assertFalse(screen.leaving) + screen = picker(row("Alpha", number=1)) + screen.enter() + self.assertFalse(screen.leaving) + + def test_enter_under_a_filter_that_matches_nothing_stays_put(self) -> None: + screen = picker(row("Alpha", number=1)) + for character in "zzzz": + screen.type_text(character) + self.assertEqual([], list(screen.visible_rows())) + self.assertIsNone(screen.enter()) + self.assertFalse(screen.leaving) + + def test_a_pasted_close_command_becomes_a_filter_and_runs_nothing(self) -> None: + screen = picker(row("Alpha", number=1), row("Beta", number=17)) + for character in "k 17": + screen.type_text(character) + self.assertTrue(screen.is_filter) + self.assertEqual((), screen.marks) + self.assertIsNone(screen.enter()) + self.assertFalse(screen.leaving) + + +class FilterTests(unittest.TestCase): + def test_the_filter_narrows_the_list_as_it_is_typed(self) -> None: + screen = picker(row("Blueprint", number=1), row("Config Sweep", number=2)) + for character in "blu": + screen.type_text(character) + self.assertEqual(["Blueprint"], session_labels(screen)) + + def test_backspace_widens_it_again(self) -> None: + screen = picker(row("Blueprint", number=1), row("Config Sweep", number=2)) + for character in "blu": + screen.type_text(character) + for _ in range(3): + screen.backspace() + self.assertEqual(["Blueprint", "Config Sweep"], session_labels(screen)) + + +class EnterTests(unittest.TestCase): + def test_enter_on_a_row_opens_that_session(self) -> None: + screen = picker(row("Alpha", number=1)) + batch = screen.enter() + self.assertIsNotNone(batch) + self.assertEqual(("sp", "picker-open", "{proof}"), batch.plans[0].argv) + self.assertEqual("Opened Alpha.", batch.success) + + def test_enter_on_a_session_open_elsewhere_takes_it_over(self) -> None: + screen = picker(row("Alpha", number=1, availability="attached")) + batch = screen.enter() + self.assertEqual("picker-takeover", batch.plans[0].argv[1]) + + def test_enter_with_marks_opens_the_actions_with_close_highlighted(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.type_text("1") + self.assertIsNone(screen.enter()) + self.assertIsNotNone(screen.panel) + self.assertEqual("Close", screen.panel.current.label) + self.assertEqual( + ["Open", "History", "Close", "Change account", "Change model", "Rename", "Color"], + [item.label for item in screen.panel.items], + ) + + def test_close_of_several_marked_sessions_reports_once(self) -> None: + screen = picker(row("Alpha", number=1), row("Beta", number=2)) + for character in "1,2": + screen.type_text(character) + screen.enter() + batch = screen.enter() + self.assertEqual(2, len(batch.plans)) + self.assertEqual("Closed 2 sessions.", batch.success) + self.assertTrue(all(plan.argv[1] == "picker-close" for plan in batch.plans)) + + def test_closing_one_marked_session_names_it(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.type_text("1") + screen.enter() + batch = screen.enter() + self.assertEqual("Closed Alpha.", batch.success) + + def test_several_marked_sessions_offer_only_what_fits_them_all(self) -> None: + screen = picker(row("Alpha", number=1), row("Beta", number=2)) + for character in "1,2": + screen.type_text(character) + screen.enter() + self.assertEqual(["Close"], [item.label for item in screen.panel.items]) + self.assertEqual("2 sessions marked", screen.panel.title) + + def test_the_counted_machine_row_opens_in_place(self) -> None: + screen = picker( + row("Mine", number=1), + row("Drill", number=2, origin="machine", agent_status="working"), + ) + screen.move(1) + self.assertEqual("1 machine session", screen.current_row().label) + self.assertIsNone(screen.enter()) + self.assertIn("Drill", session_labels(screen)) + screen.enter() + self.assertNotIn("Drill", session_labels(screen)) + + def test_the_help_row_opens_and_closes_the_help(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.move(500) + screen.enter() + self.assertEqual("help", screen.view) + screen.enter() + self.assertEqual("list", screen.view) + + def test_the_closed_row_opens_the_closed_list(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.set_closed( + [ + ClosedSession( + key="u1", + title="Orphaned Record Audit", + provider="claude", + uuid="u1", + cwd="/srv/project", + account_alias="primary", + closed_at_unix_ms=1, + ) + ] + ) + row_to(screen, rowmod.CLOSED) + screen.enter() + self.assertEqual("closed", screen.view) + self.assertEqual(["Orphaned Record Audit"], [r.label for r in screen.visible_rows()]) + + def test_a_closed_conversation_offers_restore_first(self) -> None: + screen = picker() + screen.view = "closed" + screen.set_closed( + [ + ClosedSession( + key="u1", + title="Orphaned Record Audit", + provider="claude", + uuid="u1", + cwd="/srv/project", + account_alias=None, + closed_at_unix_ms=1, + ) + ] + ) + screen.enter() + self.assertEqual("Restore", screen.panel.current.label) + batch = screen.enter() + self.assertEqual( + ("sp", "restore-exact", "claude", "u1", "/srv/project"), batch.plans[0].argv + ) + self.assertEqual("Restored Orphaned Record Audit.", batch.success) + + def test_a_closed_shell_session_offers_history_and_says_why(self) -> None: + screen = picker() + screen.view = "closed" + screen.set_closed( + [ + ClosedSession( + key="sk-1", + title="Build log", + provider="shell", + uuid="", + cwd="/srv/project", + account_alias=None, + closed_at_unix_ms=1, + ) + ] + ) + screen.enter() + self.assertEqual(["History"], [item.label for item in screen.panel.items]) + self.assertIn("history", screen.panel.items[0].detail) + + +class PanelTests(unittest.TestCase): + def _panel(self, **keywords): + screen = picker(row("Alpha", number=1), **keywords) + screen.type_text("1") + screen.enter() + return screen + + def test_escape_closes_the_panel_and_says_nothing_changed(self) -> None: + screen = self._panel() + screen.escape() + self.assertIsNone(screen.panel) + self.assertEqual("Nothing changed.", screen.status) + + def test_rename_takes_the_typed_name_without_asking_twice(self) -> None: + screen = self._panel() + panel_to(screen, "Rename") + screen.enter() + for character in "Blueprint": + screen.type_text(character) + batch = screen.enter() + self.assertEqual(("sp", "picker-name", "{proof}", "Blueprint"), batch.plans[0].argv) + self.assertEqual("Renamed Blueprint.", batch.success) + + def test_an_empty_rename_changes_nothing(self) -> None: + screen = self._panel() + panel_to(screen, "Rename") + screen.enter() + self.assertIsNone(screen.enter()) + self.assertEqual("Nothing changed.", screen.status) + + def test_change_account_offers_the_accounts_it_was_given(self) -> None: + screen = self._panel(accounts=("primary", "spare")) + panel_to(screen, "Change account") + screen.enter() + self.assertEqual(["primary", "spare"], [item.label for item in screen.panel.items]) + batch = screen.enter() + self.assertEqual( + ("sp", "picker-account-switch", "{proof}", "primary"), batch.plans[0].argv + ) + + def test_change_model_offers_the_models_it_was_given(self) -> None: + screen = self._panel(models=("opus", "sonnet")) + panel_to(screen, "Change model") + screen.enter() + batch = screen.enter() + self.assertEqual(("sp", "change-model", "1", "opus"), batch.plans[0].argv) + + def test_change_model_with_nothing_to_offer_says_so(self) -> None: + screen = self._panel() + panel_to(screen, "Change model") + self.assertIsNone(screen.enter()) + self.assertEqual("Models: none.", screen.status) + + def test_color_offers_the_palette_the_provider_accepts(self) -> None: + screen = self._panel() + panel_to(screen, "Color") + screen.enter() + self.assertEqual("red", screen.panel.items[0].label) + batch = screen.enter() + self.assertEqual(("sp", "color", "1", "red"), batch.plans[0].argv) + self.assertEqual("Alpha is now red.", batch.success) + + def test_a_codex_session_is_offered_the_codex_palette(self) -> None: + screen = picker(row("Beta", number=1, provider="codex")) + screen.type_text("1") + screen.enter() + panel_to(screen, "Color") + screen.enter() + self.assertEqual("lime", screen.panel.items[0].label) + + def test_history_is_an_action_on_every_row(self) -> None: + screen = self._panel() + panel_to(screen, "History") + batch = screen.enter() + self.assertEqual(("sp", "picker-history", "{proof}"), batch.plans[0].argv) + + +class NewSessionTests(unittest.TestCase): + def test_new_session_asks_provider_then_project_and_nothing_else(self) -> None: + screen = picker(row("Alpha", number=1), projects=(("main", "/srv/project"),)) + row_to(screen, rowmod.NEW_SESSION) + screen.enter() + self.assertEqual(["Claude Code", "Codex", "Shell"], [i.label for i in screen.panel.items]) + screen.enter() + self.assertEqual(["This directory", "main"], [i.label for i in screen.panel.items]) + screen.move(1) + batch = screen.enter() + self.assertEqual(("sp", "new", "claude", "main"), batch.plans[0].argv) + + def test_projects_asks_project_then_provider(self) -> None: + screen = picker(row("Alpha", number=1), projects=(("main", "/srv/project"),)) + row_to(screen, rowmod.PROJECTS) + screen.enter() + self.assertEqual("Project", screen.panel.title) + screen.move(1) + screen.enter() + self.assertEqual("New session", screen.panel.title) + batch = screen.enter() + self.assertEqual(("sp", "new", "claude", "main"), batch.plans[0].argv) + + +class MouseTests(unittest.TestCase): + def test_a_click_highlights_and_a_second_click_opens(self) -> None: + screen = picker(row("Alpha", number=1), row("Beta", number=2)) + drawn = screen.frame(width=100, height=20) + line = next( + number for number, key in drawn.row_lines.items() + if key == screen.visible_rows()[1].key + ) + wanted = screen.visible_rows()[1] + self.assertIsNone(screen.click(line, 20, drawn)) + self.assertEqual(wanted.label, screen.current_row().label) + batch = screen.click(line, 20, drawn) + self.assertEqual(f"Opened {wanted.label}.", batch.success) + + def test_a_click_in_the_mark_column_ticks_the_row(self) -> None: + screen = picker(row("Alpha", number=1), row("Beta", number=2)) + drawn = screen.frame(width=100, height=20) + line = next( + number for number, key in drawn.row_lines.items() + if key == screen.visible_rows()[1].key + ) + wanted = screen.visible_rows()[1] + screen.click(line, 0, drawn) + self.assertEqual((wanted.number,), screen.marks) + screen.click(line, 0, drawn) + self.assertEqual((), screen.marks) + + def test_the_wheel_scrolls_the_list(self) -> None: + screen = picker(*[row(f"S{n}", number=n) for n in range(1, 30)]) + screen.frame(width=100, height=12) + screen.wheel(3) + self.assertEqual(3, screen.top) + screen.wheel(-3) + self.assertEqual(0, screen.top) + + def test_a_click_on_a_panel_row_takes_it(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.type_text("1") + screen.enter() + drawn = screen.frame(width=100, height=20) + line = next( + number for number, key in drawn.row_lines.items() if key == "panel:close" + ) + batch = screen.click(line, 6, drawn) + self.assertEqual("Closed Alpha.", batch.success) + + +class ResultTests(unittest.TestCase): + def test_a_finished_action_reports_after_the_fact(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.type_text("1") + screen.enter() + batch = screen.enter() + screen.action_finished(batch, ok=True) + self.assertEqual("Closed Alpha.", screen.status) + self.assertEqual("", screen.input_text) + + def test_a_refused_action_says_nothing_changed(self) -> None: + screen = picker(row("Alpha", number=1)) + screen.type_text("1") + screen.enter() + batch = screen.enter() + screen.action_finished(batch, ok=False) + self.assertEqual("Close was refused. Nothing changed.", screen.status) + + def test_a_verb_that_answers_for_itself_is_quoted_as_it_answered(self) -> None: + screen = picker(row("Alpha", number=1), models=("opus",)) + screen.type_text("1") + screen.enter() + panel_to(screen, "Change model") + screen.enter() + batch = screen.enter() + screen.action_finished( + batch, ok=False, note="session-kit: there is no command named change-model" + ) + self.assertEqual( + "session-kit: there is no command named change-model", screen.status + ) + + def test_a_cached_list_refuses_to_open_anything(self) -> None: + screen = picker() + screen.set_inventory(inventory(row("Alpha", number=1), stale=True)) + self.assertIsNone(screen.enter()) + self.assertEqual("Nothing changed.", screen.status) + + +class NoConfirmationTests(unittest.TestCase): + def test_no_screen_in_any_action_path_asks_a_question(self) -> None: + screen = picker(row("Alpha", number=1), accounts=("primary",), models=("opus",)) + seen = [screen.frame(width=100, height=20).joined()] + screen.type_text("1") + seen.append(screen.frame(width=100, height=20).joined()) + screen.enter() + seen.append(screen.frame(width=100, height=20).joined()) + for _ in range(len(screen.panel.items)): + seen.append(screen.frame(width=100, height=20).joined()) + screen.move(1) + for text in seen: + text = "\n".join(text.splitlines()[:-1]) + self.assertNotIn("[y/N]", text) + self.assertNotIn("(y/n)", text) + self.assertNotIn("Are you sure", text) + self.assertNotIn("?", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_voice_labels.py b/tests/test_voice_labels.py new file mode 100644 index 0000000..88b66ea --- /dev/null +++ b/tests/test_voice_labels.py @@ -0,0 +1,547 @@ +"""One vocabulary: the renderers may not spell a word a person reads. + +`lib/sessionkit_inventory/labels.py` is `docs/voice.md` as code. These tests +hold three lines at once: + +* no renderer contains a human label as a literal, the label module is the + only place a word lives; +* what `sp list` and `sp detail` print is exactly what the label module says, + asserted against the module rather than against a second hardcoded copy, so + the strings the cohesion release fixed cannot drift back; +* the bash picker, which cannot import Python, still carries the same table. +""" + +from __future__ import annotations + +import ast +import os +from pathlib import Path +import re +import sys +import unittest + +from tests.support import REPO + + +sys.path.insert(0, os.fspath(REPO / "lib")) + +from sessionkit_inventory import labels # noqa: E402 +from sessionkit_inventory import render # noqa: E402 + + +RENDERERS = ( + REPO / "lib" / "sessionkit_inventory" / "render.py", +) +PICKER_RENDER = REPO / "lib" / "sh" / "shpool_login_render.sh" +NOW_MS = 1_800_000_000_000 + + +def code_strings(path: Path) -> list[tuple[int, str]]: + """Every string literal in a module except its docstrings. + + Comments and docstrings explain the words; only a literal the code can + print is a second copy of one. + """ + tree = ast.parse(path.read_text(encoding="utf-8")) + docstrings = set() + for node in ast.walk(tree): + if isinstance( + node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) + ): + if ast.get_docstring(node, clean=False) is not None: + docstrings.add(id(node.body[0].value)) + found = [] + for node in ast.walk(tree): + if ( + isinstance(node, ast.Constant) + and isinstance(node.value, str) + and id(node) not in docstrings + ): + found.append((node.lineno, node.value)) + return found + + +def banned_labels() -> dict[str, str]: + """Every word the label module owns, mapped to the name that owns it.""" + owned: dict[str, str] = {} + + def remember(name: str, value: object) -> None: + if isinstance(value, str) and len(value) >= 4: + owned.setdefault(value, name) + elif isinstance(value, (tuple, list)): + for item in value: + remember(name, item) + elif isinstance(value, dict): + for key, item in value.items(): + remember(name, key) + remember(name, item) + + for name in dir(labels): + if name.startswith("_") or name.isupper() is False: + continue + remember(name, getattr(labels, name)) + return owned + + +def row( + shpool_id: str, + *, + number: int | None = 1, + provider: str = "claude", + availability: str = "ready", + needs_you: bool = False, + agent_status: str = "running", + **extra: object, +) -> dict: + item = { + "row": number or 0, + "terminal_number": number, + "shpool_id": shpool_id, + "shpool_id_raw": shpool_id, + "availability": availability, + "provider": provider, + "display_provider": provider, + "identity": {"uuid": f"uuid-{shpool_id}", "confidence": "exact"}, + "title": f"session {shpool_id}", + "display_title": f"session {shpool_id}", + "cwd": "/srv/project", + "started_at_unix_ms": NOW_MS - 3_600_000, + "process_age_seconds": 60, + "recent_output_age_seconds": 30, + "agent_status": agent_status, + "needs_you": needs_you, + "subagents": [], + "active_subagent_count": 0, + "account_alias": "acct", + "provider_title_state": "ready", + } + item.update(extra) + return item + + +def inventory(*rows: dict, **fields: object) -> dict: + document = { + "schema_version": 1, + "generated_at": "2026-07-28T00:00:00Z", + "source": "live", + "stale": False, + "warnings": [], + "daemon_generation": {"pid": 7, "process_start_ticks": 70}, + "sessions": list(rows), + "outside_agents": [], + } + document.update(fields) + return document + + +def render_list(document: dict, columns: str = "200") -> list[str]: + previous = os.environ.get("COLUMNS") + os.environ["COLUMNS"] = columns + try: + return render.render_inventory( + document, False, color_enabled=lambda: False + ).splitlines() + finally: + if previous is None: + os.environ.pop("COLUMNS", None) + else: + os.environ["COLUMNS"] = previous + + +class LabelSourceTests(unittest.TestCase): + def test_no_renderer_spells_a_word_the_label_module_owns(self) -> None: + owned = banned_labels() + offences = [] + for path in RENDERERS: + for line, value in code_strings(path): + owner = owned.get(value) + if owner is not None: + offences.append( + f"{path.relative_to(REPO)}:{line} spells {value!r}; " + f"import labels.{owner} instead" + ) + self.assertEqual([], offences, "\n".join(offences)) + + def test_a_renderer_may_not_hide_a_phrase_inside_a_longer_literal(self) -> None: + phrases = { + value: name + for value, name in banned_labels().items() + if " " in value + } + offences = [] + for path in RENDERERS: + for line, value in code_strings(path): + for phrase, owner in phrases.items(): + if phrase in value: + offences.append( + f"{path.relative_to(REPO)}:{line} contains {phrase!r} " + f"(labels.{owner})" + ) + self.assertEqual([], offences, "\n".join(offences)) + + def test_every_state_word_is_named(self) -> None: + for word in labels.STATE_WORDS.values(): + self.assertIn(word, labels.SESSION_STATES) + self.assertEqual( + labels.NEEDS_YOU, labels.STATE_WORDS[labels.STATUS_NEEDS_YOUR_REPLY] + ) + self.assertEqual(labels.WORKING, labels.STATE_WORDS[labels.STATUS_RUNNING]) + self.assertEqual( + labels.WORKING, labels.STATE_WORDS[labels.STATUS_REPLY_OPTIONAL] + ) + # `sp list` renames only the word that contradicted the term sheet. + self.assertEqual( + {labels.STATUS_NEEDS_YOUR_REPLY: labels.NEEDS_YOU}, + dict(labels.LIST_STATE_WORDS), + ) + + def test_the_bash_picker_carries_the_same_state_table(self) -> None: + source = PICKER_RENDER.read_text(encoding="utf-8") + match = re.search(r"STATE_WORDS = \{(.*?)\n\}", source, re.S) + if match is None: + self.skipTest("the picker no longer declares STATE_WORDS") + table = dict(re.findall(r'"([^"]+)": "([^"]+)"', match.group(1))) + self.assertEqual(dict(labels.STATE_WORDS), table) + + def test_the_bash_picker_uses_the_same_group_headings(self) -> None: + source = (REPO / "lib" / "sh" / "shpool_login_view.sh").read_text( + encoding="utf-8" + ) + self.assertIn(f'"{labels.GROUP_READY}"', source) + self.assertIn(f'"{labels.GROUP_OPEN_ELSEWHERE}"', source) + + +class LabelBehaviourTests(unittest.TestCase): + def test_provider_names_and_the_placeholder(self) -> None: + self.assertEqual("Claude", labels.provider_name(labels.PROVIDER_CLAUDE)) + self.assertEqual("Codex", labels.provider_name(labels.PROVIDER_CODEX)) + self.assertEqual("Shell", labels.provider_name(labels.PROVIDER_SHELL)) + self.assertEqual("Unknown", labels.provider_name(labels.PROVIDER_UNKNOWN)) + self.assertEqual("Claude", labels.provider_name("CLAUDE")) + self.assertEqual(labels.MISSING, labels.provider_name("")) + self.assertEqual(labels.MISSING, labels.provider_name(None)) + + def test_where_a_session_can_be_opened(self) -> None: + self.assertEqual( + labels.WHERE_READY, labels.where_word(labels.AVAILABILITY_READY) + ) + self.assertEqual( + labels.WHERE_OPEN_ELSEWHERE, + labels.where_word(labels.AVAILABILITY_ATTACHED), + ) + self.assertEqual(labels.MISSING, labels.where_word("")) + self.assertEqual( + labels.GROUP_READY, labels.group_heading(labels.AVAILABILITY_READY) + ) + self.assertEqual( + labels.GROUP_OPEN_ELSEWHERE, + labels.group_heading(labels.AVAILABILITY_ATTACHED), + ) + + def test_state_precedence(self) -> None: + state = lambda item: labels.session_state(item, stall_seconds=2700) # noqa: E731 + self.assertEqual( + labels.SETUP_INCOMPLETE, + state({"setup_incomplete": True, "needs_you": True}), + ) + self.assertEqual(labels.NEEDS_YOU, state({"needs_you": True})) + self.assertEqual( + labels.NEEDS_YOU, state({"agent_status": labels.STATUS_NEEDS_YOUR_REPLY}) + ) + self.assertEqual( + labels.WORKING, state({"agent_status": labels.STATUS_RUNNING}) + ) + self.assertEqual( + labels.WORKING, state({"agent_status": labels.STATUS_REPLY_OPTIONAL}) + ) + self.assertEqual( + labels.WORKING, + state({"agent_status": labels.STATUS_RUNNING, "reply_optional": True}), + ) + # An optional reply is still the model working, however long it is quiet. + self.assertEqual( + labels.WORKING, + state( + { + "agent_status": labels.STATUS_REPLY_OPTIONAL, + "recent_output_age_seconds": 99_999, + } + ), + ) + # A session quiet for the whole stall window has stopped working, + # and what is left is the person. There is no third word for it. + self.assertEqual( + labels.WAITING_ON_YOU, + state( + { + "agent_status": labels.STATUS_RUNNING, + "recent_output_age_seconds": 2700, + } + ), + ) + # The two vocabularies for one situation. Claude's poll parks a + # session at its prompt as `needs your reply`; Codex's rollout parks + # the same session as `idle`. One situation, one word. + self.assertEqual(labels.WAITING_ON_YOU, state({"agent_status": labels.STATUS_IDLE})) + self.assertEqual( + state({"agent_status": labels.STATUS_NEEDS_YOUR_REPLY}), + state({"agent_status": labels.STATUS_IDLE}), + ) + self.assertEqual( + labels.WORKING, + state( + { + "agent_status": labels.STATUS_RUNNING, + "recent_output_age_seconds": 2699, + } + ), + ) + self.assertEqual( + labels.STATUS_UNAVAILABLE, + state({"agent_status": labels.STATUS_STATE_UNAVAILABLE}), + ) + self.assertEqual(labels.STATUS_UNAVAILABLE, state({})) + # A provider that exited is a person's turn: only they can restore or + # close the shell it left behind. It used to print the collector's own + # `provider exited` straight onto the screen. + self.assertEqual( + labels.WAITING_ON_YOU, + state({"agent_status": labels.STATUS_PROVIDER_EXITED}), + ) + # And a word this file has never seen is the kit failing to read a + # state, not a state. Nothing reaches a row verbatim. + for unknown in ("busy", "brand-new-vendor-word", "unknown"): + self.assertEqual( + labels.STATUS_UNAVAILABLE, state({"agent_status": unknown}) + ) + + def test_age_phrases(self) -> None: + self.assertEqual("0m", labels.duration(0)) + self.assertEqual("59m", labels.duration(59 * 60)) + self.assertEqual("1h 0m", labels.duration(3600)) + self.assertEqual("1h 1m", labels.duration(3661)) + self.assertEqual("1d 1h", labels.duration(90_061)) + self.assertEqual("now", labels.brief_age(59)) + self.assertEqual("1m", labels.brief_age(60)) + + self.assertEqual(labels.JUST_NOW, labels.relative_time(NOW_MS, NOW_MS)) + self.assertEqual("1 min ago", labels.relative_time(NOW_MS - 60_000, NOW_MS)) + self.assertEqual("2 hr ago", labels.relative_time(NOW_MS - 7_200_000, NOW_MS)) + self.assertEqual("1 day ago", labels.relative_time(NOW_MS - 86_400_000, NOW_MS)) + self.assertEqual( + "2 days ago", labels.relative_time(NOW_MS - 172_800_000, NOW_MS) + ) + + self.assertEqual(f"{labels.NEEDS_YOU} under 1 min", labels.waiting_phrase(0)) + self.assertEqual(f"{labels.NEEDS_YOU} 12 min", labels.waiting_phrase(12 * 60)) + self.assertEqual(f"{labels.NEEDS_YOU} 1 hr", labels.waiting_phrase(3600)) + self.assertEqual(f"{labels.NEEDS_YOU} 2 days", labels.waiting_phrase(2 * 86400)) + self.assertEqual( + f"{labels.NEEDS_YOU} 12 min", + labels.waiting_since(NOW_MS - 12 * 60_000, NOW_MS), + ) + self.assertTrue( + labels.waiting_since(NOW_MS - 40 * 86_400_000, NOW_MS).startswith( + f"{labels.NEEDS_YOU} since " + ) + ) + + def test_counts_never_print_a_bare_plural(self) -> None: + self.assertEqual("1 session: 1 ready, 0 open elsewhere", + labels.session_count(1, 1, 0)) + self.assertEqual("3 sessions: 2 ready, 1 open elsewhere", + labels.session_count(3, 2, 1)) + self.assertEqual("1 subagent", labels.subagent_detail(1)) + self.assertEqual("2 subagents", labels.subagent_detail(2)) + self.assertEqual("Accounts: none.", labels.empty_state("Accounts")) + + def test_refusal_and_cancel_grammar(self) -> None: + self.assertEqual("Nothing changed.", labels.CANCEL) + self.assertEqual( + "There is no session 20 on this screen. Numbers shown here work.", + labels.no_such_session(20), + ) + self.assertTrue(labels.NO_MATCHING_SESSION.startswith(labels.ERROR_PREFIX)) + self.assertEqual( + "session-kit: no session matches that selector", + labels.NO_MATCHING_SESSION, + ) + self.assertEqual("session-kit: shpool is unavailable", + labels.error("shpool is unavailable")) + + +class RenderedOutputTests(unittest.TestCase): + """What the screens print is what the label module says, not a copy.""" + + def test_list_headings_counts_and_hints(self) -> None: + document = inventory( + row("aaa", number=1, needs_you=True), + row("bbb", number=2, provider="codex", availability="attached"), + ) + lines = render_list(document) + self.assertEqual(f" {labels.session_count(2, 1, 1)}", lines[0]) + self.assertIn(f" {labels.GROUP_READY}", lines) + self.assertIn(f" {labels.GROUP_OPEN_ELSEWHERE}", lines) + self.assertTrue(any("| CLD | acct |" in line for line in lines)) + self.assertTrue(any("| CDX | acct |" in line for line in lines)) + self.assertEqual([f" {hint}" for hint in labels.LIST_HINTS], lines[-2:]) + + def test_both_a_waiting_row_and_a_quiet_row_say_waiting_on_you(self) -> None: + document = inventory( + row("aaa", number=1, needs_you=True), + row("bbb", number=2, recent_output_age_seconds=99_999), + ) + lines = render_list(document) + waiting = next(line for line in lines if "session aaa" in line) + self.assertIn(f"{labels.SEPARATOR}{labels.WAITING_ON_YOU}", waiting) + quiet = next(line for line in lines if "session bbb" in line) + self.assertIn(labels.WAITING_ON_YOU, quiet) + self.assertNotIn("quiet", quiet) + self.assertNotIn("idle", quiet) + self.assertIn(labels.last_active(99_999), quiet) + + def test_a_row_carries_one_time_and_names_it_the_same_way_every_time(self) -> None: + document = inventory( + row("aaa", number=1, needs_you=True, recent_output_age_seconds=11_340), + row("bbb", number=2, recent_output_age_seconds=30), + ) + rows_shown = [ + line for line in render_list(document) if "| CLD |" in line + ] + self.assertEqual(2, len(rows_shown)) + for line in rows_shown: + self.assertEqual(1, line.count(labels.LAST_ACTIVE), line) + self.assertNotIn("process age", line) + self.assertNotIn("recent output", line) + self.assertNotIn("opened", line) + # Same starting column on both rows: that is what "aligned" means. + columns = {line.index(labels.LAST_ACTIVE) for line in rows_shown} + self.assertEqual(1, len(columns), rows_shown) + + def test_an_empty_list_and_a_stale_list_use_the_owned_strings(self) -> None: + self.assertIn(f" {labels.SESSIONS_EMPTY}", render_list(inventory())) + stale = render_list( + inventory(row("aaa"), source="cache", stale=True) + ) + self.assertEqual(f" {labels.stale_warning('cache')}", stale[0]) + + def test_machine_rows_are_counted_without_names_and_columns_are_bounded(self) -> None: + person = row( + "person", + account_alias="account-alias-that-is-far-too-long", + model="claude-model-identifier-that-is-far-too-long", + ) + machine = row("secret-worker", number=2, provider="codex", origin="machine") + lines = render_list(inventory(person, machine), columns="80") + text = "\n".join(lines) + self.assertNotIn("secret-worker", text) + self.assertIn("1 machine session", text) + person_line = next(line for line in lines if "| CLD |" in line) + self.assertIn("| CLD |", person_line) + self.assertIn("…", person_line) + self.assertNotIn("account-alias-that-is-far-too-long", person_line) + self.assertNotIn("claude-model-identifier-that-is-far-too-long", person_line) + self.assertLessEqual(render._display_width(person_line), 79) + + def test_an_unnumbered_row_shows_the_unnumbered_mark(self) -> None: + document = inventory(row("aaa", number=1), row("bbb", number=None)) + rows = [line for line in render_list(document) if "session bbb" in line] + self.assertEqual(1, len(rows)) + self.assertIn(f" {labels.UNNUMBERED} ", rows[0]) + + def test_outside_the_kit_is_one_heading(self) -> None: + document = inventory( + row("aaa"), + outside_agents=[ + { + "provider": "claude", + "title": "outside", + "agent_status": labels.STATUS_NEEDS_YOUR_REPLY, + "subagents": [], + "active_subagent_count": 0, + } + ], + ) + lines = render_list(document) + self.assertIn(f" {labels.GROUP_OUTSIDE_THE_KIT}", lines) + detail = next(line for line in lines if labels.provider_name("claude") in line + and labels.NEEDS_YOU in line) + self.assertIn( + f"{labels.provider_name('claude')}{labels.SEPARATOR}{labels.NEEDS_YOU}", + detail, + ) + + def test_detail_fields_and_values_are_owned(self) -> None: + document = inventory( + row( + "aaa", + number=9, + needs_you=True, + worktree={"branch": "track/b-renderer"}, + subagents=[{"status": "running"}], + ) + ) + text = render.render_detail( + document, "9", home_factory=lambda: "/srv", now_ms=NOW_MS + ) + fields = { + line.strip().split(" ", 1)[0]: line.strip().split(" ", 1)[1].strip() + for line in text.splitlines()[1:] + } + self.assertEqual(f" {labels.DETAIL_HEADING}", text.splitlines()[0]) + self.assertEqual("9", fields[labels.DETAIL_SESSION]) + self.assertEqual(labels.provider_name("claude"), fields[labels.DETAIL_PROVIDER]) + self.assertEqual(labels.NEEDS_YOU, fields[labels.DETAIL_STATE]) + self.assertEqual(labels.WHERE_READY, fields[labels.DETAIL_WHERE]) + self.assertEqual(labels.CONVERSATION_EXACT, fields[labels.DETAIL_CONVERSATION]) + self.assertEqual(labels.TITLE_STATE_SHOWING, fields[labels.DETAIL_TITLE_STATE]) + self.assertIn(labels.DETAIL_OPENED, fields) + + def test_detail_lists_each_aged_child_with_its_age_on_the_same_row(self) -> None: + document = inventory( + row( + "aaa", + number=9, + aged_children=[ + {"kind": "shell", "title": "bash", "age_seconds": 7860}, + { + "kind": "worker", + "provider": "claude", + "title": "Verifier", + "age_seconds": 3661, + }, + {"kind": "shell", "title": "young", "age_seconds": 3599}, + ], + ) + ) + + text = render.render_detail( + document, "9", home_factory=lambda: "/srv", now_ms=NOW_MS + ) + shell_row = next( + line for line in text.splitlines() if labels.DETAIL_CHILD_SHELL in line + ) + worker_row = next( + line for line in text.splitlines() if labels.DETAIL_CHILD_WORKER in line + ) + self.assertIn(f"bash{labels.SEPARATOR}2h 11m", shell_row) + self.assertIn(f"Verifier{labels.SEPARATOR}1h 1m", worker_row) + self.assertNotIn("young", text) + + def test_detail_of_an_unnumbered_session_and_a_missing_one(self) -> None: + document = inventory(row("aaa", number=None, row=1)) + text = render.render_detail( + document, "1", home_factory=lambda: "/srv", now_ms=NOW_MS + ) + self.assertIn(labels.NOT_NUMBERED, text) + self.assertIn(labels.NONE, text) + self.assertEqual( + labels.NO_MATCHING_SESSION, + render.render_detail( + document, "404", home_factory=lambda: "/srv", now_ms=NOW_MS + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_watchdog.py b/tests/test_watchdog.py index 1fac9b3..24c9e0b 100644 --- a/tests/test_watchdog.py +++ b/tests/test_watchdog.py @@ -18,12 +18,23 @@ import os import subprocess import tempfile +import time import unittest from pathlib import Path REPO = Path(__file__).resolve().parent.parent WATCHDOG = REPO / "bin" / "session_kit_watchdog" +# A journal monotonic timestamp counts from boot, and the watchdog discards any +# event that predates the daemon generation it is reasoning about. Deriving +# fixture timestamps from the host clock made these tests depend on the host's +# uptime: on a freshly booted CI runner, `time.monotonic()` is smaller than the +# ages below, every event landed at or before zero, the watchdog correctly +# dropped the evidence, and the repairs these tests exist to prove never +# happened. The fixture owns its own boot-relative clock instead, far enough +# past the oldest age any case uses that no event can fall off the start of it. +FIXTURE_BOOT_SECONDS = 100_000 + def write_executable(path: Path, body: str) -> None: path.write_text(body, encoding="utf-8") @@ -39,8 +50,9 @@ def session( uuid: str | None = "00000000-0000-4000-8000-000000000001", mutation_allowed: bool = True, availability: str = "ready", + needs_you: bool | None = None, ) -> dict: - return { + row = { "shpool_id": shpool_id, "shpool_id_raw": shpool_id, "display_shpool_id": shpool_id, @@ -53,13 +65,53 @@ def session( "identity": {"uuid": uuid, "confidence": "exact"}, "availability": availability, } + if needs_you is not None: + row["needs_you"] = needs_you + return row + + +def journal_event(message: str, *, age_seconds: int = 300, pid: int | None = None) -> str: + """Return one journalctl -o json fixture line.""" + if age_seconds >= FIXTURE_BOOT_SECONDS: + raise ValueError( + "journal fixtures must fall after the fixture boot clock; " + f"raise FIXTURE_BOOT_SECONDS above {age_seconds}" + ) + return json.dumps( + { + "_PID": str(os.getpid() if pid is None else pid), + "__MONOTONIC_TIMESTAMP": str( + int((FIXTURE_BOOT_SECONDS - age_seconds) * 1_000_000) + ), + "__REALTIME_TIMESTAMP": str( + int((time.time() - age_seconds) * 1_000_000) + ), + "MESSAGE": message, + } + ) + + +def failure_event( + session_id: str = "wedged", *, age_seconds: int = 300, pid: int | None = None +) -> str: + return journal_event( + f'handle_attach:bidi_stream{{s="{session_id}"}}:' + "disconnect_lock(shell_to_client_ctl): failed to tell shell->client " + 'to disconnect: "SendTimeoutError(..)"', + age_seconds=age_seconds, + pid=pid, + ) -FLAG_LINE = ( - 'handle_conn{cid=1}:handle_attach:bidi_stream{s="wedged"}' - ':disconnect_lock(shell_to_client_ctl): failed to tell shell->client to ' - 'disconnect: "SendTimeoutError(..)"' -) +def success_event(session_id: str = "wedged", *, age_seconds: int = 60) -> str: + return journal_event( + f'handle_attach:bidi_stream{{s="{session_id}"}}:' + "initial_attach_lock(shell_to_client_ctl): client connection status=New", + age_seconds=age_seconds, + ) + + +FLAG_LINE = failure_event() class WatchdogFixture: @@ -70,6 +122,7 @@ def __init__( serving_threads: int | None = None, journal_lines: str = "", repair_exit: int = 0, + daemon_start_seconds: int = 0, ) -> None: self.temp = tempfile.TemporaryDirectory(prefix=".watchdog-", dir=REPO) self.base = Path(self.temp.name) @@ -100,7 +153,13 @@ def __init__( "warnings": [], "daemon_generation": { "pid": os.getpid(), - "process_start_ticks": 1, + # Boot-relative, on the same clock as the journal + # fixtures above. The default sits before every event + # so ordinary cases are unaffected by it. + "process_start_ticks": max( + 1, + int(daemon_start_seconds * os.sysconf("SC_CLK_TCK")), + ), }, "sessions": sessions, "outside_agents": [], @@ -161,7 +220,8 @@ def __init__( self.shpool, f"""#!/usr/bin/env bash printf '%s\\n' "$*" >> {str(self.shpool_log)!r} -exit 0 +echo 'test fixture refuses shpool' >&2 +exit 97 """, ) @@ -172,7 +232,10 @@ def env(self) -> dict[str, str]: **os.environ, "PATH": f"{self.bin}:{os.environ['PATH']}", "HOME": str(self.base), + "XDG_STATE_HOME": str(self.base / "xdg-state"), + "XDG_DATA_HOME": str(self.base / "xdg-data"), "SESSION_KIT_STATE_DIR": str(self.state), + "SESSION_KIT_CONFIG": str(self.base / "config.toml"), "SESSION_KIT_JOURNAL_DIR": str(self.base / "journals"), "SESSION_KIT_ARCHIVE_DIR": str(self.base / "archives"), "SESSION_KIT_SHPOOL_CMD": str(self.shpool), @@ -219,6 +282,38 @@ def close(self) -> None: self.temp.cleanup() +class JournalFixtureClockTests(unittest.TestCase): + """The fixture clock must never borrow the host's uptime again. + + Every repair proof in this file feeds the watchdog journal evidence, and + the watchdog throws away an event that cannot have happened after the + daemon started. When the fixture read `time.monotonic()`, that made the + evidence a function of how long the machine had been up: correct on a + long-lived workstation, at or below zero on a runner minutes past boot, + where the repair cases all reported "no evidence" and passed nothing. + """ + + def test_event_timestamp_is_a_pure_function_of_its_age(self) -> None: + first = json.loads(failure_event(age_seconds=300)) + time.sleep(0.01) + second = json.loads(failure_event(age_seconds=300)) + + self.assertEqual( + first["__MONOTONIC_TIMESTAMP"], second["__MONOTONIC_TIMESTAMP"] + ) + self.assertEqual( + str((FIXTURE_BOOT_SECONDS - 300) * 1_000_000), + first["__MONOTONIC_TIMESTAMP"], + ) + + def test_every_supported_age_lands_after_the_daemon_started(self) -> None: + oldest = json.loads(failure_event(age_seconds=FIXTURE_BOOT_SECONDS - 1)) + + self.assertGreater(int(oldest["__MONOTONIC_TIMESTAMP"]), 0) + with self.assertRaises(ValueError): + failure_event(age_seconds=FIXTURE_BOOT_SECONDS) + + class WatchdogSafetyTests(unittest.TestCase): """Everything the watchdog must never do.""" @@ -285,8 +380,42 @@ def test_silence_alone_is_reported_and_never_repaired(self) -> None: waiting at its prompt, so a session parked for hours looks exactly like a frozen one. Closing on that basis would eventually close a live session. + + These are the statuses that say nothing either way. The statuses that + DO say a person is being waited on are the test below. + """ + for status in ("running", "working", "unknown"): + with self.subTest(agent_status=status): + fixture = WatchdogFixture( + sessions=[ + session( + agent_status=status, recent_output_age_seconds=100_000 + ) + ] + ) + try: + fixture.run() + self.assertEqual([], fixture.repairs_requested()) + recorded = fixture.recorded() + self.assertEqual(1, len(recorded)) + self.assertEqual("reported", recorded[0]["outcome"]) + finally: + fixture.close() + + def test_a_session_waiting_for_a_person_is_not_a_candidate_at_all(self) -> None: + """Quiet because it is your turn is not quiet because it is broken. + + This is the defect the operator hit on 2026-08-15: fifty records, every + one outcome=reported, twelve of them about sessions that were alive + minutes earlier and simply waiting for them. A research session lives in + "needs your reply" for days; 45 minutes of journal silence is its + NORMAL resting state, not evidence of a fault. + + The provider's own state word, and the row's own needs_you flag, are in + the snapshot the sweep already fetched -- it just was not reading them. + No record at all is the correct outcome, not a quieter record. """ - for status in ("running", "idle", "needs your reply", "working", "unknown"): + for status in ("idle", "needs your reply", "reply optional"): with self.subTest(agent_status=status): fixture = WatchdogFixture( sessions=[ @@ -298,12 +427,383 @@ def test_silence_alone_is_reported_and_never_repaired(self) -> None: try: fixture.run() self.assertEqual([], fixture.repairs_requested()) + self.assertEqual([], fixture.recorded()) + finally: + fixture.close() + + # The row's own needs_you flag says the same thing on its own, whatever + # word the provider used for its status. + with self.subTest(needs_you=True): + fixture = WatchdogFixture( + sessions=[ + session( + agent_status="running", + needs_you=True, + recent_output_age_seconds=100_000, + ) + ] + ) + try: + fixture.run() + self.assertEqual([], fixture.repairs_requested()) + self.assertEqual([], fixture.recorded()) + finally: + fixture.close() + + def test_a_wedged_session_is_still_caught_while_it_says_it_needs_you( + self, + ) -> None: + """The turn state must never suppress hard evidence. + + A terminal the daemon could not hand off is wedged whatever the + conversation last said. If quietening the waiting case had been applied + before the daemon-marker branch instead of after it, this session -- + the exact class the watchdog exists for -- would have gone unreported. + """ + fixture = WatchdogFixture( + sessions=[ + session( + agent_status="needs your reply", + needs_you=True, + recent_output_age_seconds=100_000, + ) + ], + journal_lines=FLAG_LINE, + ) + try: + fixture.run() + # Caught, and acted on: the marker is hard evidence, so this + # session reaches the repair path the waiting case never can. + self.assertEqual(["wedged"], fixture.repairs_requested()) + recorded = fixture.recorded() + self.assertEqual(1, len(recorded)) + self.assertIn("handoff failure", recorded[0]["reason"]) + finally: + fixture.close() + + def seed_records(self, fixture, *records) -> None: + fixture.repairs.write_text( + json.dumps({"schema_version": 1, "repairs": list(records)}, indent=2), + encoding="utf-8", + ) + + def record( + self, + shpool_id: str, + *, + age_days: float, + outcome: str = "reported", + evidence: str | None = None, + new_shpool_id: str = "", + ) -> dict: + row = { + "at_unix_ms": int((time.time() - age_days * 86400) * 1000), + "old_shpool_id": shpool_id, + "new_shpool_id": new_shpool_id, + "title": "a session", + "provider": "codex", + "outcome": outcome, + "reason": "no output for far longer than any normal pause", + "acknowledged": False, + } + # Omitted entirely by default: that is the shape of every record + # already on disk before this field existed. + if evidence is not None: + row["evidence"] = evidence + return row + + def test_a_record_whose_session_is_gone_retires_itself(self) -> None: + """Nothing ever removed a record before this. + + Thirty-eight of the operator's fifty named shpool ids that no longer + existed; the only bound was a 50-slot cap that dropped the OLDEST, so + the review list filled with sessions that had not existed for a day and + they were asked to clear them one at a time. + """ + fixture = WatchdogFixture(sessions=[session(recent_output_age_seconds=10)]) + try: + self.seed_records( + fixture, + self.record("long-gone", age_days=9), + self.record("wedged", age_days=9), + self.record("gone-but-recent", age_days=0.1), + ) + fixture.run() + kept = [item["old_shpool_id"] for item in fixture.recorded()] + # Gone AND past the window: retired. + self.assertNotIn("long-gone", kept) + # Still live, however old the record is: never touched. + self.assertIn("wedged", kept) + # Gone but inside the window: still readable. + self.assertIn("gone-but-recent", kept) + self.assertIn("retired 1 record", fixture.log_text()) + finally: + fixture.close() + + def test_retirement_never_runs_without_a_live_snapshot(self) -> None: + """A sweep that cannot see the estate must not erase its warnings.""" + fixture = WatchdogFixture(sessions=[session(recent_output_age_seconds=10)]) + try: + document = json.loads(fixture.inventory.read_text(encoding="utf-8")) + document["stale"] = True + document["source"] = "cache" + fixture.inventory.write_text(json.dumps(document), encoding="utf-8") + self.seed_records(fixture, self.record("long-gone", age_days=9)) + fixture.run() + self.assertEqual( + ["long-gone"], [item["old_shpool_id"] for item in fixture.recorded()] + ) + finally: + fixture.close() + + def test_retirement_off_switch_and_dry_run_change_nothing(self) -> None: + for label, overrides in ( + ("off", {"SESSION_KIT_WATCHDOG_RETIRE": "off"}), + ("zero days", {"SESSION_KIT_WATCHDOG_RETIRE_DAYS": "0"}), + ("dry run", {"SESSION_KIT_WATCHDOG_RETIRE_DRY_RUN": "1"}), + ): + with self.subTest(switch=label): + fixture = WatchdogFixture( + sessions=[session(recent_output_age_seconds=10)] + ) + try: + self.seed_records(fixture, self.record("long-gone", age_days=9)) + before = fixture.repairs.read_bytes() + fixture.run(**overrides) + self.assertEqual(before, fixture.repairs.read_bytes()) + finally: + fixture.close() + + def test_retirement_leaves_a_foreign_owned_file_alone(self) -> None: + """Own-uid only, and never through a symlink.""" + fixture = WatchdogFixture(sessions=[session(recent_output_age_seconds=10)]) + try: + elsewhere = fixture.base / "not-ours.json" + elsewhere.write_text( + json.dumps( + { + "schema_version": 1, + "repairs": [self.record("long-gone", age_days=9)], + } + ), + encoding="utf-8", + ) + before = elsewhere.read_bytes() + fixture.repairs.unlink(missing_ok=True) + fixture.repairs.symlink_to(elsewhere) + fixture.run() + # O_NOFOLLOW refuses the link, so the target is untouched. + self.assertEqual(before, elsewhere.read_bytes()) + finally: + fixture.close() + + def test_one_record_per_incident_however_long_the_silence_lasts(self) -> None: + """A second record for a session already on the list says nothing new. + + The old 6-hour cooldown was a rate limit, not a latch: twenty-one + distinct sessions became fifty records, one of them listed five times, + and every repeat inflated a count the operator had already said they did + not trust (ruling, 2026-08-15). The cooldown is set to zero here, so + only the latch can hold the second and third sweeps back. + """ + fixture = WatchdogFixture( + sessions=[session(recent_output_age_seconds=100_000)] + ) + try: + for _ in range(3): + fixture.run(SESSION_KIT_WATCHDOG_QUIET_REPORT_COOLDOWN="0") + self.assertEqual(1, len(fixture.recorded())) + finally: + fixture.close() + + def test_a_session_that_recovered_and_broke_again_earns_a_new_record( + self, + ) -> None: + """The half that can go wrong quietly: telling incidents apart. + + A record was written at T. Output SINCE T means the session recovered, + so the silence that followed is a DIFFERENT incident and must be + recorded -- otherwise one stale row silences a session forever. Output + still older than T is the same unbroken silence. + + Both shapes need an aged record to be expressible at all: a session + that spoke recently is not quiet enough to be a candidate, so "it + recovered" can only ever be seen from a record older than the silence + threshold. + """ + # Recovered: the record is 3 hours old, the last output 45 minutes ago, + # so the session spoke well after the record was written. + fixture = WatchdogFixture(sessions=[session(recent_output_age_seconds=2_800)]) + try: + self.seed_records(fixture, self.record("wedged", age_days=3 / 24)) + fixture.run(SESSION_KIT_WATCHDOG_QUIET_REPORT_COOLDOWN="0") + self.assertEqual(2, len(fixture.recorded())) + finally: + fixture.close() + + # Same incident: the record is 3 hours old and the session has not + # spoken for 5, so its last output still predates the record. + fixture = WatchdogFixture(sessions=[session(recent_output_age_seconds=18_000)]) + try: + self.seed_records(fixture, self.record("wedged", age_days=3 / 24)) + fixture.run(SESSION_KIT_WATCHDOG_QUIET_REPORT_COOLDOWN="0") + self.assertEqual(1, len(fixture.recorded())) + finally: + fixture.close() + + def test_a_cleared_row_lets_the_next_incident_speak(self) -> None: + """Acknowledging is a person saying they are done with that row.""" + fixture = WatchdogFixture( + sessions=[session(recent_output_age_seconds=100_000)] + ) + try: + fixture.run(SESSION_KIT_WATCHDOG_QUIET_REPORT_COOLDOWN="0") + recorded = fixture.recorded() + self.assertEqual(1, len(recorded)) + recorded[0]["acknowledged"] = True + fixture.repairs.write_text( + json.dumps({"schema_version": 1, "repairs": recorded}), + encoding="utf-8", + ) + fixture.run(SESSION_KIT_WATCHDOG_QUIET_REPORT_COOLDOWN="0") + self.assertEqual(2, len(fixture.recorded())) + finally: + fixture.close() + + def test_hard_evidence_is_never_silenced_by_a_softer_record(self) -> None: + """The blocker: the latch swallowed the strongest thing the watchdog knows. + + Keying only on the session's output timestamp cannot tell "same + silence, same story" from "same silence, and we have since learned + why". So once a session had any unacknowledged `reported` row, the + DAEMON-MARKER verdict for it was suppressed -- and a wedged terminal + never stops being quiet, while retirement only clears records whose + session is GONE, so in practice forever. The operator's list said + "reported quiet, no repair attempted" about a terminal the daemon had + already proven could not be served (found in review, 2026-08-15). + """ + fixture = WatchdogFixture( + sessions=[session(recent_output_age_seconds=100_000)], + journal_lines=FLAG_LINE, + ) + try: + self.seed_records( + fixture, + self.record("wedged", age_days=8 / 24, evidence="silence-only"), + ) + fixture.run( + SESSION_KIT_WATCHDOG_MODE="report", + SESSION_KIT_WATCHDOG_QUIET_REPORT_COOLDOWN="0", + ) + reasons = [item["reason"] for item in fixture.recorded()] + self.assertIn( + "the daemon logged a terminal handoff failure for this session", + reasons, + ) + finally: + fixture.close() + + def test_hard_evidence_is_recorded_even_when_the_output_age_is_unreadable( + self, + ) -> None: + """A marker proves the terminal is unservable on its own. + + With no readable output age the latch has no timestamp to reason + about, and defaulting to "same incident" swallowed a fresh, + daemon-proven second incident. + """ + fixture = WatchdogFixture( + sessions=[session(recent_output_age_seconds=None)], + journal_lines=FLAG_LINE, + ) + try: + self.seed_records( + fixture, + self.record("wedged", age_days=1 / 24, evidence="silence-only"), + ) + fixture.run( + SESSION_KIT_WATCHDOG_MODE="report", + SESSION_KIT_WATCHDOG_QUIET_REPORT_COOLDOWN="0", + ) + self.assertEqual(2, len(fixture.recorded())) + finally: + fixture.close() + + def test_the_same_statement_repeated_is_still_one_record(self) -> None: + """Escalation must not become a licence to duplicate.""" + for label, seeded in ( + ("evidence recorded", "silence-only"), + ("record predates the field", None), + ): + with self.subTest(seeded=label): + fixture = WatchdogFixture( + sessions=[session(recent_output_age_seconds=100_000)] + ) + try: + self.seed_records( + fixture, + self.record("wedged", age_days=8 / 24, evidence=seeded), + ) + fixture.run(SESSION_KIT_WATCHDOG_QUIET_REPORT_COOLDOWN="0") + self.assertEqual(1, len(fixture.recorded())) + finally: + fixture.close() + + def test_a_fleet_stall_flag_is_a_fault_not_a_person_waiting(self) -> None: + """`needs_you` is not purely a turn-state field. + + The snapshot publisher folds fleet stall flags into it, so a session + another observer believes is stuck comes back with needs_you true and + its reason listed. Reading that as "a person is being waited on" + excluded a genuinely wedged session from detection precisely BECAUSE + something else had already noticed it was stuck (found in review, + 2026-08-15). A live fault also beats a provider status word, which can + be frozen at whatever the conversation last said. + """ + for status in ("running", "needs your reply"): + with self.subTest(agent_status=status): + row = session( + agent_status=status, + needs_you=True, + recent_output_age_seconds=100_000, + ) + row["needs_you_reasons"] = ["silent"] + fixture = WatchdogFixture(sessions=[row]) + try: + fixture.run() recorded = fixture.recorded() self.assertEqual(1, len(recorded)) self.assertEqual("reported", recorded[0]["outcome"]) finally: fixture.close() + def test_a_record_whose_replacement_is_live_is_never_retired(self) -> None: + """A repaired record names the session still carrying the conversation. + + Retiring on the old id alone deleted records whose live successor was + right there in the same snapshot (found in review, 2026-08-15). + """ + fixture = WatchdogFixture( + sessions=[session(shpool_id="replacement", recent_output_age_seconds=10)] + ) + try: + self.seed_records( + fixture, + self.record( + "old-id", + age_days=9, + outcome="repaired", + new_shpool_id="replacement", + ), + ) + fixture.run() + self.assertEqual( + ["old-id"], + [item["old_shpool_id"] for item in fixture.recorded()], + ) + finally: + fixture.close() + def test_briefly_quiet_session_is_not_even_reported(self) -> None: fixture = WatchdogFixture(sessions=[session(recent_output_age_seconds=600)]) try: @@ -402,14 +902,124 @@ def test_daemon_marker_repairs_the_session(self) -> None: finally: fixture.close() - def test_missing_serving_thread_repairs_the_session(self) -> None: + def test_watchdog_does_not_restamp_the_session_it_repairs(self) -> None: + fixture = WatchdogFixture( + sessions=[session(recent_output_age_seconds=300)], + journal_lines=FLAG_LINE, + ) + try: + write_executable( + fixture.sp, + "#!/usr/bin/env bash\n" + '[[ -z ${SESSION_KIT_ORIGIN:-} ]] || exit 91\n' + 'printf \'%s\\n\' "$*" >> "$WATCHDOG_SP_LOG"\n' + "echo s20260729-000000-1234\n", + ) + completed = fixture.run(WATCHDOG_SP_LOG=str(fixture.sp_log)) + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertEqual("repaired", fixture.recorded()[0]["outcome"]) + finally: + fixture.close() + + def test_missing_serving_thread_warns_once_and_repairs_nothing(self) -> None: fixture = WatchdogFixture( sessions=[session(recent_output_age_seconds=300)], serving_threads=0, ) try: fixture.run() - self.assertEqual(["wedged"], fixture.repairs_requested()) + fixture.run() + self.assertEqual([], fixture.repairs_requested()) + announced = fixture.notify_log.read_text(encoding="utf-8") + self.assertEqual(1, announced.count("Session manager is serving fewer sessions than it lists")) + finally: + fixture.close() + + def test_null_output_age_with_marker_reports_but_never_repairs(self) -> None: + fixture = WatchdogFixture( + sessions=[session(recent_output_age_seconds=None)], + journal_lines=FLAG_LINE, + ) + try: + fixture.run() + self.assertEqual([], fixture.repairs_requested()) + self.assertEqual("reported", fixture.recorded()[0]["outcome"]) + finally: + fixture.close() + + def test_fresh_marker_is_not_reported_or_repaired(self) -> None: + fixture = WatchdogFixture( + sessions=[session(recent_output_age_seconds=300)], + journal_lines=failure_event(age_seconds=30), + ) + try: + fixture.run() + self.assertEqual([], fixture.repairs_requested()) + self.assertEqual([], fixture.recorded()) + finally: + fixture.close() + + def test_later_success_invalidates_failure(self) -> None: + fixture = WatchdogFixture( + sessions=[session(recent_output_age_seconds=300)], + journal_lines="\n".join( + [failure_event(age_seconds=300), success_event(age_seconds=60)] + ), + ) + try: + fixture.run() + self.assertEqual([], fixture.repairs_requested()) + self.assertEqual([], fixture.recorded()) + finally: + fixture.close() + + def test_marker_from_old_daemon_generation_is_ignored(self) -> None: + fixture = WatchdogFixture( + sessions=[session(recent_output_age_seconds=300)], + journal_lines=failure_event(pid=os.getpid() + 100_000), + ) + try: + fixture.run() + self.assertEqual([], fixture.repairs_requested()) + self.assertEqual([], fixture.recorded()) + finally: + fixture.close() + + def test_marker_from_before_the_daemon_started_is_ignored(self) -> None: + """A failure older than the daemon cannot be describing this daemon. + + The watchdog compares journal timestamps against the daemon's own + boot-relative start, and both sides of that comparison come from the + fixture. Nothing else exercises it, and it is the rule a host-derived + fixture clock used to trip by accident on a freshly booted runner. + """ + fixture = WatchdogFixture( + sessions=[session(recent_output_age_seconds=300)], + journal_lines=failure_event(age_seconds=300), + daemon_start_seconds=FIXTURE_BOOT_SECONDS - 100, + ) + try: + fixture.run() + + self.assertEqual([], fixture.repairs_requested()) + self.assertEqual([], fixture.recorded()) + finally: + fixture.close() + + def test_global_thread_gap_is_not_attributed_to_every_session(self) -> None: + fixture = WatchdogFixture( + sessions=[ + session("one", recent_output_age_seconds=300), + session("two", recent_output_age_seconds=300), + ], + serving_threads=1, + ) + try: + fixture.run() + self.assertEqual([], fixture.repairs_requested()) + self.assertEqual([], fixture.recorded()) + announced = fixture.notify_log.read_text(encoding="utf-8") + self.assertEqual(1, announced.count("Session manager is serving fewer sessions than it lists")) finally: fixture.close() @@ -439,7 +1049,7 @@ def test_repair_is_recorded_and_announced(self) -> None: self.assertFalse(entry["acknowledged"]) announced = fixture.notify_log.read_text(encoding="utf-8") self.assertIn("--severity=warning", announced) - self.assertIn("Session recovered", announced) + self.assertIn("Session restored", announced) finally: fixture.close() @@ -453,7 +1063,7 @@ def test_failed_repair_is_announced_without_a_critical(self) -> None: fixture.run() self.assertEqual("failed", fixture.recorded()[0]["outcome"]) announced = fixture.notify_log.read_text(encoding="utf-8") - self.assertIn("could not be recovered", announced) + self.assertIn("could not be restored", announced) # An automatic repair must never wake a phone. self.assertNotIn("critical", announced) finally: @@ -487,6 +1097,98 @@ def test_an_unresponsive_manager_is_reported(self) -> None: finally: fixture.close() + def test_a_dropped_alert_is_not_recorded_as_delivered(self) -> None: + """The state file used to say the operator was told. + + On 2026-08-06 the manager was running an unexpected build, the alert + bounced off a notifier that was down, and `report_once` stamped the + condition as reported anyway -- so the fault went quiet for six hours + without a human ever hearing it. A drop is not a delivery. + """ + fixture = WatchdogFixture(sessions=[session()]) + try: + write_executable( + fixture.notify, + """#!/usr/bin/env bash +exit 1 +""", + ) + write_executable( + fixture.shpool, + """#!/usr/bin/env bash +if [ "$1" = list ]; then sleep 300; fi +exit 0 +""", + ) + fixture.run( + SESSION_KIT_WATCHDOG_MANAGER_TIMEOUT="2", + SESSION_KIT_WATCHDOG_QUIET_RETRY="0", + ) + self.assertIn("not delivered", fixture.log_text()) + self.assertFalse(fixture.notify_log.exists()) + + # The condition is not held: the next sweep speaks again, and this + # time the notifier is up. + write_executable( + fixture.notify, + f"""#!/usr/bin/env bash +printf '%s\\n' "$*" >> {str(fixture.notify_log)!r} +""", + ) + fixture.run(SESSION_KIT_WATCHDOG_MANAGER_TIMEOUT="2") + self.assertIn( + "not responding", + fixture.notify_log.read_text(encoding="utf-8"), + ) + finally: + fixture.close() + + def test_a_dropped_alert_is_held_only_for_the_retry_window(self) -> None: + fixture = WatchdogFixture(sessions=[session()]) + try: + write_executable(fixture.notify, "#!/usr/bin/env bash\nexit 1\n") + write_executable( + fixture.shpool, + """#!/usr/bin/env bash +if [ "$1" = list ]; then sleep 300; fi +exit 0 +""", + ) + fixture.run(SESSION_KIT_WATCHDOG_MANAGER_TIMEOUT="2") + stamps = json.loads(fixture.reported.read_text(encoding="utf-8")) + held = stamps["reported"]["health:manager-stuck"] + age = time.time() - held + # Stamped 15 minutes short of the six-hour cooldown: a notifier + # nobody configured cannot fill the log, and a notifier that was + # down for a minute costs one retry. + self.assertLess(age, 21600) + self.assertGreater(age, 21600 - 1000) + finally: + fixture.close() + + def test_a_delivered_alert_holds_the_full_cooldown(self) -> None: + fixture = WatchdogFixture(sessions=[session()]) + try: + write_executable( + fixture.shpool, + """#!/usr/bin/env bash +if [ "$1" = list ]; then sleep 300; fi +exit 0 +""", + ) + fixture.run(SESSION_KIT_WATCHDOG_MANAGER_TIMEOUT="2") + stamps = json.loads(fixture.reported.read_text(encoding="utf-8")) + held = stamps["reported"]["health:manager-stuck"] + self.assertLess(time.time() - held, 5) + # One message per condition per cooldown: the second sweep is quiet. + before = fixture.notify_log.read_text(encoding="utf-8") + fixture.run(SESSION_KIT_WATCHDOG_MANAGER_TIMEOUT="2") + self.assertEqual( + before, fixture.notify_log.read_text(encoding="utf-8") + ) + finally: + fixture.close() + def test_a_replaced_manager_binary_is_reported(self) -> None: """A routine reinstall must not silently undo the patch.""" fixture = WatchdogFixture(sessions=[session()]) @@ -507,6 +1209,81 @@ def test_a_replaced_manager_binary_is_reported(self) -> None: finally: fixture.close() + def test_a_restored_session_that_prints_a_closing_note_is_a_repair( + self, + ) -> None: + """The status decides, not the shape of the last line printed.""" + fixture = WatchdogFixture( + sessions=[session(recent_output_age_seconds=300)], + journal_lines=FLAG_LINE, + ) + try: + write_executable( + fixture.sp, + """#!/usr/bin/env bash +echo s20260811-101500-7 +echo "session-kit: the terminal name was kept" >&2 +exit 0 +""", + ) + fixture.run() + entry = fixture.recorded()[0] + self.assertEqual("repaired", entry["outcome"]) + self.assertEqual("s20260811-101500-7", entry["new_shpool_id"]) + self.assertIn("Session restored", fixture.notify_log.read_text()) + finally: + fixture.close() + + def test_a_failed_repair_is_never_read_as_a_new_session(self) -> None: + """A diagnostic that merely looks like an ID is still a failure.""" + fixture = WatchdogFixture( + sessions=[session(recent_output_age_seconds=300)], + journal_lines=FLAG_LINE, + ) + try: + write_executable( + fixture.sp, + """#!/usr/bin/env bash +echo s20260811-101500-7 +echo "session-kit: shpool refused to close the exact disconnected session" >&2 +exit 1 +""", + ) + fixture.run() + entry = fixture.recorded()[0] + self.assertEqual("failed", entry["outcome"]) + self.assertEqual("", entry["new_shpool_id"]) + self.assertIn("could not be restored", fixture.notify_log.read_text()) + finally: + fixture.close() + + def test_a_post_kill_failure_announces_the_route_that_works(self) -> None: + """The session is gone, so "open the picker" is not the answer.""" + fixture = WatchdogFixture( + sessions=[session(recent_output_age_seconds=300)], + journal_lines=FLAG_LINE, + ) + try: + write_executable( + fixture.sp, + """#!/usr/bin/env bash +echo "The original terminal was already closed but the conversation is intact on disk." >&2 +echo "Retry with: sp restore-exact claude 11111111-2222-4333-8444-555555555555 /srv/work" >&2 +exit 1 +""", + ) + fixture.run() + self.assertEqual("failed", fixture.recorded()[0]["outcome"]) + announced = fixture.notify_log.read_text(encoding="utf-8") + self.assertIn( + "sp restore-exact claude " + "11111111-2222-4333-8444-555555555555 /srv/work", + announced, + ) + self.assertNotIn("Open the picker to restore it.", announced) + finally: + fixture.close() + def test_a_blind_inventory_defers_instead_of_reporting_failure(self) -> None: """A loaded daemon is a blind spot, not a failed repair.""" fixture = WatchdogFixture( @@ -523,7 +1300,8 @@ def test_a_blind_inventory_defers_instead_of_reporting_failure(self) -> None: ) fixture.run() self.assertEqual([], fixture.recorded()) - self.assertFalse(fixture.notify_log.exists()) + announced = fixture.notify_log.read_text(encoding="utf-8") + self.assertNotIn("Session could not be restored", announced) self.assertIn("repair deferred", fixture.log_text()) finally: fixture.close() diff --git a/tests/test_worker_model.py b/tests/test_worker_model.py new file mode 100644 index 0000000..c6acb03 --- /dev/null +++ b/tests/test_worker_model.py @@ -0,0 +1,71 @@ +"""The launch gate's own rules, pinned. + +``validate_requested_model`` sits on the LIVE launch path, the login shell +(bashrc/shpool.bashrc) and ``sp new`` both call the ``validate-worker-model`` +verb before starting a provider. It moved into ``sessionkit_inventory`` in the +one-door rebuild (2026-08-12); these tests pin its rules so a future edit +cannot silently loosen what launches. +""" +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO / "lib")) + +from sessionkit_inventory.worker_model import ( # noqa: E402 + IntakeError, + validate_requested_model, +) + + +class WorkerModelGateTests(unittest.TestCase): + def test_claude_models_must_carry_the_claude_prefix(self) -> None: + self.assertEqual( + "claude-fable-5", validate_requested_model("claude", "claude-fable-5") + ) + self.assertEqual( + "claude-opus-5", validate_requested_model("claude", " claude-opus-5 ") + ) + with self.assertRaises(IntakeError): + validate_requested_model("claude", "gpt-5") + + def test_codex_models_must_carry_a_codex_family_prefix(self) -> None: + for accepted in ("gpt-5.6", "o3-pro", "o4-mini", "codex-mini"): + self.assertEqual(accepted, validate_requested_model("codex", accepted)) + with self.assertRaises(IntakeError): + validate_requested_model("codex", "claude-opus-5") + + def test_only_the_two_providers_pass_the_gate(self) -> None: + for provider in ("gemini", "", None, "CLAUDE", 7): + with self.subTest(provider=provider): + with self.assertRaises(IntakeError): + validate_requested_model(provider, "claude-opus-5") + + def test_identifier_bounds_and_character_set_hold(self) -> None: + # Inherited contract, pinned as-is: an overlong identifier is TRUNCATED + # to 128 characters before validation, not refused (the ported code + # has always done this; changing a launch-path gate needs its own + # decision, not a drive-by). + self.assertEqual( + 128, len(validate_requested_model("claude", "claude-" + "x" * 130)) + ) + with self.assertRaises(IntakeError): + validate_requested_model("claude", "claude one") + with self.assertRaises(IntakeError): + validate_requested_model("claude", "") + with self.assertRaises(IntakeError): + validate_requested_model("claude", None) + with self.assertRaises(IntakeError): + validate_requested_model("claude", ["claude-opus-5"]) + + def test_error_type_is_a_value_error_for_the_facade_handler(self) -> None: + # session_inventory.main() catches ValueError; the gate's refusals must + # stay inside that net so the verb exits with a message, not a traceback. + self.assertTrue(issubclass(IntakeError, ValueError)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_worktree_isolation.py b/tests/test_worktree_isolation.py new file mode 100644 index 0000000..f9a00ba --- /dev/null +++ b/tests/test_worktree_isolation.py @@ -0,0 +1,1310 @@ +"""Delegated work gets its own copy of the code, and gives it back on close. + +Isolation used to be a flag somebody had to remember (`sp new --worktree`) and +cleanup a verb somebody had to type (`sp teardown`). Two workers in one +checkout edit the same files, and a worker that is never torn down leaves its +directory on the machine for good. + +So: a machine session in a git repository is given a worktree without asking, +and the close gives it back. Everything below is about what that must never +do, remove unsaved work, remove unmerged commits, remove a directory somebody +is still standing in, remove the operator's own checkout, or remove any of the +copies that were already on this machine before the kit started managing them. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +from lib.sessionkit_inventory import worktrees +from tests.support import REPO, run +from tests.test_commands import CommandFixture, inventory_document, session_row + +SP = REPO / "bin" / "sp" + +# Every directory this suite makes -- fixture repositories, worktree roots, the +# worktrees themselves -- lives under here, and here is inside the checkout this +# agent owns. The suite creates and DELETES git worktrees, so "where can a path +# in this file point" is not a tidiness question: a sandbox that escapes is a +# sandbox that removes directories somewhere real. `sandbox()` is the only way +# this file makes a temporary directory, and `assert_contained` re-proves the +# containment at the moment each fixture is built. +SANDBOX_ROOT = Path( + os.environ.get("SESSION_KIT_TEST_EXEC_ROOT", os.fspath(REPO)) +).resolve() + + +def sandbox(prefix: str) -> tempfile.TemporaryDirectory: + return tempfile.TemporaryDirectory(prefix=prefix, dir=SANDBOX_ROOT) + + +def assert_contained(case: unittest.TestCase, *paths: Path | str) -> None: + for path in paths: + resolved = Path(path).resolve() + case.assertTrue( + resolved == SANDBOX_ROOT or SANDBOX_ROOT in resolved.parents, + f"{resolved} is outside the suite's sandbox {SANDBOX_ROOT}", + ) + +GIT_ENVIRONMENT = { + "GIT_AUTHOR_NAME": "Fixture", + "GIT_AUTHOR_EMAIL": "fixture@example.invalid", + "GIT_COMMITTER_NAME": "Fixture", + "GIT_COMMITTER_EMAIL": "fixture@example.invalid", + # Pinned so a replayed commit keeps its identity: the rebase case below + # needs the commit it stops on to be the same object it started as, or the + # test cannot tell "kept for the rebase" from "kept for being unmerged". + "GIT_AUTHOR_DATE": "2026-01-01T00:00:00+00:00", + "GIT_COMMITTER_DATE": "2026-01-01T00:00:00+00:00", + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_SYSTEM": "/dev/null", +} + + +def git(repo: Path, *arguments: str) -> str: + completed = subprocess.run( + ["git", "-C", os.fspath(repo), *arguments], + capture_output=True, + text=True, + check=True, + env={**os.environ, **GIT_ENVIRONMENT}, + ) + return completed.stdout + + +class WorktreeReleaseTests(unittest.TestCase): + """The core: what release removes, and everything it refuses to.""" + + def setUp(self) -> None: + self.temporary = sandbox("session-kit-worktrees.") + self.base = Path(self.temporary.name) + self.state = self.base / "state" + self.state.mkdir() + self.root = self.base / "worktree-root" + self.environ = {"SESSION_KIT_WORKTREE_ROOT": os.fspath(self.root)} + assert_contained(self, self.base, self.root) + self.repo = self.base / "repo" + self.repo.mkdir() + git(self.repo, "init", "--initial-branch=main", "--quiet") + (self.repo / "README").write_text("one\n", encoding="utf-8") + git(self.repo, "add", "README") + git(self.repo, "commit", "--quiet", "-m", "first") + + def tearDown(self) -> None: + self.temporary.cleanup() + + def materialize(self, branch: str, *, auto: bool = True) -> dict: + return worktrees.materialize( + repo=self.repo, + branch=branch, + state_dir=self.state, + environ=self.environ, + auto=auto, + origin="machine" if auto else "", + ) + + def release(self, **extra: object) -> dict: + return worktrees.release( + self.state, environ=self.environ, merged_into_ref="main", **extra + ) + + def fake_proc(self, pid: int, cwd: Path) -> Path: + """A /proc with one process standing in ``cwd``.""" + proc = self.base / f"proc-{pid}" + (proc / str(pid)).mkdir(parents=True) + (proc / str(pid) / "cwd").symlink_to(cwd) + return proc + + # ---- the ordinary case ------------------------------------------------ + + def test_a_clean_merged_copy_is_given_back_on_close(self) -> None: + record = self.materialize("worker/one") + worktrees.bind(state_dir=self.state, path=record["path"], shpool_id="7", + environ=self.environ) + self.assertTrue(Path(record["path"]).is_dir()) + verdict = self.release(shpool_id="7") + self.assertEqual("removed", verdict["action"], verdict) + self.assertFalse(Path(record["path"]).exists()) + self.assertIsNone( + worktrees.lookup(self.state, path=record["path"], environ=self.environ) + ) + + def test_the_branch_and_its_commits_outlive_the_directory(self) -> None: + record = self.materialize("worker/keep-commits") + worktrees.bind(state_dir=self.state, path=record["path"], shpool_id="8", + environ=self.environ) + self.release(shpool_id="8") + self.assertIn("worker/keep-commits", git(self.repo, "branch", "--list", + "worker/keep-commits")) + + # ---- the four refusals ------------------------------------------------ + + def test_uncommitted_work_keeps_the_copy_and_says_so(self) -> None: + record = self.materialize("worker/dirty") + worktrees.bind(state_dir=self.state, path=record["path"], shpool_id="9", + environ=self.environ) + (Path(record["path"]) / "unsaved.txt").write_text("half a thought\n", + encoding="utf-8") + verdict = self.release(shpool_id="9") + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn("uncommitted or untracked", verdict["reason"]) + self.assertTrue((Path(record["path"]) / "unsaved.txt").is_file()) + + def test_unmerged_commits_keep_the_copy_and_say_so(self) -> None: + record = self.materialize("worker/unmerged") + path = Path(record["path"]) + worktrees.bind(state_dir=self.state, path=path, shpool_id="10", + environ=self.environ) + (path / "work.txt").write_text("finished\n", encoding="utf-8") + git(path, "add", "work.txt") + git(path, "commit", "--quiet", "-m", "the work") + verdict = self.release(shpool_id="10") + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn("is not merged into main", verdict["reason"]) + self.assertTrue(path.is_dir()) + + def test_a_live_session_in_the_copy_keeps_it_whatever_else_is_true(self) -> None: + record = self.materialize("worker/busy") + path = Path(record["path"]) + worktrees.bind(state_dir=self.state, path=path, shpool_id="11", + environ=self.environ) + verdict = worktrees.release( + self.state, + shpool_id="11", + environ=self.environ, + merged_into_ref="main", + proc=self.fake_proc(4242, path), + ) + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn("still working in it (pid 4242)", verdict["reason"]) + self.assertTrue(path.is_dir()) + + def test_a_copy_nobody_asked_the_kit_for_is_never_removed(self) -> None: + """The dozen worktrees already on a machine are not ours to collect.""" + record = self.materialize("worker/by-name", auto=False) + worktrees.bind(state_dir=self.state, path=record["path"], shpool_id="12", + environ=self.environ) + verdict = self.release(shpool_id="12") + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn("asked for by name", verdict["reason"]) + self.assertTrue(Path(record["path"]).is_dir()) + + def test_a_session_with_no_worktree_is_a_quiet_no_op(self) -> None: + verdict = self.release(shpool_id="404") + self.assertEqual("none", verdict["action"], verdict) + self.assertEqual("", worktrees.render_verdict(verdict)) + + # ---- the operator's own checkout -------------------------------------- + + def test_the_operators_own_checkout_is_refused_by_teardown(self) -> None: + outside = self.base / "operators-own" + outside.mkdir() + (outside / "keep-me").write_text("mine\n", encoding="utf-8") + record = self.materialize("worker/outside") + registry = self.root / "registry" / f"{record['token']}.json" + document = json.loads(registry.read_text(encoding="utf-8")) + document["path"] = os.fspath(outside) + registry.write_text(json.dumps(document), encoding="utf-8") + with self.assertRaises(worktrees.WorktreeError) as refusal: + worktrees.teardown( + state_dir=self.state, + path=outside, + environ=self.environ, + merged_into_ref="main", + force=True, + ) + self.assertIn("outside the kit's worktree root", str(refusal.exception)) + self.assertTrue((outside / "keep-me").is_file()) + self.assertEqual( + "kept", self.release(path=outside)["action"] + ) + + def test_force_never_pulls_the_floor_out_from_a_live_session(self) -> None: + record = self.materialize("worker/occupied") + path = Path(record["path"]) + with self.assertRaises(worktrees.WorktreeError) as refusal: + worktrees.teardown( + state_dir=self.state, + path=path, + environ=self.environ, + merged_into_ref="main", + force=True, + proc=self.fake_proc(5150, path), + ) + self.assertIn("still working in it", str(refusal.exception)) + self.assertTrue(path.is_dir()) + + # ---- the sweep -------------------------------------------------------- + + def test_the_sweep_releases_only_copies_whose_session_is_gone(self) -> None: + live = self.materialize("worker/live") + gone = self.materialize("worker/gone") + by_name = self.materialize("worker/named", auto=False) + for record, session in ((live, "20"), (gone, "21"), (by_name, "22")): + worktrees.bind(state_dir=self.state, path=record["path"], + shpool_id=session, environ=self.environ) + verdicts = worktrees.release_idle( + self.state, ["20"], merged_into_ref="main", environ=self.environ + ) + self.assertEqual( + [("removed", gone["path"])], + [(verdict["action"], verdict["path"]) for verdict in verdicts], + ) + self.assertTrue(Path(live["path"]).is_dir()) + self.assertTrue(Path(by_name["path"]).is_dir()) + + def test_a_copy_with_no_recorded_session_is_never_swept_on_age_alone(self) -> None: + """Age is not ownership. + + A bind that failed leaves a *running* session in a copy that looks + exactly like an abandoned one, and the launch path treats a failed bind + as non-fatal on purpose. So an unowned copy is reported, never removed, + however old it is. + """ + fresh = self.materialize("worker/starting") + for _ in range(2): + verdicts = worktrees.release_idle( + self.state, [], merged_into_ref="main", environ=self.environ + ) + self.assertEqual( + [("kept", fresh["path"])], + [(verdict["action"], verdict["path"]) for verdict in verdicts], + ) + self.assertIn("no session is recorded", verdicts[0]["reason"]) + self.assertTrue(Path(fresh["path"]).is_dir()) + + def test_a_sweep_with_no_live_list_refuses_rather_than_assuming(self) -> None: + """"I was not told what is alive" must never read as "nothing is".""" + record = self.materialize("worker/live-elsewhere") + worktrees.bind(state_dir=self.state, path=record["path"], shpool_id="30", + environ=self.environ) + with self.assertRaises(worktrees.WorktreeError) as refusal: + worktrees.release_idle( + self.state, None, merged_into_ref="main", environ=self.environ + ) + self.assertIn("refusing to treat", str(refusal.exception)) + self.assertTrue(Path(record["path"]).is_dir()) + + def test_every_copy_bound_to_one_session_is_answered_for(self) -> None: + first = self.materialize("worker/twin-a") + second = self.materialize("worker/twin-b") + for record in (first, second): + worktrees.bind(state_dir=self.state, path=record["path"], + shpool_id="31", environ=self.environ) + verdict = self.release(shpool_id="31") + self.assertEqual("many", verdict["action"], verdict) + self.assertEqual( + {"removed"}, {item["action"] for item in verdict["verdicts"]} + ) + self.assertFalse(Path(first["path"]).exists()) + self.assertFalse(Path(second["path"]).exists()) + + +class WorkIsMoreThanGitStatusTests(unittest.TestCase): + """`git status` is not the question "is there work here". + + Every case below was clean to `git status --porcelain` and every one of + them is somebody's work. The first two destroy it permanently: a commit + that exists on no branch dies with the worktree's reflog, and a file the + repository ignores, a report, a log, a screenshot, a `.env`, is exactly + what an agent produces all day. + """ + + def setUp(self) -> None: + self.temporary = sandbox("session-kit-work.") + self.base = Path(self.temporary.name) + self.state = self.base / "state" + self.state.mkdir() + self.root = self.base / "worktree-root" + self.environ = {"SESSION_KIT_WORKTREE_ROOT": os.fspath(self.root)} + assert_contained(self, self.base, self.root) + self.repo = self.base / "repo" + self.repo.mkdir() + git(self.repo, "init", "--initial-branch=main", "--quiet") + (self.repo / "README").write_text("one\n", encoding="utf-8") + git(self.repo, "add", "README") + git(self.repo, "commit", "--quiet", "-m", "first") + + def tearDown(self) -> None: + self.temporary.cleanup() + + def copy_for(self, branch: str, session: str) -> Path: + record = worktrees.materialize( + repo=self.repo, branch=branch, state_dir=self.state, + environ=self.environ, auto=True, origin="machine", + ) + worktrees.bind(state_dir=self.state, path=record["path"], + shpool_id=session, environ=self.environ) + return Path(record["path"]) + + def release(self, session: str) -> dict: + return worktrees.release( + self.state, shpool_id=session, environ=self.environ, + merged_into_ref="main", + ) + + def assert_clean_to_git(self, path: Path) -> None: + self.assertEqual("", git(path, "status", "--porcelain").strip()) + + def test_a_commit_on_a_detached_head_is_never_removed_with_its_copy(self) -> None: + path = self.copy_for("worker/detached", "40") + git(path, "checkout", "--quiet", "--detach") + (path / "finished.txt").write_text("the work\n", encoding="utf-8") + git(path, "add", "finished.txt") + git(path, "commit", "--quiet", "-m", "work nobody else has") + commit = git(path, "rev-parse", "HEAD").strip() + self.assert_clean_to_git(path) + self.assertEqual("", git(self.repo, "branch", "--contains", commit).strip()) + + verdict = self.release("40") + + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn(commit[:12], verdict["reason"]) + self.assertIn("not merged into main", verdict["reason"]) + self.assertTrue(path.is_dir()) + self.assertEqual(commit, git(path, "rev-parse", "HEAD").strip()) + + def test_files_the_repository_ignores_are_still_somebody_s_work(self) -> None: + path = self.copy_for("worker/ignored", "41") + (path / ".gitignore").write_text( + "*.log\nproof/logs/\n.env\n/*-audit-report.md\n", encoding="utf-8" + ) + git(path, "add", ".gitignore") + git(path, "commit", "--quiet", "-m", "ignore rules") + git(self.repo, "merge", "--quiet", "--ff-only", "worker/ignored") + (path / "traffic-audit-report.md").write_text("six hours\n", encoding="utf-8") + (path / ".env").write_text("SECRET=1\n", encoding="utf-8") + (path / "proof" / "logs").mkdir(parents=True) + (path / "proof" / "logs" / "crawl.log").write_text("lines\n", encoding="utf-8") + self.assert_clean_to_git(path) + + verdict = self.release("41") + + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn("the repository ignores but somebody made", verdict["reason"]) + self.assertTrue((path / "traffic-audit-report.md").is_file()) + self.assertTrue((path / ".env").is_file()) + self.assertTrue((path / "proof" / "logs" / "crawl.log").is_file()) + + def test_a_stashed_change_keeps_its_copy(self) -> None: + path = self.copy_for("worker/stashed", "42") + (path / "README").write_text("half a thought\n", encoding="utf-8") + git(path, "stash", "push", "--quiet", "-m", "wip") + self.assert_clean_to_git(path) + + verdict = self.release("42") + + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn("stashed change", verdict["reason"]) + self.assertTrue(path.is_dir()) + + def test_an_interrupted_merge_keeps_its_copy(self) -> None: + path = self.copy_for("worker/merging", "43") + git(self.repo, "branch", "side", "main") + side = self.base / "side" + git(self.repo, "worktree", "add", "--quiet", os.fspath(side), "side") + (side / "side.txt").write_text("theirs\n", encoding="utf-8") + git(side, "add", "side.txt") + git(side, "commit", "--quiet", "-m", "side work") + git(path, "merge", "--no-commit", "--no-ff", "-q", "side") + self.assertTrue((Path(git(path, "rev-parse", "--absolute-git-dir").strip()) + / "MERGE_HEAD").exists()) + + verdict = self.release("43") + + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn("a merge is in progress", verdict["reason"]) + self.assertTrue(path.is_dir()) + + def test_an_interrupted_rebase_keeps_its_copy(self) -> None: + path = self.copy_for("worker/rebasing", "44") + (path / "one.txt").write_text("one\n", encoding="utf-8") + git(path, "add", "one.txt") + git(path, "commit", "--quiet", "-m", "one") + git(self.repo, "merge", "--quiet", "--ff-only", "worker/rebasing") + before = git(path, "rev-parse", "HEAD").strip() + result = subprocess.run( + ["git", "-C", os.fspath(path), "rebase", "--exec", "false", "HEAD~1"], + capture_output=True, text=True, check=False, + env={**os.environ, **GIT_ENVIRONMENT}, + ) + self.assertNotEqual(0, result.returncode, "the rebase must stop mid-way") + self.assert_clean_to_git(path) + # The replayed commit is the same object, so "unmerged" cannot be the + # reason this copy survives, only the interrupted rebase can be. + self.assertEqual(before, git(path, "rev-parse", "HEAD").strip()) + + verdict = self.release("44") + + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn("rebase is in progress", verdict["reason"]) + self.assertNotIn("not merged", verdict["reason"]) + self.assertTrue(path.is_dir()) + + def test_a_checkout_that_is_not_the_recorded_one_is_never_removed(self) -> None: + """A record outlives its directory; what is there now may be a person's.""" + path = self.copy_for("worker/original", "45") + git(self.repo, "worktree", "remove", "--force", os.fspath(path)) + git(self.repo, "worktree", "add", "--quiet", "-b", "operator/manual", + os.fspath(path), "main") + (path / "mine.txt").write_text("the operator's\n", encoding="utf-8") + git(path, "add", "mine.txt") + git(path, "commit", "--quiet", "-m", "by hand") + git(self.repo, "merge", "--quiet", "--ff-only", "operator/manual") + + verdict = self.release("45") + + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn("not the recorded", verdict["reason"]) + self.assertTrue((path / "mine.txt").is_file()) + + def test_force_does_not_override_a_commit_that_exists_nowhere_else(self) -> None: + path = self.copy_for("worker/forced", "46") + git(path, "checkout", "--quiet", "--detach") + (path / "only.txt").write_text("only copy\n", encoding="utf-8") + git(path, "add", "only.txt") + git(path, "commit", "--quiet", "-m", "unreferenced") + with self.assertRaises(worktrees.WorktreeError) as refusal: + worktrees.teardown( + state_dir=self.state, path=path, environ=self.environ, + merged_into_ref="main", force=True, + ) + self.assertIn("whatever --force says", str(refusal.exception)) + self.assertTrue(path.is_dir()) + + def test_every_directory_this_suite_can_name_is_inside_its_sandbox(self) -> None: + """The suite deletes worktrees. Prove it can only delete its own. + + Read back, from the registry, every path this fixture has caused to + exist -- the repository, the worktree root, the copies themselves -- + and require each one to be under the agent's own checkout. A fixture + that escapes while these paths are being deleted is the work-loss bug + happening tonight instead of after an install. + """ + first = self.copy_for("worker/one", "90") + second = self.copy_for("worker/two", "91") + recorded = worktrees.records(self.state, self.environ) + self.assertEqual(2, len(recorded), recorded) + for record in recorded: + assert_contained(self, record["path"], record["repo"]) + assert_contained(self, first, second, self.repo, self.state, + worktrees.worktree_root(self.state, self.environ)) + + def test_a_copy_with_nothing_in_it_still_goes_back(self) -> None: + """The guard has to stay narrow enough to actually collect a copy.""" + path = self.copy_for("worker/empty", "47") + verdict = self.release("47") + self.assertEqual("removed", verdict["action"], verdict) + self.assertFalse(path.exists()) + + # ---- stashes: three shapes, and git names two of them nothing --------- + + def test_a_stash_from_a_mixed_case_branch_keeps_its_copy(self) -> None: + """Git writes the branch as spelled; a search must fold both sides. + + `On Feature/Work: …` against a search for `on Feature/Work:` in a + line that has already been lower-cased matches nothing, so the copy + was removed and the stash left with no directory that explains it. + """ + path = self.copy_for("Feature/Work", "48") + (path / "README").write_text("changed\n", encoding="utf-8") + git(path, "stash", "push", "--quiet", "-m", "case-stash") + self.assert_clean_to_git(path) + self.assertIn("On Feature/Work:", git(self.repo, "stash", "list")) + + verdict = self.release("48") + + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn("stashed change", verdict["reason"]) + self.assertTrue(path.is_dir()) + + def test_a_stash_pushed_from_a_detached_head_keeps_its_copy(self) -> None: + """Git records those as `On (no branch)`, which names nothing at all. + + The only thing that can attribute one is the commit it was made from, + which is this copy's HEAD. + """ + path = self.copy_for("worker/stash-detached", "49") + git(path, "checkout", "--quiet", "--detach") + head = git(path, "rev-parse", "HEAD").strip() + (path / "README").write_text("mid-thought\n", encoding="utf-8") + git(path, "stash", "push", "--quiet", "-m", "detached-stash") + self.assert_clean_to_git(path) + listing = git(self.repo, "stash", "list") + self.assertIn("On (no branch):", listing) + self.assertEqual(head, git(path, "rev-parse", "HEAD").strip()) + + verdict = self.release("49") + + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn("stashed change", verdict["reason"]) + self.assertTrue(path.is_dir()) + + # ---- --force covers a decision, never an unrepeatable loss ----------- + + def test_force_does_not_remove_a_detached_commit_the_check_could_not_place( + self, + ) -> None: + """Detached and *unproved* is the rule, not detached and known-unmerged. + + A reference that does not exist makes `merge-base` exit 128, so the + comparison produces an error rather than an answer. That error used to + read as an ordinary refusal, which `--force` covers, and the forced + removal takes the copy's reflog, the only thing that reaches the + commit. + """ + path = self.copy_for("worker/unplaceable", "50") + git(path, "checkout", "--quiet", "--detach") + (path / "only.txt").write_text("only copy\n", encoding="utf-8") + git(path, "add", "only.txt") + git(path, "commit", "--quiet", "-m", "unreferenced") + commit = git(path, "rev-parse", "HEAD").strip() + + with self.assertRaises(worktrees.WorktreeError) as refusal: + worktrees.teardown( + state_dir=self.state, path=path, environ=self.environ, + merged_into_ref="refs/heads/no-such-reference", force=True, + ) + + self.assertIn("whatever --force says", str(refusal.exception)) + self.assertIn("not on a branch", str(refusal.exception)) + self.assertTrue(path.is_dir()) + self.assertEqual( + commit, git(path, "rev-parse", "HEAD").strip(), "the commit is still there" + ) + + def test_force_does_not_destroy_a_half_finished_rebase(self) -> None: + """The replay state lives only in the copy, and no branch holds it.""" + path = self.copy_for("worker/rebase-forced", "51") + (path / "step.txt").write_text("one\n", encoding="utf-8") + git(path, "add", "step.txt") + git(path, "commit", "--quiet", "-m", "a step to replay") + git(self.repo, "merge", "--quiet", "--ff-only", "worker/rebase-forced") + before = git(path, "rev-parse", "HEAD").strip() + completed = subprocess.run( + ["git", "-C", os.fspath(path), "rebase", "--exec", "false", "HEAD~1"], + capture_output=True, text=True, check=False, + env={**os.environ, **GIT_ENVIRONMENT}, + ) + self.assertNotEqual(0, completed.returncode, "the rebase must stop mid-way") + self.assert_clean_to_git(path) + # The replayed commit is the same object, so the copy is still merged: + # the interrupted rebase is the only thing that can be keeping it. + self.assertEqual(before, git(path, "rev-parse", "HEAD").strip()) + + with self.assertRaises(worktrees.WorktreeError) as refusal: + worktrees.teardown( + state_dir=self.state, path=path, environ=self.environ, + merged_into_ref="main", force=True, + ) + + self.assertIn("whatever --force says", str(refusal.exception)) + self.assertIn("rebase is in progress", str(refusal.exception)) + self.assertIn("rebase --abort", str(refusal.exception)) + self.assertTrue(path.is_dir()) + + +class AQuestionGitCouldNotAnswerKeepsTheCopyTests(unittest.TestCase): + """A check that could not run has not passed. + + Every guard in the module asks git to *prove* the work is safe. Each of + those questions was asked without `check`, and each empty answer was then + read as "there is nothing there", so one failed `git rev-parse` switched + off the identity check, the half-finished-operation check and the + unmerged-commit check together, and the copy was removed in silence. + + The failures below are injected through the `runner` seam the module + already takes for exactly this, so the copy underneath is a real one with + real work in it and only the *answer* is missing. + """ + + def setUp(self) -> None: + self.temporary = sandbox("session-kit-blind.") + self.base = Path(self.temporary.name) + self.state = self.base / "state" + self.state.mkdir() + self.root = self.base / "worktree-root" + self.environ = {"SESSION_KIT_WORKTREE_ROOT": os.fspath(self.root)} + assert_contained(self, self.base, self.root) + self.repo = self.base / "repo" + self.repo.mkdir() + git(self.repo, "init", "--initial-branch=main", "--quiet") + (self.repo / "README").write_text("one\n", encoding="utf-8") + git(self.repo, "add", "README") + git(self.repo, "commit", "--quiet", "-m", "first") + record = worktrees.materialize( + repo=self.repo, branch="worker/blind", state_dir=self.state, + environ=self.environ, auto=True, origin="machine", + ) + worktrees.bind(state_dir=self.state, path=record["path"], + shpool_id="60", environ=self.environ) + self.path = Path(record["path"]) + assert_contained(self, self.path) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def runner_that_cannot_answer(self, *failing: str): + """Real git, except the named subcommand always fails.""" + def run(argv, **kwargs): # noqa: ANN001 - a subprocess.run stand-in + # argv is ["git", "-C", <cwd>, <subcommand>, ...] + words = [str(value) for value in argv] + for name in failing: + wanted = name.split(" ") + if words[3 : 3 + len(wanted)] == wanted: + return subprocess.CompletedProcess( + words, 128, "", f"fatal: simulated failure of {name}\n" + ) + return subprocess.run(argv, **kwargs) + return run + + def release_with(self, runner) -> dict: # noqa: ANN001 + return worktrees.release( + self.state, shpool_id="60", environ=self.environ, + merged_into_ref="main", runner=runner, + ) + + def test_a_head_git_cannot_read_keeps_the_copy(self) -> None: + verdict = self.release_with(self.runner_that_cannot_answer("rev-parse HEAD")) + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn("which commit this copy is on", verdict["reason"]) + self.assertTrue(self.path.is_dir()) + + def test_a_git_directory_git_cannot_name_keeps_the_copy(self) -> None: + """Without it, a half-finished rebase in the copy is invisible.""" + verdict = self.release_with( + self.runner_that_cannot_answer("rev-parse --absolute-git-dir") + ) + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn("half-finished merge or rebase", verdict["reason"]) + self.assertTrue(self.path.is_dir()) + + def test_a_stash_list_that_failed_keeps_the_copy(self) -> None: + verdict = self.release_with(self.runner_that_cannot_answer("stash list")) + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn("stash list could not be read", verdict["reason"]) + self.assertTrue(self.path.is_dir()) + + def test_a_comparison_that_errored_keeps_the_copy(self) -> None: + verdict = self.release_with(self.runner_that_cannot_answer("merge-base")) + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn("could not be compared", verdict["reason"]) + self.assertTrue(self.path.is_dir()) + + def test_an_unreadable_process_table_keeps_the_copy(self) -> None: + """`[]` from a `/proc` that could not be listed is not "nobody is here". + + That empty list is the only thing standing between a live session and + having its directory removed underneath it. + """ + missing = self.base / "no-such-proc" + self.assertFalse(missing.exists()) + verdict = worktrees.release( + self.state, shpool_id="60", environ=self.environ, + merged_into_ref="main", proc=missing, + ) + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn("process table", verdict["reason"]) + self.assertTrue(self.path.is_dir()) + + def test_a_registry_record_that_cannot_be_read_stops_the_release(self) -> None: + """One session, two copies, one unreadable record. + + Concluding "this session holds exactly one copy" from a listing that + silently dropped the other is how the other becomes permanent, the + exact failure the many-copies branch exists to prevent. + """ + second = worktrees.materialize( + repo=self.repo, branch="worker/blind-two", state_dir=self.state, + environ=self.environ, auto=True, origin="machine", + ) + worktrees.bind(state_dir=self.state, path=second["path"], + shpool_id="60", environ=self.environ) + damaged = self.root / "registry" / f"{second['token']}.json" + damaged.write_text("{not json at all", encoding="utf-8") + + verdict = worktrees.release( + self.state, shpool_id="60", environ=self.environ, merged_into_ref="main" + ) + + self.assertEqual("kept", verdict["action"], verdict) + self.assertIn("could not be read", verdict["reason"]) + self.assertTrue(self.path.is_dir()) + self.assertTrue(Path(second["path"]).is_dir()) + + +class OnlyOurOwnRegistrationIsEverForgottenTests(unittest.TestCase): + """`git worktree prune` is repository-wide, and that is somebody else's work. + + A worktree's `.git/worktrees/<name>` directory holds its index, its + staged-but-uncommitted content, its HEAD, its reflog and its rebase state. + `git worktree prune` deletes that directory for *every* worktree of the + repository whose own directory it cannot reach at that moment: one on a + volume that is not mounted, one on a share that is briefly down, one + somebody renamed and means to move back. The kit ran it for its own + housekeeping, with `check=False`, inside a repository it was told never to + touch beyond its own copy. + """ + + def setUp(self) -> None: + self.temporary = sandbox("session-kit-prune.") + self.base = Path(self.temporary.name) + self.state = self.base / "state" + self.state.mkdir() + self.root = self.base / "worktree-root" + self.environ = {"SESSION_KIT_WORKTREE_ROOT": os.fspath(self.root)} + assert_contained(self, self.base, self.root) + self.repo = self.base / "repo" + self.repo.mkdir() + git(self.repo, "init", "--initial-branch=main", "--quiet") + (self.repo / "README").write_text("one\n", encoding="utf-8") + git(self.repo, "add", "README") + git(self.repo, "commit", "--quiet", "-m", "first") + + def tearDown(self) -> None: + self.temporary.cleanup() + + def an_unreachable_worktree_of_the_operators(self) -> tuple[str, Path, Path]: + """A hand-made worktree with staged work, whose directory is moved away.""" + theirs = self.base / "theirs" + parked = self.base / "theirs-on-an-unmounted-volume" + git(self.repo, "worktree", "add", "--quiet", "-b", "operator/manual", + os.fspath(theirs), "main") + (theirs / "staged.txt").write_text("hours of it\n", encoding="utf-8") + git(theirs, "add", "staged.txt") + registrations = self.repo / ".git" / "worktrees" + name = next( + entry.name for entry in registrations.iterdir() + if entry.name != "" and (entry / "gitdir").is_file() + and os.fspath(theirs) in (entry / "gitdir").read_text(encoding="utf-8") + ) + theirs.rename(parked) + self.assertFalse(theirs.exists()) + self.assertTrue((registrations / name).is_dir()) + return name, parked, registrations / name + + def test_giving_a_copy_back_leaves_another_worktrees_registration_alone( + self, + ) -> None: + name, parked, registration = self.an_unreachable_worktree_of_the_operators() + record = worktrees.materialize( + repo=self.repo, branch="worker/ours", state_dir=self.state, + environ=self.environ, auto=True, origin="machine", + ) + worktrees.bind(state_dir=self.state, path=record["path"], + shpool_id="70", environ=self.environ) + + verdict = worktrees.release( + self.state, shpool_id="70", environ=self.environ, merged_into_ref="main" + ) + + self.assertEqual("removed", verdict["action"], verdict) + self.assertFalse(Path(record["path"]).exists(), "ours went") + self.assertTrue( + registration.is_dir(), + f"the operator's registration {name} was pruned with ours", + ) + self.assertTrue((registration / "index").is_file(), "their staged work") + # And it still works: put the volume back and the staged file is there. + parked.rename(self.base / "theirs") + self.assertIn( + "staged.txt", + git(self.base / "theirs", "diff", "--cached", "--name-only"), + ) + + def test_rebuilding_a_copy_whose_directory_vanished_prunes_nothing_else( + self, + ) -> None: + """The same prune ran on an ordinary retried launch, with no teardown.""" + name, parked, registration = self.an_unreachable_worktree_of_the_operators() + record = worktrees.materialize( + repo=self.repo, branch="worker/retried", state_dir=self.state, + environ=self.environ, auto=True, origin="machine", + ) + shutil.rmtree(record["path"]) + + again = worktrees.materialize( + repo=self.repo, branch="worker/retried", state_dir=self.state, + environ=self.environ, auto=True, origin="machine", + ) + + self.assertTrue(Path(again["path"]).is_dir()) + self.assertTrue( + registration.is_dir(), + f"the operator's registration {name} was pruned by a relaunch", + ) + self.assertTrue((registration / "index").is_file()) + + +class ACheckoutThatIsTheRunningThingIsNeverCopiedTests(unittest.TestCase): + """Some directories are served live. A copy of one is not isolation. + + Editing a copy of a checkout whose files are being served is work that + never takes effect, and the repository would collect a branch per + delegated session besides. A repository says so for itself with a marker + file, or the host names it; either way nothing is written into it and + nothing is ever removed outside the kit's own root. + """ + + def setUp(self) -> None: + self.temporary = sandbox("session-kit-shared.") + self.base = Path(self.temporary.name) + self.state = self.base / "state" + self.state.mkdir() + self.root = self.base / "worktree-root" + self.environ = {"SESSION_KIT_WORKTREE_ROOT": os.fspath(self.root)} + assert_contained(self, self.base, self.root) + self.repo = self.base / "live-site" + self.repo.mkdir() + git(self.repo, "init", "--initial-branch=main", "--quiet") + (self.repo / "index.php").write_text("<?php\n", encoding="utf-8") + git(self.repo, "add", "index.php") + git(self.repo, "commit", "--quiet", "-m", "the live site") + + def tearDown(self) -> None: + self.temporary.cleanup() + + def assert_untouched(self) -> None: + self.assertEqual( + [], git(self.repo, "branch", "--list", "sk/*").strip().splitlines() + ) + self.assertFalse((self.repo / ".git" / "worktrees").exists()) + + def test_a_big_checkout_is_not_copied_without_being_asked(self) -> None: + """Four gigabytes inside `sp new` is a decision, not a default.""" + reason = worktrees.auto_copy_refusal( + self.repo, {"SESSION_KIT_AUTO_WORKTREE_MAX_FILES": "0"} + ) + self.assertEqual("", reason, "a limit of 0 turns the size check off") + reason = worktrees.auto_copy_refusal( + self.repo, {"SESSION_KIT_AUTO_WORKTREE_MAX_FILES": "1"} + ) + self.assertEqual("", reason, "one tracked file is not over a limit of one") + (self.repo / "second.php").write_text("<?php\n", encoding="utf-8") + git(self.repo, "add", "second.php") + git(self.repo, "commit", "--quiet", "-m", "second") + reason = worktrees.auto_copy_refusal( + self.repo, {"SESSION_KIT_AUTO_WORKTREE_MAX_FILES": "1"} + ) + self.assertIn("more than the 1 this copies without asking", reason) + # An explicit branch is still a decision, and is never refused for size. + record = worktrees.materialize( + repo=self.repo, branch="fix/by-hand", state_dir=self.state, + environ={**self.environ, "SESSION_KIT_AUTO_WORKTREE_MAX_FILES": "1"}, + ) + self.assertTrue(Path(record["path"]).is_dir()) + + def test_a_repository_under_a_system_root_is_refused_by_path_alone(self) -> None: + """The one answer that needs nobody to have configured anything. + + The marker file and `SESSION_KIT_SHARED_REPOS` are both opt-in, so on a + host where nobody set either, which is every host until somebody does + a checkout that *is* the running site was copyable. Where a + repository lives is a fact about it, and `/srv`, `/var`, `/opt` and + `/usr` are where a machine keeps software it runs. + """ + for candidate in ( + "/srv/site", "/var/www/app", "/opt/thing", "/usr/local/src/x", + "/etc/config", "/root/deploy", "/", + ): + with self.subTest(candidate=candidate): + reason = worktrees.shared_checkout(candidate, {}) + self.assertNotEqual("", reason, candidate) + self.assertIn("software it runs", reason) + # Scratch space sits inside those roots and is not software anything + # runs; refusing it would refuse every temporary fixture on the machine. + for candidate in ("/tmp/work", "/var/tmp/work", "/run/user/1000/x"): + with self.subTest(candidate=candidate): + self.assertEqual("", worktrees.shared_checkout(candidate, {})) + # A rule with no way out is a rule somebody works around. + self.assertEqual( + "", + worktrees.shared_checkout( + "/srv/site", {"SESSION_KIT_COPYABLE_REPOS": "/srv/site"} + ), + ) + + def test_a_working_copy_is_never_built_under_a_system_root(self) -> None: + """The other direction: not where it comes from, where it would land.""" + with self.assertRaises(worktrees.WorktreeError) as refusal: + worktrees.materialize( + repo=self.repo, branch="sk/w-1-1", state_dir=self.state, + environ={"SESSION_KIT_WORKTREE_ROOT": "/srv/session-kit-worktrees"}, + auto=True, + ) + self.assertIn("/srv", str(refusal.exception)) + self.assertIn("software it runs", str(refusal.exception)) + self.assert_untouched() + self.assertFalse(Path("/srv/session-kit-worktrees").exists()) + + def test_a_marker_file_in_the_repository_refuses_the_copy(self) -> None: + (self.repo / worktrees.SHARED_MARKER).write_text("", encoding="utf-8") + with self.assertRaises(worktrees.WorktreeError) as refusal: + worktrees.materialize( + repo=self.repo, branch="sk/w-1-1", state_dir=self.state, + environ=self.environ, auto=True, + ) + self.assertIn("shared checkout", str(refusal.exception)) + self.assert_untouched() + + def test_the_host_can_name_one_without_editing_the_repository(self) -> None: + environ = { + **self.environ, + "SESSION_KIT_SHARED_REPOS": f"/nowhere:{self.repo}", + } + with self.assertRaises(worktrees.WorktreeError) as refusal: + worktrees.materialize( + repo=self.repo, branch="sk/w-1-1", state_dir=self.state, + environ=environ, auto=True, + ) + self.assertIn("SESSION_KIT_SHARED_REPOS", str(refusal.exception)) + self.assert_untouched() + + def test_a_directory_inside_a_named_checkout_is_covered_too(self) -> None: + inside = self.repo / "httpdocs" + inside.mkdir() + self.assertIn( + "SESSION_KIT_SHARED_REPOS", + worktrees.shared_checkout( + inside, {"SESSION_KIT_SHARED_REPOS": os.fspath(self.repo)} + ), + ) + + def test_nothing_outside_the_kits_root_is_ever_removed(self) -> None: + """Whatever a record claims, removal stops at the root boundary.""" + source = self.base / "repo" + source.mkdir() + git(source, "init", "--initial-branch=main", "--quiet") + (source / "README").write_text("one\n", encoding="utf-8") + git(source, "add", "README") + git(source, "commit", "--quiet", "-m", "first") + record = worktrees.materialize( + repo=source, branch="worker/one", state_dir=self.state, + environ=self.environ, auto=True, + ) + registry = self.root / "registry" / f"{record['token']}.json" + keep = self.repo / "index.php" + for target in (self.repo, self.repo / "httpdocs"): + document = json.loads(registry.read_text(encoding="utf-8")) + document["path"] = os.fspath(target) + registry.write_text(json.dumps(document), encoding="utf-8") + with self.assertRaises(worktrees.WorktreeError) as refusal: + worktrees.teardown( + state_dir=self.state, path=target, environ=self.environ, + merged_into_ref="main", force=True, + ) + self.assertIn("outside the kit's worktree root", str(refusal.exception)) + verdict = worktrees.release( + self.state, path=target, environ=self.environ, merged_into_ref="main" + ) + self.assertEqual("kept", verdict["action"], verdict) + self.assertTrue(keep.is_file()) + + +class AReleasedCopyStillLeadsBackToItsRepositoryTests(unittest.TestCase): + """A delegated session that closes cleanly must stay restorable. + + Its recorded directory is the copy, and a clean close is exactly the case + where the copy is collected, so the most ordinary delegated session was + also the one whose conversation could never be reopened: restore refuses a + directory that does not exist, and no screen offers another one. The + release leaves a tombstone naming the repository, and the restore reopens + there instead of dead-ending. + """ + + UUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + + def setUp(self) -> None: + self.fixture = CommandFixture() + self.addCleanup(self.fixture.close) + self.environ = { + "SESSION_KIT_WORKTREE_ROOT": str(self.fixture.base / "worktrees") + } + self.repo = self.fixture.base / "repo" + self.repo.mkdir() + git(self.repo, "init", "--initial-branch=main", "--quiet") + (self.repo / "README").write_text("one\n", encoding="utf-8") + git(self.repo, "add", "README") + git(self.repo, "commit", "--quiet", "-m", "first") + + def released_copy(self) -> str: + record = worktrees.materialize( + repo=self.repo, branch="sk/w-1-1", state_dir=self.fixture.state, + environ=self.environ, auto=True, origin="machine", + ) + worktrees.bind(state_dir=self.fixture.state, path=record["path"], + shpool_id="worker", environ=self.environ) + verdict = worktrees.release( + self.fixture.state, shpool_id="worker", environ=self.environ, + merged_into_ref="main", + ) + self.assertEqual("removed", verdict["action"], verdict) + self.assertFalse(Path(record["path"]).exists()) + return str(record["path"]) + + def test_the_conversation_reopens_in_the_repository_the_copy_came_from( + self, + ) -> None: + gone = self.released_copy() + result = run( + [SP, "restore-exact", "codex", self.UUID, gone], + env={ + **self.fixture.env(), + "SESSION_KIT_BACKGROUND": "1", + "SESSION_KIT_PROVIDER_PROOF_ATTEMPTS": "2", + "STUB_DYNAMIC_PROVIDER": "codex", + "STUB_DYNAMIC_CWD": str(self.repo), + "STUB_DYNAMIC_UUID": self.UUID, + }, + check=False, + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("was given back when it closed", result.stdout) + self.assertIn(str(self.repo), result.stdout) + self.assertNotIn("not an existing absolute directory", result.stderr) + + def test_a_directory_that_was_never_a_copy_still_refuses(self) -> None: + """The fallback is for a copy this kit gave back, not for any typo.""" + result = run( + [SP, "restore-exact", "codex", self.UUID, + str(self.fixture.base / "never-existed")], + env={**self.fixture.env(), "SESSION_KIT_BACKGROUND": "1"}, + check=False, + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("not an existing absolute directory", result.stderr) + + +class TheSweepIsToldWhatIsAliveTests(unittest.TestCase): + """The list of live sessions is read where it can be got wrong once. + + "Nothing is alive" and "I could not find out what is alive" produce the + same empty list, and one of those two means remove every copy. The shell + that used to turn a `shpool list --json` payload into `--active` flags had + no way to tell them apart: an enumerator that failed printed nothing, and + the next line substituted "there are none alive". The payload is now read + by the verb that acts on it, which is a place a test can reach. + """ + + CORE = REPO / "lib" / "session_inventory.py" + + def setUp(self) -> None: + self.temporary = sandbox("session-kit-sweep-list.") + self.base = Path(self.temporary.name) + self.state = self.base / "state" + self.state.mkdir(mode=0o700) + self.root = self.base / "worktree-root" + assert_contained(self, self.base, self.root) + self.repo = self.base / "repo" + self.repo.mkdir() + git(self.repo, "init", "--initial-branch=main", "--quiet") + (self.repo / "README").write_text("one\n", encoding="utf-8") + git(self.repo, "add", "README") + git(self.repo, "commit", "--quiet", "-m", "first") + record = worktrees.materialize( + repo=self.repo, branch="worker/swept", state_dir=self.state, + environ={"SESSION_KIT_WORKTREE_ROOT": os.fspath(self.root)}, + auto=True, origin="machine", + ) + worktrees.bind( + state_dir=self.state, path=record["path"], shpool_id="live-one", + environ={"SESSION_KIT_WORKTREE_ROOT": os.fspath(self.root)}, + ) + self.copy = Path(record["path"]) + assert_contained(self, self.copy) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def sweep(self, payload: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, os.fspath(self.CORE), "worktree", "sweep", + "--active-stdin", "--merged-into", "main", + ], + input=payload, capture_output=True, text=True, check=False, + cwd=os.fspath(REPO), + env={ + **os.environ, + "SESSION_KIT_STATE_DIR": os.fspath(self.state), + "SESSION_KIT_WORKTREE_ROOT": os.fspath(self.root), + "PYTHONDONTWRITEBYTECODE": "1", + }, + ) + + def test_a_payload_that_cannot_be_read_sweeps_nothing(self) -> None: + for payload in ( + "", + "{not json", + json.dumps({"sessions": "not a list"}), + json.dumps({"sessions": [{"name": "live-one"}, "a stray row"]}), + json.dumps(17), + ): + with self.subTest(payload=payload): + result = self.sweep(payload) + self.assertEqual(2, result.returncode, result.stdout) + self.assertIn("could not be read", result.stderr) + self.assertTrue(self.copy.is_dir(), "the copy was removed anyway") + + def test_a_payload_that_names_the_session_keeps_its_copy(self) -> None: + result = self.sweep(json.dumps({"sessions": [{"name": "live-one"}]})) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("", result.stdout.strip()) + self.assertTrue(self.copy.is_dir()) + + def test_a_payload_that_read_cleanly_and_held_nothing_is_none_alive(self) -> None: + """The one case where an empty list really does mean nothing is alive.""" + result = self.sweep(json.dumps({"sessions": []})) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("Removed the worker/swept working copy", result.stdout) + self.assertFalse(self.copy.exists()) + + +class DelegatedSessionsAreIsolatedByDefaultTests(unittest.TestCase): + """`sp new` itself: who gets a copy without asking for one.""" + + def setUp(self) -> None: + self.fixture = CommandFixture() + + def tearDown(self) -> None: + self.fixture.close() + + def worktree_calls(self) -> list[list[str]]: + # Derived from the fixture's own directory rather than an attribute the + # fixture only grew on this branch: a test whose *absence* of calls is + # the point has to be able to run where the feature does not exist. + log = Path( + self.fixture.env().get( + "STUB_WORKTREE_LOG", str(self.fixture.base / "worktree.log") + ) + ) + if not log.exists(): + return [] + return [ + json.loads(line) + for line in log.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + def new_session(self, *argv: str) -> subprocess.CompletedProcess[str]: + return run( + [SP, "new", "shell", "fixture", *argv], + env={ + **self.fixture.env(), + "STUB_DYNAMIC_PROVIDER": "shell", + "STUB_DYNAMIC_CWD": str(self.fixture.project), + }, + ) + + def test_a_delegated_session_is_given_its_own_copy_without_asking(self) -> None: + started = self.new_session("--origin", "machine") + materialize = [call for call in self.worktree_calls() + if call[1:2] == ["materialize"]] + self.assertEqual(1, len(materialize), self.worktree_calls()) + self.assertIn("--auto", materialize[0]) + self.assertIn("--origin", materialize[0]) + self.assertIn("Its own copy of the code", started.stdout) + self.assertIn("goes back when this session closes", started.stdout) + + def test_the_copy_is_bound_to_the_session_that_got_it(self) -> None: + self.new_session("--origin", "machine") + bound = [call for call in self.worktree_calls() if call[1:2] == ["bind"]] + self.assertEqual(1, len(bound), self.worktree_calls()) + self.assertIn("--shpool-id", bound[0]) + + def test_a_persons_own_session_is_left_in_the_directory_they_chose(self) -> None: + self.new_session() + self.assertEqual([], self.worktree_calls()) + + def test_a_delegated_session_can_still_say_it_means_the_checkout(self) -> None: + self.new_session("--origin", "machine", "--no-worktree") + self.assertEqual([], self.worktree_calls()) + + def test_a_shared_checkout_keeps_the_session_in_it_and_says_why(self) -> None: + """A checkout that is the running thing is never copied.""" + started = run( + [SP, "new", "shell", "fixture", "--origin", "machine"], + env={ + **self.fixture.env(), + "STUB_DYNAMIC_PROVIDER": "shell", + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "STUB_SHARED_REPO": str(self.fixture.project), + }, + ) + self.assertIn("works in", started.stdout) + self.assertIn(".session-kit-shared marker", started.stdout) + self.assertEqual( + [], + [call for call in self.worktree_calls() if call[1:2] == ["materialize"]], + self.worktree_calls(), + ) + + def test_a_copy_check_that_broke_keeps_the_session_in_the_checkout(self) -> None: + """A guard that failed is not a guard that passed. + + `copy-check` answers in exit codes: 0 with a reason means do not copy + this one, 1 means copying it is fine. Every other exit is the check + itself failing, and reading "non-zero" as "go ahead" put a delegated + session in a copy of a checkout that might be the running site, where + its edits take effect nowhere and the verification reads the old page. + """ + started = run( + [SP, "new", "shell", "fixture", "--origin", "machine"], + env={ + **self.fixture.env(), + "STUB_DYNAMIC_PROVIDER": "shell", + "STUB_DYNAMIC_CWD": str(self.fixture.project), + "STUB_COPY_CHECK_BROKEN": "1", + }, + ) + self.assertIn("could not check whether", started.stdout) + self.assertIn("works in it directly", started.stdout) + self.assertIn("--worktree BRANCH", started.stdout) + self.assertEqual( + [], + [call for call in self.worktree_calls() if call[1:2] == ["materialize"]], + self.worktree_calls(), + ) + + def test_closing_a_session_gives_its_copy_back(self) -> None: + """The close itself releases it. Nobody has to remember a teardown.""" + row = session_row("worker") + self.fixture.inventory.write_text( + json.dumps(inventory_document(row)), encoding="utf-8" + ) + self.fixture.shpool_state.write_text( + json.dumps( + { + "sessions": [ + { + "name": "worker", + "status": "Disconnected", + "started_at_unix_ms": row["started_at_unix_ms"], + } + ] + } + ), + encoding="utf-8", + ) + closed = run( + [SP, "close", "worker"], + env={**self.fixture.env(), "SESSION_KIT_CONFIRM_ID": "worker"}, + check=False, + ) + self.assertEqual(0, closed.returncode, closed.stderr) + released = [call for call in self.worktree_calls() + if call[1:2] == ["release"]] + self.assertEqual(1, len(released), self.worktree_calls()) + self.assertIn("--shpool-id", released[0]) + self.assertIn("worker", released[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tui_support.py b/tests/tui_support.py new file mode 100644 index 0000000..ac77a1d --- /dev/null +++ b/tests/tui_support.py @@ -0,0 +1,149 @@ +"""Fixtures for the picker's tests. + +Rows here are the shape `shpool_status --json` writes, so a test that passes +against these is a test against the document the picker actually reads. +""" + +from __future__ import annotations + +from pathlib import Path +import sys + +REPO = Path(__file__).resolve().parents[1] +if str(REPO / "lib") not in sys.path: + sys.path.insert(0, str(REPO / "lib")) + +from sessionkit_tui.model import parse_inventory # noqa: E402 +from sessionkit_tui.state import Picker # noqa: E402 + + +class Clock: + """A clock a test can move. Nothing in the core reads a real one.""" + + def __init__(self, now: int = 1_000_000) -> None: + self.now = now + + def __call__(self) -> int: + return self.now + + def advance(self, milliseconds: int) -> None: + self.now += milliseconds + + +def row( + title: str, + *, + number: int, + provider: str = "claude", + availability: str = "ready", + needs_you: bool = False, + origin: str | None = None, + model: str | None = None, + account_alias: str | None = None, + quiet_seconds: int | None = None, + subagents: int = 0, + age_seconds: int = 300, + # A provider turn that has finished. This is a WAITING session: `idle` + # maps to `needs you`, so a fixture row that is meant to want nothing + # from a person has to say `agent_status="working"` for itself. + agent_status: str = "idle", + display_color: str | None = None, +) -> dict: + suffix = sum(map(ord, title)) + number + uuid = f"00000000-0000-4000-8000-{suffix:012d}" if provider != "shell" else None + record = { + "row": number, + "terminal_number": number, + "shpool_id": f"sk-{suffix}", + "shpool_id_raw": f"sk-{suffix}", + "display_shpool_id": f"sk-{suffix}", + "mutation_allowed": True, + "mutation_rejection_reason": None, + "shpool_shell": {"pid": 1000 + suffix, "process_start_ticks": 10_000 + suffix}, + "started_at_unix_ms": 1_700_000_000_000 + suffix, + "shpool_status": "Disconnected" if availability == "ready" else "Attached", + "availability": availability, + "provider": provider, + "display_provider": provider, + "identity": { + "uuid": uuid, + "pid": 2000 + suffix, + "process_start_ticks": 20_000 + suffix, + "provenance": "fixture", + "confidence": "exact", + }, + "title": title, + "display_title": title, + "native_title": title, + "cwd": "/srv/project", + "process_age_seconds": age_seconds, + "recent_output_age_seconds": quiet_seconds, + "recent_output_at_unix_ms": None, + "agent_status": agent_status, + "needs_you": needs_you, + "subagents": [{"status": "working"} for _ in range(subagents)], + "active_subagent_count": subagents, + "recovery": {"available": bool(uuid), "provider": provider, "uuid": uuid}, + "diagnostics": [], + } + if origin is not None: + record["origin"] = origin + if model is not None: + record["model"] = model + if account_alias is not None: + record["account_alias"] = account_alias + if display_color is not None: + record["display_color"] = display_color + return record + + +def document(*rows: dict, stale: bool = False) -> dict: + return { + "schema_version": 1, + "generated_at": "2026-08-12T00:00:00Z", + "source": "cache" if stale else "live", + "stale": stale, + "warnings": [], + "daemon_generation": {"boot_id": "fixture", "pid": 77, "process_start_ticks": 770}, + "sessions": list(rows), + "outside_agents": [], + } + + +def inventory(*rows: dict, stale: bool = False): + return parse_inventory(document(*rows, stale=stale)) + + +def picker(*rows: dict, clock: Clock | None = None, **keywords) -> Picker: + clock = clock or Clock() + return Picker(inventory=inventory(*rows), clock=clock, stall=2700, **keywords) + + +def write_executable(path: Path, body: str) -> Path: + path.write_text(body, encoding="utf-8") + path.chmod(0o755) + return path + + +def panel_to(screen, label: str): + """Move the panel highlight onto a named row, or fail loudly.""" + + for index, item in enumerate(screen.panel.items): + if item.label == label: + screen.move(index - screen.panel.index) + return screen.panel.current + raise AssertionError(f"no panel row named {label}") + + +def row_to(screen, kind: str, limit: int = 60): + """Move the list highlight onto the first row of a kind.""" + + for _ in range(limit): + current = screen.current_row() + if current is not None and current.kind == kind: + return current + before = screen.cursor_index() + screen.move(1) + if screen.cursor_index() == before: + break + raise AssertionError(f"no row of kind {kind}") diff --git a/tools/build-public-tree b/tools/build-public-tree index d6195fa..9852552 100755 --- a/tools/build-public-tree +++ b/tools/build-public-tree @@ -23,6 +23,7 @@ REQUIRED = { "THIRD_PARTY_NOTICES", "bashrc/shpool.bashrc", "bin/codex_resume_here", + "bin/reset-collection-order.py", "bin/session-kit", "bin/session_kit_common", "bin/session_kit_watchdog", @@ -35,14 +36,19 @@ REQUIRED = { "config/shpool.example.toml", "deploy/session-kit-launcher", "deploy/session-kit-release", + "extras/statusline-quota-refresh.example", "install.sh", "lib/session_inventory.py", "lib/sessionkit_inventory/__init__.py", "lib/sessionkit_inventory/common.py", "lib/sessionkit_inventory/lifecycle.py", + "lib/sessionkit_inventory/private_store.py", + "lib/sessionkit_inventory/projects.py", "lib/sessionkit_inventory/providers.py", "lib/sessionkit_inventory/reaper.py", "lib/sessionkit_inventory/state_io.py", + "lib/sessionkit_inventory/worker_model.py", + "lib/sessionkit_inventory/worktrees.py", "public-files.txt", "shpool/config.toml", "systemd/session-kit-watchdog.service", @@ -53,6 +59,8 @@ REQUIRED = { "tests/run", "tests/support.py", "tests/test_lifecycle_shell.py", + "tests/test_manifest_coverage.py", + "tests/test_projects.py", "tests/test_public_export.py", "tests/test_reaper_autoclose.py", "tests/test_release.py", @@ -60,6 +68,7 @@ REQUIRED = { "tools/build-release-artifact", "tools/check-doc-links", "tools/public-scan", + "tools/public-scan-history-baseline", } diff --git a/tools/build-release-artifact b/tools/build-release-artifact index 217e413..45f2c4e 100755 --- a/tools/build-release-artifact +++ b/tools/build-release-artifact @@ -8,7 +8,7 @@ import gzip import hashlib import io import json -from pathlib import Path +from pathlib import Path, PurePosixPath import re import subprocess import tarfile @@ -26,19 +26,109 @@ BUILD_INPUTS = ( ) -def tree_digest(root: Path) -> str: +def entry_order(relative: str) -> tuple[str, ...]: + """Order entries by path component, never by raw string. + + Sorting `Path` objects delegates to the interpreter's own path comparison, + so the archive layout and the tree digest would depend on the Python build + that produced them. Ordering by components is fixed by this file alone. + """ + return PurePosixPath(relative).parts + + +def digest_entries(entries: list[tuple[str, int, bytes]]) -> str: digest = hashlib.sha256() - for path in sorted(item for item in root.rglob("*") if item.is_file()): - relative = path.relative_to(root).as_posix() - mode = path.stat().st_mode & 0o777 + for relative, mode, payload in sorted( + entries, key=lambda entry: entry_order(entry[0]) + ): digest.update(relative.encode("utf-8")) digest.update(b"\0") digest.update(f"{mode:o}".encode("ascii")) digest.update(b"\0") - digest.update(hashlib.sha256(path.read_bytes()).digest()) + digest.update(hashlib.sha256(payload).digest()) return digest.hexdigest() +def tree_digest(root: Path) -> str: + return digest_entries( + [ + ( + path.relative_to(root).as_posix(), + path.stat().st_mode & 0o777, + path.read_bytes(), + ) + for path in root.rglob("*") + if path.is_file() + ] + ) + + +def require_reachable(commit: str) -> None: + """Refuse a commit no ref reaches: it can be collected, and then the + artifact's recorded source disappears from the repository.""" + ancestor = subprocess.run( + ["git", "-C", str(ROOT), "merge-base", "--is-ancestor", commit, "HEAD"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if ancestor.returncode == 0: + return + refs = subprocess.run( + [ + "git", + "-C", + str(ROOT), + "for-each-ref", + "--contains", + commit, + "--count=1", + "--format=%(refname)", + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + if refs.returncode == 0 and refs.stdout.strip(): + return + raise RuntimeError( + f"release commit is not reachable from HEAD or any ref: {commit}" + ) + + +def verify_archive( + archive_path: Path, + prefix: str, + commit: str, + expected_digest: str, +) -> None: + """Read the built archive back and prove what it contains and records.""" + entries: list[tuple[str, int, bytes]] = [] + with tarfile.open(archive_path, "r:gz") as archive: + for member in archive: + name = member.name + if name.startswith("/") or ".." in Path(name).parts: + raise RuntimeError(f"unsafe archive member: {name}") + if name != prefix and not name.startswith(f"{prefix}/"): + raise RuntimeError(f"archive member outside the prefix: {name}") + if member.isdir(): + continue + if not member.isfile(): + raise RuntimeError(f"unsupported archive member: {name}") + payload = archive.extractfile(member) + if payload is None: + raise RuntimeError(f"unreadable archive member: {name}") + entries.append( + (name[len(prefix) + 1 :], member.mode & 0o777, payload.read()) + ) + if digest_entries(entries) != expected_digest: + raise RuntimeError("archive content does not match the exported public tree") + records = [payload for relative, _, payload in entries if relative == "SOURCE.json"] + if not records: + raise RuntimeError("archive does not contain SOURCE.json") + if json.loads(records[0]).get("source_commit") != commit: + raise RuntimeError("archive does not record the requested commit") + + def require_committed_builder(commit: str) -> None: for relative in BUILD_INPUTS: committed = subprocess.run( @@ -60,7 +150,10 @@ def add_tree(archive: tarfile.TarFile, source: Path, prefix: str) -> None: root_info.mtime = 0 archive.addfile(root_info) - for path in sorted(source.rglob("*")): + for path in sorted( + source.rglob("*"), + key=lambda item: entry_order(item.relative_to(source).as_posix()), + ): relative = path.relative_to(source).as_posix() info = tarfile.TarInfo(f"{prefix}/{relative}") info.mtime = 0 @@ -94,6 +187,7 @@ def main() -> int: stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) + require_reachable(args.commit) require_committed_builder(args.commit) output = args.output_dir.resolve() @@ -139,6 +233,12 @@ def main() -> int: while chunk := source.read(1024 * 1024): compressed.write(chunk) + try: + verify_archive(archive_path, basename, args.commit, public_digest) + except Exception: + archive_path.unlink(missing_ok=True) + raise + archive_digest = hashlib.sha256(archive_path.read_bytes()).hexdigest() checksum_path.write_text( f"{archive_digest} {archive_path.name}\n", @@ -150,6 +250,9 @@ def main() -> int: "builder_commit": args.commit, "archive": archive_path.name, "archive_sha256": archive_digest, + # The archive was read back after the build: its files match the exported + # tree digest and its SOURCE.json records this exact source commit. + "archive_verified": True, "public_tree_sha256": public_digest, "build_tool": "tools/build-release-artifact", "reproducible_timestamps": True, diff --git a/tools/check-doc-links b/tools/check-doc-links index d944e0b..4139223 100755 --- a/tools/check-doc-links +++ b/tools/check-doc-links @@ -1,18 +1,71 @@ #!/usr/bin/env python3 -"""Check local links in public Markdown files.""" +"""Check local links in public Markdown files. + +Both halves of a link are checked: the file it points at, and the heading +inside that file when the link carries a `#fragment`. The fragment half was +unchecked for a long time, which meant a renamed heading silently turned +every link to it into a link that scrolls nowhere, invisible in review +because the page still loads. +""" from __future__ import annotations import argparse +from itertools import chain from pathlib import Path import re from urllib.parse import unquote, urlsplit LINK = re.compile(r"(?<!!)\[[^\]]+\]\(([^)]+)\)") +# Markdown is not the only way a shipped page links. The README's own +# navigation is a row of <a href="#section">, and every picture on it is an +# <img src="docs/assets/...">. Neither was checked, so a renamed section or a +# deleted picture broke the front page with nothing failing. +HTML_LINK = re.compile( + r"<(?:a[^>]*?\shref|img[^>]*?\ssrc)=[\"']([^\"']+)[\"']", re.IGNORECASE +) +HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$") +FENCE = re.compile(r"^\s*(?:```|~~~)") IGNORED_PARTS = {".git", ".mypy_cache", ".ruff_cache", "__pycache__"} +def anchors(document: Path) -> set[str]: + """Every fragment GitHub will resolve inside one Markdown file. + + GitHub lowercases the heading, drops everything that is not a word + character, a space, or a hyphen, turns runs of spaces into hyphens, and + suffixes a repeat with -1, -2. Headings inside fenced code are not + headings. + """ + + found: set[str] = set() + seen: dict[str, int] = {} + fenced = False + for line in document.read_text(encoding="utf-8").splitlines(): + if FENCE.match(line): + fenced = not fenced + continue + if fenced: + continue + match = HEADING.match(line) + if match is None: + continue + text = match.group(2) + # A heading may carry markup, the README's own title holds the + # project mark. GitHub slugs the text it renders, not the source. + text = re.sub(r"<[^>]+>", "", text) + text = re.sub(r"`([^`]*)`", r"\1", text) + text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text) + text = re.sub(r"[*_]{1,3}([^*_]*)[*_]{1,3}", r"\1", text) + slug = re.sub(r"[^\w\s-]", "", text.strip().casefold()) + slug = re.sub(r"\s+", "-", slug) + repeats = seen.get(slug, 0) + seen[slug] = repeats + 1 + found.add(slug if repeats == 0 else f"{slug}-{repeats}") + return found + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("root", nargs="?", default=".", type=Path) @@ -28,13 +81,21 @@ def main() -> int: continue text = document.read_text(encoding="utf-8") for line_number, line in enumerate(text.splitlines(), 1): - for match in LINK.finditer(line): + for match in chain(LINK.finditer(line), HTML_LINK.finditer(line)): raw = match.group(1).strip().split(maxsplit=1)[0].strip("<>") parsed = urlsplit(raw) - if parsed.scheme or raw.startswith(("#", "//")): + if parsed.scheme or raw.startswith(("//", "data:")): continue target_text = unquote(parsed.path) if not target_text: + # A bare "#section" points inside this very document. + if parsed.fragment: + checked += 1 + if parsed.fragment.casefold() not in anchors(document): + failures.append( + f"{document.relative_to(root)}:{line_number}: " + f"no such heading: {raw}" + ) continue checked += 1 if target_text.startswith("/"): @@ -57,6 +118,16 @@ def main() -> int: f"{document.relative_to(root)}:{line_number}: " f"missing target: {raw}" ) + continue + if ( + parsed.fragment + and target.suffix == ".md" + and parsed.fragment.casefold() not in anchors(target) + ): + failures.append( + f"{document.relative_to(root)}:{line_number}: " + f"no such heading: {raw}" + ) if failures: print("documentation link check failed:") for failure in failures: diff --git a/tools/check-embedded-scripts b/tools/check-embedded-scripts new file mode 100755 index 0000000..f883e19 --- /dev/null +++ b/tools/check-embedded-scripts @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Syntax-check the scripts Session Kit embeds in shell heredocs. + +`bash -n`, ShellCheck, and Ruff all stop at the heredoc marker: to every one of +them a heredoc body is opaque text. Session Kit keeps thousands of lines of +Python and a handful of shell fragments inside those bodies, including the +login path in `bashrc/shpool.bashrc`, where a syntax error is not a failed +check but a broken interactive login. + +This extracts each literal heredoc body, checks it with the tool that +understands it, and maps any error back to its line in the shell file. + +Bodies from unquoted heredocs are expanded by the shell before the interpreter +sees them, so their source is not the text on disk. Those are counted and +skipped rather than checked against a text the shell never runs. +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile + + +DEFAULT_TARGETS = ( + "install.sh", + "bin", + "lib/sh", + "bashrc/shpool.bashrc", + "deploy/session-kit-launcher", + "tests/run", + "extras/notify-desktop", +) + +# `<<` starts a heredoc; `<<<` is a here-string and `<<=` is an assignment, so +# both are excluded. The delimiter may be quoted, which is what makes the body +# literal instead of expanded. +HEREDOC = re.compile( + r"""<<(?P<dash>-?)\s*(?P<quote>['"]?)(?P<name>[A-Za-z_][A-Za-z0-9_]*)(?P=quote)""" +) +PYTHON_NAMES = {"PY", "PYTHON", "PYTHON3", "PYCODE"} +SHELL_NAMES = {"SH", "BASH", "SHELL", "ZSH"} +PYTHON_OPENERS = ("import ", "from ", "def ", "class ", "if __name__") + + +@dataclass +class Body: + """One heredoc body and where it starts in the shell file.""" + + path: Path + name: str + line: int + expanded: bool + text: str + + +def find_bodies(path: Path) -> list[Body]: + """Return every heredoc body in one shell file, in file order.""" + lines = path.read_text(encoding="utf-8").splitlines() + bodies: list[Body] = [] + pending: list[tuple[str, bool, bool]] = [] + collecting: tuple[str, bool, bool, int, list[str]] | None = None + for number, line in enumerate(lines, 1): + if collecting is not None: + name, strip_tabs, expanded, start, collected = collecting + candidate = line.lstrip("\t") if strip_tabs else line + if candidate == name: + bodies.append( + Body(path, name, start, expanded, "\n".join(collected) + "\n") + ) + collecting = None + else: + collected.append(candidate) + continue + if pending: + name, strip_tabs, expanded = pending.pop(0) + collecting = (name, strip_tabs, expanded, number, []) + candidate = line.lstrip("\t") if strip_tabs else line + if candidate == name: + bodies.append(Body(path, name, number, expanded, "")) + collecting = None + else: + collecting[4].append(candidate) + continue + if line.lstrip().startswith("#"): + continue + for match in HEREDOC.finditer(line): + before = line[: match.start()] + if before.endswith("<") or line[match.end() : match.end() + 1] == "<": + continue + pending.append((match["name"], match["dash"] == "-", match["quote"] == "")) + if collecting is not None: + raise ValueError( + f"{path}:{collecting[3]}: heredoc {collecting[0]} is never terminated" + ) + return bodies + + +def classify(body: Body) -> str: + """Return "python", "shell", or "data" for one body.""" + if body.name in PYTHON_NAMES: + return "python" + if body.name in SHELL_NAMES: + return "shell" + for line in body.text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith(PYTHON_OPENERS): + return "python" + break + return "data" + + +def check_python(body: Body) -> list[str]: + try: + compile(body.text, str(body.path), "exec") + except SyntaxError as error: + line = body.line + (error.lineno or 1) - 1 + return [f"{body.path}:{line}: embedded Python: {error.msg}"] + except ValueError as error: + return [f"{body.path}:{body.line}: embedded Python: {error}"] + return [] + + +def check_shell(body: Body, shellcheck: str | None) -> list[str]: + problems = [] + with tempfile.TemporaryDirectory(prefix="session-kit-embedded.") as temporary: + script = Path(temporary) / "embedded.sh" + script.write_text(body.text, encoding="utf-8") + commands = [["bash", "-n", os.fspath(script)]] + if shellcheck is not None: + commands.append([shellcheck, "--shell=bash", os.fspath(script)]) + for command in commands: + done = subprocess.run( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + if done.returncode == 0: + continue + detail = " ".join(done.stdout.split())[:400] or "no output" + problems.append( + f"{body.path}:{body.line}: embedded shell " + f"({Path(command[0]).name}): {detail}" + ) + return problems + + +def shell_files(targets: list[str], root: Path) -> list[Path]: + found: list[Path] = [] + for target in targets: + path = root / target + if path.is_dir(): + found.extend(sorted(item for item in path.iterdir() if item.is_file())) + elif path.is_file(): + found.append(path) + else: + raise SystemExit(f"check-embedded-scripts: missing target: {path}") + return found + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Syntax-check scripts embedded in shell heredocs." + ) + parser.add_argument( + "targets", + nargs="*", + help="files or directories to scan (default: the kit's shell sources)", + ) + parser.add_argument( + "--root", + default=os.fspath(Path(__file__).resolve().parents[1]), + help="repository root the default targets resolve against", + ) + parser.add_argument( + "--quiet", action="store_true", help="print only failures and the summary" + ) + arguments = parser.parse_args() + root = Path(arguments.root).resolve() + targets = arguments.targets or list(DEFAULT_TARGETS) + shellcheck = shutil.which("shellcheck") + problems: list[str] = [] + checked = {"python": 0, "shell": 0} + skipped_expanded = 0 + data_bodies = 0 + for path in shell_files(targets, root): + try: + bodies = find_bodies(path) + except (OSError, UnicodeError, ValueError) as error: + problems.append(f"{path}: {error}") + continue + for body in bodies: + kind = classify(body) + if kind == "data": + data_bodies += 1 + continue + if body.expanded: + skipped_expanded += 1 + continue + checked[kind] += 1 + if kind == "python": + problems.extend(check_python(body)) + else: + problems.extend(check_shell(body, shellcheck)) + if not arguments.quiet: + print( + f"{path.relative_to(root)}: {len(bodies)} heredocs", + flush=True, + ) + for problem in problems: + print(problem, file=sys.stderr) + tool = "shellcheck and bash -n" if shellcheck else "bash -n" + print( + f"embedded script check: {checked['python']} Python bodies compiled, " + f"{checked['shell']} shell bodies checked with {tool}, " + f"{skipped_expanded} expanded bodies skipped, " + f"{data_bodies} data bodies ignored, {len(problems)} problems" + ) + return 1 if problems else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/install-matrix b/tools/install-matrix new file mode 100755 index 0000000..d17f78b --- /dev/null +++ b/tools/install-matrix @@ -0,0 +1,278 @@ +#!/usr/bin/env bash +# Prove the documented Linux install on clean distribution images. +# +# For each distribution the matrix builds an image carrying only the documented +# prerequisites, boots systemd inside it, clones this repository from the local +# path, and runs the documented sequence: ./install.sh --check, a +# non-interactive ./install.sh, session-kit services enable, session-kit +# doctor. Nothing reaches the network for the source, so a run proves the +# checkout in front of you rather than whatever GitHub currently serves. +# +# What a container cannot prove is recorded in the report, never faked. The +# matrix sets SESSION_KIT_TESTING nowhere; every check runs for real. +# +# Usage: +# tools/install-matrix [options] [distro ...] +# +# Options: +# --results-dir DIR Write logs and the summary here (default: a temp dir). +# --shpool PATH Use an existing static shpool 0.11.0 binary. +# --jobs N Distributions to run at once (default: 2). +# --keep Leave containers running for inspection. +# --no-build Reuse existing matrix images; do not rebuild. +# --list Print the known distributions and exit. +# --help This text. +# +# Requirements: Docker, and enough privilege to run a container with +# CAP_SYS_ADMIN. Containers are named sk-install-matrix-<distro>, are capped at +# 2 CPUs and 2 GiB, never use host networking or host bind mounts beyond a +# read-only view of this repository, and are removed at the end of a run. +set -uo pipefail + +program=${0##*/} +repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +assets_dir=$repo_dir/tools/install-matrix.d +name_prefix=sk-install-matrix +shpool_version=0.11.0 + +# distro base image +DISTROS=( + "ubuntu-2204 ubuntu:22.04 apt" + "ubuntu-2404 ubuntu:24.04 apt" + "debian-12 debian:12 apt" + "fedora-41 fedora:41 dnf" +) + +die() { + printf '%s: %s\n' "$program" "$*" >&2 + exit 1 +} + +note() { + printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" +} + +usage() { + sed -n '2,/^set -uo/p' "$repo_dir/tools/install-matrix" | + sed -e 's/^# \{0,1\}//' -e '/^set -uo/d' +} + +distro_field() { + local wanted=$1 field=$2 row name image family + for row in "${DISTROS[@]}"; do + read -r name image family <<<"$row" + [[ $name == "$wanted" ]] || continue + case $field in + image) printf '%s\n' "$image" ;; + family) printf '%s\n' "$family" ;; + esac + return 0 + done + return 1 +} + +results_dir= +shpool_binary= +jobs=2 +keep=0 +build=1 +selected=() +while (($#)); do + case $1 in + --results-dir) results_dir=${2:?--results-dir needs a path}; shift 2 ;; + --shpool) shpool_binary=${2:?--shpool needs a path}; shift 2 ;; + --jobs) jobs=${2:?--jobs needs a number}; shift 2 ;; + --keep) keep=1; shift ;; + --no-build) build=0; shift ;; + --list) + printf '%s\n' "${DISTROS[@]}" | awk '{printf "%-12s %s\n", $1, $2}' + exit 0 + ;; + --help|-h) usage; exit 0 ;; + -*) die "unknown option: $1" ;; + *) selected+=("$1"); shift ;; + esac +done + +[[ $jobs =~ ^[1-9][0-9]*$ ]] || die "--jobs takes a positive number" +if ((${#selected[@]} == 0)); then + for row in "${DISTROS[@]}"; do + selected+=("${row%% *}") + done +fi +for name in "${selected[@]}"; do + distro_field "$name" image >/dev/null || + die "unknown distribution: $name (see --list)" +done + +command -v docker >/dev/null 2>&1 || die "docker is required" +docker version >/dev/null 2>&1 || die "docker is not usable by this account" +# A linked worktree keeps a .git file rather than a directory, so both count. +[[ -e $repo_dir/.git ]] || die "install-matrix runs from a Git checkout" +[[ -r $assets_dir/user-phase.sh ]] || die "missing $assets_dir/user-phase.sh" + +if [[ -z $results_dir ]]; then + results_dir=$(mktemp -d -t install-matrix-XXXXXX) || die "cannot create a results directory" +else + mkdir -p "$results_dir" || die "cannot create $results_dir" + results_dir=$(cd -- "$results_dir" && pwd -P) +fi + +source_commit=$(git -C "$repo_dir" rev-parse HEAD) +source_branch=$(git -C "$repo_dir" rev-parse --abbrev-ref HEAD) +note "source $source_branch @ $source_commit" +note "results $results_dir" + +# Containers clone from a staged copy of this checkout rather than from the +# checkout itself. A linked worktree keeps its objects in the main repository, +# which a container cannot reach through one read-only mount, and staging on the +# host resolves that for every checkout shape. The staged copy carries the +# committed HEAD, so an uncommitted edit is never silently proved. +source_stage=$results_dir/source +rm -rf "$source_stage" +git clone --no-hardlinks --quiet "$repo_dir" "$source_stage" || + die "cannot stage a clone of $repo_dir" +note "staged clone $source_stage @ $(git -C "$source_stage" rev-parse HEAD)" + +# shpool is not packaged by any distribution. Build the static binary once with +# the container route in extras/build-static-binary.md and reuse it in every +# leg, which is what a maintainer or a CI cache would do. +shpool_dir=$results_dir/shpool +mkdir -p "$shpool_dir" +if [[ -n $shpool_binary ]]; then + [[ -x $shpool_binary ]] || die "shpool binary is not executable: $shpool_binary" + install -m 755 "$shpool_binary" "$shpool_dir/shpool" || die "cannot stage shpool" + note "shpool staged from $shpool_binary" +else + note "building static shpool $shpool_version (rust:alpine, one container)" + docker run --rm --name "$name_prefix-shpool-build" --cpus 2 --memory 3g \ + -v "$shpool_dir:/out" rust:alpine sh -c " + apk add -q musl-dev && + cargo install shpool --version $shpool_version --locked --quiet && + cp /usr/local/cargo/bin/shpool /out/ && + /out/shpool version + " >"$results_dir/shpool-build.log" 2>&1 || + die "static shpool build failed; see $results_dir/shpool-build.log" + chmod 755 "$shpool_dir/shpool" +fi +"$shpool_dir/shpool" version >"$results_dir/shpool-version.txt" 2>&1 || true + +build_image() { + local distro=$1 image family dockerfile + image=$(distro_field "$distro" image) + family=$(distro_field "$distro" family) + dockerfile=$assets_dir/Dockerfile.$family + note "build $distro from $image" + docker build --quiet \ + --file "$dockerfile" \ + --build-arg "BASE_IMAGE=$image" \ + --tag "$name_prefix:$distro" \ + "$assets_dir" >"$results_dir/$distro-build.log" 2>&1 +} + +run_leg() { + local distro=$1 + local container=$name_prefix-$distro + local log=$results_dir/$distro.log + local status=0 + docker rm -f "$container" >/dev/null 2>&1 + { + printf 'distro: %s\nimage: %s\nsource: %s @ %s\nstarted: %s\n' \ + "$distro" "$(distro_field "$distro" image)" "$source_branch" \ + "$source_commit" "$(date -u +%FT%TZ)" + } >"$log" + # A private cgroup namespace plus CAP_SYS_ADMIN is what systemd needs inside + # a container. No host cgroup mount, no host network, no privileged mode. + if ! docker run -d --name "$container" \ + --memory 2g --cpus 2 \ + --cgroupns=private --cap-add SYS_ADMIN \ + --security-opt seccomp=unconfined \ + --tmpfs /run --tmpfs /run/lock \ + -v "$source_stage:/src:ro" \ + -v "$assets_dir:/matrix:ro" \ + -v "$results_dir/shpool:/shpool:ro" \ + "$name_prefix:$distro" >>"$log" 2>&1; then + printf 'MATRIX_OVERALL 2\ncontainer failed to start\n' >>"$log" + return 2 + fi + local waited=0 + until docker exec "$container" systemctl is-system-running >/dev/null 2>&1; do + ((waited += 1)) + if ((waited > 30)); then + printf 'systemd did not reach a running state within 30s\n' >>"$log" + break + fi + sleep 1 + done + docker exec --env MATRIX_USER=tester "$container" \ + bash /matrix/root-phase.sh >>"$log" 2>&1 || status=$? + docker cp "$container:/home/tester/doctor.json" \ + "$results_dir/$distro-doctor.json" >/dev/null 2>&1 || true + if ((keep == 0)); then + docker rm -f "$container" >/dev/null 2>&1 + fi + return "$status" +} + +if ((build)); then + pids=() + for distro in "${selected[@]}"; do + build_image "$distro" & + pids+=("$!:$distro") + done + build_failed=0 + for entry in "${pids[@]}"; do + if ! wait "${entry%%:*}"; then + note "image build FAILED for ${entry#*:} (see $results_dir/${entry#*:}-build.log)" + build_failed=1 + fi + done + ((build_failed == 0)) || die "one or more images failed to build" +fi + +declare -A leg_status=() +running=0 +pids=() +for distro in "${selected[@]}"; do + run_leg "$distro" & + pids+=("$!:$distro") + ((running += 1)) + if ((running >= jobs)); then + entry=${pids[0]} + pids=("${pids[@]:1}") + wait "${entry%%:*}" + leg_status[${entry#*:}]=$? + ((running -= 1)) + fi +done +for entry in "${pids[@]}"; do + wait "${entry%%:*}" + leg_status[${entry#*:}]=$? +done + +summary=$results_dir/summary.tsv +: >"$summary" +overall=0 +for distro in "${selected[@]}"; do + status=${leg_status[$distro]:-3} + printf '%s\t%s\t%s\n' "$distro" "$(distro_field "$distro" image)" \ + "$( ((status == 0)) && printf pass || printf "fail(%s)" "$status")" \ + >>"$summary" + ((status == 0)) || overall=1 +done + +printf '\n== install matrix ==\n' +printf 'source: %s @ %s\n' "$source_branch" "$source_commit" +printf 'results: %s\n\n' "$results_dir" +while IFS=$'\t' read -r distro image verdict; do + printf '%-12s %-14s %s\n' "$distro" "$image" "$verdict" + grep -h '^MATRIX_RESULT ' "$results_dir/$distro.log" 2>/dev/null | + awk '{ printf " %-24s rc=%s\n", $2, $3 }' +done <"$summary" + +if ((overall == 0)); then + printf '\nall legs passed\n' +else + printf '\nat least one leg failed; read the per-distro logs above\n' +fi +exit "$overall" diff --git a/tools/install-matrix.d/Dockerfile.apt b/tools/install-matrix.d/Dockerfile.apt new file mode 100644 index 0000000..20c2225 --- /dev/null +++ b/tools/install-matrix.d/Dockerfile.apt @@ -0,0 +1,49 @@ +# Matrix image for Debian-family distributions. +# +# Layer order is deliberate: container scaffolding first, then the documented +# prerequisites verbatim from docs/install.md, so a missing documented package +# shows up as a preflight failure instead of being hidden by scaffolding. +ARG BASE_IMAGE=debian:12 +FROM ${BASE_IMAGE} + +ENV DEBIAN_FRONTEND=noninteractive + +# Scaffolding, not prerequisites. A real Linux machine boots systemd and has a +# login session; a base container image has neither. systemd and dbus are +# installed here to reproduce that machine state, never to satisfy a check the +# kit makes about the kit itself. +RUN apt-get update \ + && apt-get install -y systemd dbus dbus-user-session \ + && rm -rf /var/lib/apt/lists/* + +# The documented Linux prerequisites, copied from docs/install.md. Recommends +# are left enabled because the documented command does not disable them. +RUN apt-get update \ + && apt-get install -y python3 procps diffutils findutils util-linux \ + bsdextrautils coreutils curl \ + && rm -rf /var/lib/apt/lists/* + +# docs/install.md: "A Git checkout also requires Git." +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* + +# One unprivileged operator account. UID 1000 matches the mounted source +# checkout's owner, so Git reads it without an ownership override. +ARG MATRIX_USER=tester +RUN if id -u 1000 >/dev/null 2>&1; then userdel -r "$(id -un 1000)" || true; fi \ + && useradd -m -u 1000 -s /bin/bash "${MATRIX_USER}" + +# A provider CLI is a documented requirement ("Claude Code, Codex, or both") +# and the preflight fails without one. docs/install.md does not say how to get +# one; this uses the vendor's own installer. +USER ${MATRIX_USER} +RUN curl -fsSL https://claude.ai/install.sh -o /tmp/claude-install.sh \ + && bash /tmp/claude-install.sh \ + && rm -f /tmp/claude-install.sh \ + && "$HOME/.local/bin/claude" --version +USER root + +# Kept last so editing it never rebuilds the layers above. +COPY cgroup-init.sh /usr/local/sbin/matrix-init + +STOPSIGNAL SIGRTMIN+3 +CMD ["/usr/local/sbin/matrix-init"] diff --git a/tools/install-matrix.d/Dockerfile.dnf b/tools/install-matrix.d/Dockerfile.dnf new file mode 100644 index 0000000..055efeb --- /dev/null +++ b/tools/install-matrix.d/Dockerfile.dnf @@ -0,0 +1,29 @@ +# Matrix image for RPM-family distributions (Fedora, and by extension RHEL +# derivatives). docs/install.md documents prerequisites for Debian and Ubuntu +# only; the dnf list here is the equivalent set the preflight actually needs. +ARG BASE_IMAGE=fedora:41 +FROM ${BASE_IMAGE} + +# Scaffolding, not prerequisites (see Dockerfile.apt). +RUN dnf -y install systemd dbus-daemon && dnf clean all + +# Prerequisite equivalents of the documented Debian/Ubuntu list. +RUN dnf -y install python3 procps-ng diffutils findutils util-linux-core \ + util-linux coreutils curl git && dnf clean all + +ARG MATRIX_USER=tester +RUN if id -u 1000 >/dev/null 2>&1; then userdel -r "$(id -un 1000)" || true; fi \ + && useradd -m -u 1000 -s /bin/bash "${MATRIX_USER}" + +USER ${MATRIX_USER} +RUN curl -fsSL https://claude.ai/install.sh -o /tmp/claude-install.sh \ + && bash /tmp/claude-install.sh \ + && rm -f /tmp/claude-install.sh \ + && "$HOME/.local/bin/claude" --version +USER root + +# Kept last so editing it never rebuilds the layers above. +COPY cgroup-init.sh /usr/local/sbin/matrix-init + +STOPSIGNAL SIGRTMIN+3 +CMD ["/usr/local/sbin/matrix-init"] diff --git a/tools/install-matrix.d/cgroup-init.sh b/tools/install-matrix.d/cgroup-init.sh new file mode 100755 index 0000000..6283cdb --- /dev/null +++ b/tools/install-matrix.d/cgroup-init.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# PID 1 for a matrix container. Docker mounts the container's own cgroup tree +# read-only; systemd needs it writable to create its scopes. With a private +# cgroup namespace this remount exposes the container's own subtree and nothing +# of the host, which is why the container needs CAP_SYS_ADMIN but not +# --privileged and no host bind mounts. +mount -o remount,rw /sys/fs/cgroup 2>/dev/null || + echo 'install-matrix: cgroup remount failed; systemd will not start' >&2 +exec /usr/lib/systemd/systemd diff --git a/tools/install-matrix.d/root-phase.sh b/tools/install-matrix.d/root-phase.sh new file mode 100755 index 0000000..119c688 --- /dev/null +++ b/tools/install-matrix.d/root-phase.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Runs as root inside a matrix container after systemd has started. It creates +# only the state a real machine already has - a lingering login session for the +# operator account - and then hands the documented install to that account. +set -uo pipefail + +user=${MATRIX_USER:-tester} +uid=$(id -u "$user") || exit 1 + +printf '===== STEP container-systemd =====\n' +systemctl is-system-running || true +systemctl --version | head -1 + +# docs/install.md: "Enable this before session-kit services enable, not after." +printf '\n===== STEP linger =====\n' +loginctl enable-linger "$user" +for _ in 1 2 3 4 5 6 7 8 9 10; do + [[ -d /run/user/$uid ]] && break + sleep 1 +done +loginctl show-user "$user" --property=Linger +printf 'MATRIX_RESULT linger %s\n' "$?" + +# su keeps the environment minimal, which is what a fresh SSH login gives. +runuser -u "$user" -- env \ + HOME="/home/$user" \ + XDG_RUNTIME_DIR="/run/user/$uid" \ + TERM=dumb \ + bash /matrix/user-phase.sh +exit "$?" diff --git a/tools/install-matrix.d/user-phase.sh b/tools/install-matrix.d/user-phase.sh new file mode 100755 index 0000000..7b86111 --- /dev/null +++ b/tools/install-matrix.d/user-phase.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# The documented install, run as the unprivileged test user inside a matrix +# container. Every command here is one a reader of docs/install.md would type. +# Emits "MATRIX_RESULT <step> <exit-code>" lines that tools/install-matrix +# collects into the per-distro summary. +set -uo pipefail + +export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)} +export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" + +checkout=$HOME/session-kit +rc=0 + +step() { + printf '\n===== STEP %s =====\n' "$1" +} + +record() { + printf 'MATRIX_RESULT %s %s\n' "$1" "$2" +} + +# A step whose failure is a real install blocker. +run_gate() { + local name=$1 + shift + step "$name" + printf '$ %s\n' "$*" + "$@" + local status=$? + record "$name" "$status" + if ((status != 0)); then + rc=1 + fi + return 0 +} + +# A step recorded for evidence whose exit code is not a pass/fail gate (for +# example the pre-activation doctor, which is documented to fail while the +# user units are installed and not yet enabled). +run_note() { + local name=$1 + shift + step "$name" + printf '$ %s\n' "$*" + "$@" + record "$name" "$?" + return 0 +} + +step environment +id +printf 'bash %s\n' "$BASH_VERSION" +python3 --version +uname -srm +sed -n '1,3p' /etc/os-release +printf 'XDG_RUNTIME_DIR=%s\n' "$XDG_RUNTIME_DIR" + +# A real machine reaches this point with a login session; the container gets +# the same state from lingering, enabled by root before this script runs. +run_gate user-manager systemctl --user show-environment + +# Clone the source under test from the local repository, so the run never +# depends on GitHub being reachable. A clone is a clean Git root, which is what +# the installer's source-provenance check requires. +run_gate clone git clone --no-hardlinks --quiet /src "$checkout" +if [[ -d $checkout/.git ]]; then + printf 'source commit: %s\n' "$(git -C "$checkout" rev-parse HEAD)" + printf 'source branch: %s\n' "$(git -C "$checkout" rev-parse --abbrev-ref HEAD)" +fi + +# shpool is not packaged by any distribution. The static binary at /shpool was +# produced by the container build route in extras/build-static-binary.md and is +# installed exactly where that document puts it. +step shpool-install +mkdir -p "$HOME/.cargo/bin" +install -m 755 /shpool/shpool "$HOME/.cargo/bin/shpool" +shpool version +record shpool-install "$?" + +cd "$checkout" || exit 1 + +run_gate check ./install.sh --check +run_gate install ./install.sh --non-interactive +run_note doctor-before-activation session-kit doctor +run_gate enable-login session-kit enable-login +run_gate services-enable session-kit services enable +run_note services-status session-kit services status +run_gate doctor session-kit doctor + +step doctor-json +session-kit doctor --json >"$HOME/doctor.json" +record doctor-json "$?" +cat "$HOME/doctor.json" + +step unit-state +systemctl --user list-units --no-pager --all \ + 'shpool*' 'session-kit*' 2>&1 || true +loginctl show-user "$(id -un)" --property=Linger 2>&1 || true + +step summary +printf 'MATRIX_OVERALL %s\n' "$rc" +exit "$rc" diff --git a/tools/operator-drill b/tools/operator-drill new file mode 100755 index 0000000..60ee001 --- /dev/null +++ b/tools/operator-drill @@ -0,0 +1,616 @@ +#!/usr/bin/env python3 +"""Drive the launch path the way a person does, against a stub that has no daemon. + +This is the release gate that used to be a person. It builds a sandbox with its +own HOME, its own state directory and a fake `shpool` that implements verbs and +starts nothing, then runs the kit's real `bashrc/shpool.bashrc` inside a pty and +reads what actually happened. + +ISOLATION IS THE POINT, and it is not a mode: it is the only mode. A real +daemon in a review fixture can answer that fixture's client and make its +session names look authoritative; mixing those names with the operator +daemon's process tree can hide or misidentify live sessions. Every path this +drill hands to the kit is asserted to live inside its own sandbox before a +single step runs, and the drill refuses itself otherwise. There is no flag +that points it at the operator's estate. + +The husk rides here as a standing regression. Tab is IFS whitespace, so +`IFS=$'\\t' read` collapses a run of tabs and drops the empty fields between +them. The launch record's fourth field is the launch key, and it is EMPTY for +every `sp new --model` nobody passed `--launch-key` to: the six generation +fields shifted one place left, the cross-check refused a record that was armed +perfectly, and three sessions in one night became shells with no provider. +Step 2 arms that exact shape and fails if the provider does not start. + +Usage: + tools/operator-drill # every step + tools/operator-drill --steps 1,2 # a subset + tools/operator-drill --json # machine-readable summary + tools/operator-drill --release <dir> # drive another release tree +""" + +from __future__ import annotations + +import argparse +import fcntl +import json +import os +import pty +import re +import select +import shutil +import struct +import subprocess +import sys +import tempfile +import termios +import time + +ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07|\x1b[()][AB0]") +REPO = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + +# A drill sandbox always carries this file, and the stub refuses to run against +# a state directory that has no such marker above it. A stub reachable from an +# ordinary PATH would be a worse bug than the one it tests for. +SANDBOX_MARK = ".session-kit-drill-sandbox" +STUB_MARK = "session-kit-drill-stub" + +STEPS = ("1", "2", "3", "4") + + +class IsolationError(Exception): + """The drill would have touched something outside its own sandbox.""" + + +def visible(text: str) -> str: + return ANSI.sub("", text) + + +# The fake `shpool`: verbs only. It never forks a daemon, never opens a socket, +# and refuses the daemon verb out loud so a future step cannot quietly acquire +# one. Its own fence is the sandbox marker: pointed anywhere else, it exits. +SHPOOL_STUB = r"""#!/usr/bin/env bash +# session-kit drill stub, %s. Verbs only: no daemon, no socket. +set -u +state=${DRILL_STUB_STATE:?stub state directory is required} +root=${DRILL_SANDBOX:?stub sandbox root is required} +if [[ ! -e $root/%s || $state != "$root"/* ]]; then + printf 'operator-drill stub: refusing a state directory outside the sandbox\n' >&2 + exit 3 +fi +mkdir -p "$state/sessions" "$state/events" +verb=${1:-} +shift || true +case "$verb" in + daemon) + printf 'operator-drill stub: a drill never starts a daemon\n' >&2 + exit 3 + ;; + attach) + name="" + directory="" + while (($#)); do + case "$1" in + --dir) directory=$2; shift 2 ;; + --background|--force) shift ;; + --cmd) shift 2 ;; + *) name=$1; shift ;; + esac + done + [[ -n $name ]] || exit 2 + printf '%%s\n' "${directory:-$PWD}" > "$state/sessions/$name" + printf 'attach %%s\n' "$name" >> "$state/events/log" + cd -- "${directory:-$PWD}" || exit 1 + export SHPOOL_SESSION_NAME=$name + exec "${DRILL_STUB_SHELL:-/bin/bash}" -i + ;; + list) + printf '{"sessions":[' + separator="" + for path in "$state"/sessions/*; do + [[ -e $path ]] || continue + printf '%%s{"name":"%%s","status":"Disconnected"}' "$separator" "${path##*/}" + separator="," + done + printf ']}\n' + ;; + detach) + printf 'detach %%s\n' "${1:-${SHPOOL_SESSION_NAME:-unknown}}" >> "$state/events/log" + ;; + kill) + for name in "$@"; do + command rm -f -- "$state/sessions/$name" + printf 'kill %%s\n' "$name" >> "$state/events/log" + done + ;; + *) + exit 2 + ;; +esac +""" % (STUB_MARK, SANDBOX_MARK) + + +def write_stub(sandbox: str) -> str: + """Put the stub on a PATH only this run can see.""" + binaries = os.path.join(sandbox, "bin") + os.makedirs(binaries, mode=0o700, exist_ok=True) + path = os.path.join(binaries, "shpool") + with open(path, "w", encoding="utf-8") as handle: + handle.write(SHPOOL_STUB) + os.chmod(path, 0o700) + return binaries + + +def assert_isolated(sandbox: str, environment: dict) -> list: + """Refuse the run unless every path the kit will read is a fixture path. + + Called before the first session shell exists. It reads only what it was + handed, so the tests can drive it with a hostile environment and get the + refusal instead of a daemon. + """ + sandbox = os.path.realpath(sandbox) + home = os.path.realpath(os.path.expanduser("~")) + if not os.path.isdir(sandbox): + raise IsolationError("the sandbox directory does not exist") + if not os.path.exists(os.path.join(sandbox, SANDBOX_MARK)): + raise IsolationError("the sandbox carries no drill marker") + if sandbox == home or sandbox.startswith(home + os.sep): + raise IsolationError("the sandbox is inside the operator's home") + + def inside(value: str) -> bool: + resolved = os.path.realpath(value) + return resolved == sandbox or resolved.startswith(sandbox + os.sep) + + for name in ( + "HOME", + "XDG_STATE_HOME", + "XDG_RUNTIME_DIR", + "SESSION_KIT_STATE_DIR", + "SESSION_KIT_START_DIR", + "SESSION_KIT_JOURNAL_DIR", + "SHPOOL_SOCKET", + "DRILL_STUB_STATE", + ): + value = environment.get(name) + if not value: + raise IsolationError(f"{name} is unset, so the kit would read the real one") + if not inside(value): + raise IsolationError(f"{name} points outside the sandbox: {value}") + + socket = environment["SHPOOL_SOCKET"] + if os.path.exists(socket): + raise IsolationError("the fixture socket path already exists; something is listening") + + stub = environment.get("SESSION_KIT_SHPOOL_CMD", "") + if not stub or not inside(stub): + raise IsolationError("SESSION_KIT_SHPOOL_CMD is not the sandbox stub") + with open(stub, encoding="utf-8") as handle: + if STUB_MARK not in handle.read(4096): + raise IsolationError("the stub file is not the drill's stub") + + resolved = shutil.which("shpool", path=environment.get("PATH", "")) + if not resolved or os.path.realpath(resolved) != os.path.realpath(stub): + raise IsolationError("`shpool` on the drill PATH is not the stub") + + return [ + f"sandbox {sandbox}", + f"stub {stub} (no daemon verb)", + f"socket fixture {socket} (absent)", + "HOME, state, start, journal and runtime dirs all inside the sandbox", + ] + + +# ---------------------------------------------------------------- terminal -- +class Terminal: + """One command on a real pty, driven the way a person drives one.""" + + def __init__(self, argv, cwd=None, env=None, rows=45, columns=140): + self.argv = argv + self.raw = bytearray() + self.pid, self.fd = pty.fork() + if self.pid == 0: # pragma: no cover - the child execs immediately + try: + if cwd: + os.chdir(cwd) + environment = dict(os.environ) + environment["TERM"] = "xterm-256color" + # The drill can run from inside a managed session; a child that + # inherited that name would look like a nested one. + environment.pop("SHPOOL_SESSION_NAME", None) + if env: + environment.update(env) + os.execvpe(argv[0], argv, environment) + finally: + os._exit(127) + # Without a window size the session shell never draws or answers. + fcntl.ioctl( + self.fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, columns, 0, 0) + ) + self.status = None + + def pump(self, seconds: float) -> None: + end = time.monotonic() + seconds + while time.monotonic() < end: + ready, _, _ = select.select([self.fd], [], [], 0.1) + if not ready: + continue + try: + chunk = os.read(self.fd, 65536) + except OSError: + return + if not chunk: + return + self.raw.extend(chunk) + + def screen(self) -> str: + return visible(self.raw.decode("utf-8", "replace")) + + def close(self) -> None: + if self.status is None: + try: + os.kill(self.pid, 9) + os.waitpid(self.pid, 0) + except (ProcessLookupError, ChildProcessError): + pass + try: + os.close(self.fd) + except OSError: + pass + + +# ------------------------------------------------------------------ report -- +class Report: + """PASS, FAIL, SKIP with the evidence each verdict was read from.""" + + def __init__(self, quiet: bool = False) -> None: + self.results = [] + self.quiet = quiet + + def _record(self, verdict: str, step: str, summary: str, evidence) -> None: + entry = { + "verdict": verdict, + "step": step, + "summary": summary, + "evidence": list(evidence or []), + } + self.results.append(entry) + if self.quiet: + return + print(f"{verdict} {step}: {summary}", flush=True) + for line in entry["evidence"]: + print(f" {line}", flush=True) + + def passed(self, step, summary, evidence=None) -> None: + self._record("PASS", step, summary, evidence) + + def failed(self, step, summary, evidence=None) -> None: + self._record("FAIL", step, summary, evidence) + + def skipped(self, step, summary, evidence=None) -> None: + self._record("SKIP", step, summary, evidence) + + def exit_code(self) -> int: + return 1 if any(r["verdict"] == "FAIL" for r in self.results) else 0 + + def payload(self) -> dict: + return {"steps": self.results, "failed": self.exit_code() != 0} + + +# ------------------------------------------------------------------- drill -- +class Sandbox: + """The whole world the drill is allowed to touch.""" + + def __init__(self, release: str) -> None: + self.release = release + self.root = os.path.realpath(tempfile.mkdtemp(prefix="session-kit-drill.")) + open(os.path.join(self.root, SANDBOX_MARK), "w").close() + self.home = os.path.join(self.root, "home") + self.state = os.path.join(self.root, "state") + self.start = os.path.join(self.root, "state", "shpool-start") + self.stub_state = os.path.join(self.root, "stub") + self.runtime = os.path.join(self.root, "run") + self.project = os.path.join(self.root, "project") + for directory in ( + self.home, + self.state, + self.start, + self.stub_state, + self.runtime, + self.project, + ): + os.makedirs(directory, mode=0o700, exist_ok=True) + self.binaries = write_stub(self.root) + self.provider_log = os.path.join(self.root, "provider.log") + self.boot = os.path.join(self.root, "boot-id") + with open(self.boot, "w", encoding="utf-8") as handle: + handle.write("drill-boot\n") + provider = os.path.join(self.binaries, "claude") + with open(provider, "w", encoding="utf-8") as handle: + handle.write( + "#!/usr/bin/env bash\n" + 'printf "PROVIDER-STARTED %s\\n" "$*" >> "$DRILL_PROVIDER_LOG"\n' + 'exec sleep "${DRILL_PROVIDER_SECONDS:-20}"\n' + ) + os.chmod(provider, 0o700) + # The session shell takes the launch path, not the journal wrapper. + open(os.path.join(self.home, ".no_shpool_journal"), "w").close() + with open(os.path.join(self.home, ".bashrc"), "w", encoding="utf-8") as handle: + handle.write(f"source {self.release}/bashrc/shpool.bashrc\n") + self.name = "s20260101-000000-424242" + + @property + def environment(self) -> dict: + stub = os.path.join(self.binaries, "shpool") + return { + "HOME": self.home, + "XDG_STATE_HOME": self.state, + "XDG_RUNTIME_DIR": self.runtime, + "SESSION_KIT_STATE_DIR": self.state, + "SESSION_KIT_START_DIR": self.start, + "SESSION_KIT_JOURNAL_DIR": os.path.join(self.state, "shpool-journal"), + "SESSION_KIT_BOOT_ID_FILE": self.boot, + "SESSION_KIT_INVENTORY_CORE": f"{self.release}/lib/session_inventory.py", + "SESSION_KIT_RELEASE_DIR": self.release, + "SESSION_KIT_SHPOOL_CMD": stub, + "SHPOOL_SOCKET": os.path.join(self.stub_state, "shpool.socket"), + "DRILL_SANDBOX": self.root, + "DRILL_STUB_STATE": self.stub_state, + "DRILL_PROVIDER_LOG": self.provider_log, + "PATH": self.binaries + ":" + os.environ.get("PATH", ""), + } + + def remove(self) -> None: + shutil.rmtree(self.root, ignore_errors=True) + + # ------------------------------------------------------------ records -- + def arm(self, boot: str = "drill-boot", model: str | None = None) -> str: + """Write the start record and return the inner script that arms the rest. + + The sidecars carry the running shell's own generation, so they can only + be written from inside the session shell, exactly as `sp` writes them + from the launcher it just spawned. + """ + start = os.path.join(self.start, self.name) + with open(start, "w", encoding="utf-8") as handle: + handle.write(f"claude\t{self.project}\t\tnew\n") + os.chmod(start, 0o600) + for suffix in (".expected", ".launch", ".account"): + try: + os.unlink(start + suffix) + except FileNotFoundError: + pass + inner = ( + 'shell_start=$(awk "{print \\$22}" /proc/$$/stat); ' + 'daemon_start=$(awk "{print \\$22}" /proc/$PPID/stat); ' + f'printf "claude\\t{self.project}\\t{boot}\\t1\\t%s\\t%s\\t%s\\t%s\\t\\tnew\\n" ' + f'"$$" "$shell_start" "$PPID" "$daemon_start" > "{start}.expected"; ' + f'chmod 600 "{start}.expected"; ' + ) + if model: + # Armed the way `sp new --model` arms it: the launch key is EMPTY. + # This is the husk's exact shape. + inner += ( + f'printf "claude\\t{self.project}\\t{model}\\t\\t{boot}\\t1\\t%s\\t%s\\t%s\\t%s\\n" ' + f'"$$" "$shell_start" "$PPID" "$daemon_start" > "{start}.launch"; ' + f'chmod 600 "{start}.launch"; ' + ) + return inner + f"source {self.release}/bashrc/shpool.bashrc" + + def provider_argv(self) -> str: + try: + with open(self.provider_log, encoding="utf-8") as handle: + return handle.read().strip() + except OSError: + return "" + + def forget_provider(self) -> None: + try: + os.truncate(self.provider_log, 0) + except OSError: + pass + + def run_session(self, inner: str, timeout: float = 45, stop_text: str = "") -> tuple: + """One session shell under the stub, with its records already armed. + + Waiting stops the moment the provider starts, or the moment the screen + says the thing the step was watching for. A step that expects a + refusal should not pay a full timeout to read one. + """ + environment = self.environment + environment["SHPOOL_SESSION_NAME"] = self.name + environment["SHPOOL_JOURNAL"] = "disabled" + terminal = Terminal( + ["bash", "--noprofile", "--norc", "-ic", inner, "drill-inner"], + cwd=self.project, + env=environment, + ) + end = time.monotonic() + timeout + started = False + while time.monotonic() < end: + terminal.pump(0.3) + if "PROVIDER-STARTED" in self.provider_argv(): + started = True + break + if stop_text and stop_text in terminal.screen(): + break + return terminal, started + + +# ------------------------------------------------------------------- steps -- +def step_launch(report: Report, sandbox: Sandbox) -> None: + sandbox.forget_provider() + terminal, started = sandbox.run_session( + sandbox.arm(), stop_text="provider not started" + ) + evidence = [f"provider argv: {sandbox.provider_argv()[:120]}"] + screen = terminal.screen()[-300:] + terminal.close() + if started: + report.passed("1 launch", "the kit's launch path started a provider under the stub", evidence) + else: + report.failed( + "1 launch", + "the launch path started nothing", + evidence + [f"screen: {screen!r}"], + ) + + +def step_model_launch(report: Report, sandbox: Sandbox) -> None: + sandbox.forget_provider() + # A failing step reads its refusal off the screen rather than paying the + # whole timeout: the husk announces itself as a retained launch record. + terminal, started = sandbox.run_session( + sandbox.arm(model="claude-opus-5"), stop_text="provider not started" + ) + argv = sandbox.provider_argv() + evidence = [ + "records armed as `sp new --model` arms them: launch key EMPTY", + f"provider argv: {argv[:120]}", + ] + screen = terminal.screen()[-300:] + terminal.close() + if started and "--model claude-opus-5" in argv: + report.passed( + "2 model launch", + "a model launch with no launch key starts its provider", + evidence, + ) + else: + report.failed( + "2 model launch", + "the tab-collapse husk is back: a model launch started nothing", + evidence + [f"screen: {screen!r}"], + ) + + +def step_stale_record(report: Report, sandbox: Sandbox) -> None: + """A record from another boot must start nothing, and say why.""" + sandbox.forget_provider() + terminal, started = sandbox.run_session( + sandbox.arm(boot="a-previous-boot"), + timeout=20, + stop_text="provider not started", + ) + screen = terminal.screen() + terminal.close() + said_so = "stale or mismatched launch record" in screen + evidence = [ + "the expected record carries a boot id from another boot", + f"provider argv: {sandbox.provider_argv()[:80] or '(none)'}", + f"screen: {screen[-200:]!r}", + ] + if not started and said_so: + report.passed( + "3 stale record", + "a launch record from another boot starts nothing and says so", + evidence, + ) + else: + report.failed( + "3 stale record", + "a stale launch record was acted on, or its refusal was silent", + evidence, + ) + + +def step_no_daemon(report: Report, sandbox: Sandbox) -> None: + """Nothing in the run acquired a daemon or a socket.""" + environment = sandbox.environment + refused = subprocess.run( + [environment["SESSION_KIT_SHPOOL_CMD"], "daemon"], + env={**os.environ, **environment}, + capture_output=True, + text=True, + ) + log = os.path.join(sandbox.stub_state, "events", "log") + events = "" + if os.path.exists(log): + with open(log, encoding="utf-8") as handle: + events = handle.read().strip() + socket_exists = os.path.exists(environment["SHPOOL_SOCKET"]) + evidence = [ + f"stub daemon verb: exit {refused.returncode} · {refused.stderr.strip()}", + f"stub events: {events or '(none)'}", + f"fixture socket present: {socket_exists}", + ] + if refused.returncode == 3 and not socket_exists and "daemon" not in events: + report.passed("4 no daemon", "the run started no daemon and opened no socket", evidence) + else: + report.failed("4 no daemon", "the drill reached for a daemon", evidence) + + +RUNNERS = { + "1": step_launch, + "2": step_model_launch, + "3": step_stale_record, + "4": step_no_daemon, +} + + +def run(report: Report, release: str, wanted) -> int: + if not os.path.exists("/proc/self/stat"): + report.skipped( + "0 isolation", + "the drill reads process generations from /proc; this platform has none", + ["run it on Linux, where the launch path's generation check lives"], + ) + return report.exit_code() + sandbox = Sandbox(release) + try: + try: + evidence = assert_isolated(sandbox.root, sandbox.environment) + except IsolationError as error: + report.failed( + "0 isolation", + f"the drill refused itself: {error}", + ["no session shell was started, and no step ran"], + ) + return report.exit_code() + report.passed( + "0 isolation", + "every path the kit will read is a fixture path", + evidence, + ) + for step in STEPS: + if step in wanted: + RUNNERS[step](report, sandbox) + finally: + sandbox.remove() + return report.exit_code() + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--steps", default=",".join(STEPS)) + parser.add_argument( + "--release", + default=REPO, + help="the release tree to drive; defaults to the tree this file lives in", + ) + parser.add_argument("--json", action="store_true", help="print a machine-readable summary") + args = parser.parse_args(argv) + + wanted = {piece.strip() for piece in args.steps.split(",") if piece.strip()} + unknown = sorted(wanted - set(STEPS)) + if unknown: + print( + f"operator-drill: no step {', '.join(unknown)}. Steps 1-{len(STEPS)} work.", + file=sys.stderr, + ) + return 2 + release = os.path.realpath(args.release) + if not os.path.isfile(os.path.join(release, "bashrc", "shpool.bashrc")): + print(f"operator-drill: {release} is not a release tree", file=sys.stderr) + return 2 + + report = Report(quiet=args.json) + if not args.json: + print(f"operator drill · release {release} · isolated", flush=True) + code = run(report, release, wanted) + if args.json: + print(json.dumps(report.payload(), indent=2)) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/public-scan b/tools/public-scan index 292b853..a603e0d 100755 --- a/tools/public-scan +++ b/tools/public-scan @@ -31,6 +31,8 @@ SECRETS = { } # Repository-specific words are stored only as one-way digests. The scanner # remains safe to publish while still rejecting those words in release trees. +# Personal names of the maintainer or operators belong here too: shipped files +# name roles ("the operator", "the maintainer"), never people. PRIVATE_TOKEN_DIGESTS = frozenset( { "bcee5f794de98e5f990f21d817f6340df82a99bdcf3f7574f1adbf81be1741d2", @@ -38,10 +40,149 @@ PRIVATE_TOKEN_DIGESTS = frozenset( "9856a5635d21fe7ec66370d38d9326517935b01abae243723ec0d3b1ce610c63", "9fcc02aea1ee9a3091972d30ea9054e0d593501ccb26d21c6c0332121dfa888c", "72615b744fff4e9baeddb92066417fd08f60c8ebbdab199442069dfcd3c571ee", + # Private account aliases and handles. NOTE: the public GitHub + # org slug is deliberately NOT listed - it is meant to ship. + "e407d44798fe8871fc0e252120f886ccc8eed386ff61ac662a7fafe65bf625ff", + "dc0215a3b20331be68e97be4071b9f8612958aaeb9ad61a9111b7cd2a8810e1d", + # Personal-name tokens (see the maintainer rule in CONTRIBUTING.md). + "ec4f2dbb3b140095550c9afbbb69b5d6fd9e814b9da82fad0b34e9fcbe56f1cb", + "0b135e8de8f42b4baddb5e580400b8c2dc56f9204758314c2be7019aa9d8d98a", # Neutral test-only marker. "9244bfa2efb5ae19b6f84f62dc79582ad52a25a14f7e904e9c39e739360a7cff", } ) +# Every digest a path has ever legitimately shipped with: the working tree +# must match one, and superseded versions stay reachable in public history. +APPROVED_BINARY_SHA256 = { + "docs/assets/session-kit-picker.png": ( + # v0.4.0 render, superseded 2026-08-17. + "6885c0c477fac8b1811c5a29f1f70063b5f6333cfb3c8d3c96b62dc82c0d7d0b", + # 2026-08-17 render: live states, accounts, and models in the demo. + "2e1e5693c15bea25fa27ddd0b07fe4329cabb75c2831a21d38cea36216cdffe2", + # 2026-08-17 second render: every run pinned to the character grid so + # bold glyphs cannot drift the columns. + "5cb56b210644526e5cc54534248a922ae2e2dca36c0ef717c211caa9f5a83ca8", + ), + # Shipped publicly with v0.4.0, later trimmed from the tree; the blob + # stays reachable in public history. Reviewed 2026-08-17: a dashboard + # screenshot, nothing private. + # The Reddit launch hero: a designed banner around a picker mock. Added + # 2026-08-17 as the README's lead image. + "docs/assets/session-kit-hero.png": ( + "6323318e29c1d7ee9c5ab2d1e766107fa8303b742ebfa86d6621b846ecf14841", + ), + "docs/assets/session-kit-dashboard.png": ( + "b14518a8a78aa077be5ee7d21bdc76929b43e1f035420a1519df8965cf94e3db", + ), + # --------------------------------------------------------------- history + # README redesign, 2026-08-17. Every terminal region in these four is the + # deterministic tools/render-readme-picker output, whose demo data is + # neutral by construction: aliases "personal" and "work", demo session + # ids, and a "session kit" title bar rather than a real user@host. An + # earlier cut of this artwork composited a live capture and carried a + # real account alias, host name, and personal name; it was rejected and + # never entered the tree. Reviewed at 3x zoom before approval: nothing + # private in any of the four. + "docs/assets/readme/hero.png": ( + "e5a5031889808fce8269ac21e254e7c92003bb4951075ae6ecac602bf0869a2b", + ), + # Callout leader lines stop at the capture edge; an earlier cut drew them + # across it and struck out the labels they pointed at. + "docs/assets/readme/picker-anatomy.png": ( + "a25a27a9b35b9d7c2d130f1fc5f52f3afe487024409229e93d61e42c7756a59f", + # 2026-08-17 recomposition: leader lines dropped entirely for a labelled + # row strip and four panels, so nothing has to reach into the capture at + # all. The picker render is pasted unscaled-in-aspect and verified + # pixel-identical to tools/render-readme-picker output before approval. + "2819ce4a9fc8a8d6ecace378d9f7dbbc24dfb3d3074118f6a836cf3d174f95b6", + # 2026-08-17, third cut: an annotated picker rather than an explainer. + # The capture is pasted at native size and is byte-identical to the + # render; every label sits outside it and its leader stops short of the + # border, so the crop of this figure and the picker render compare + # equal pixel for pixel. Includes the prompt line the renderer used to + # trim, and the ↵ and ❯ glyphs now that the host has a font with them. + "e637186dfc12ad5e631074fdc4b6edb6fc096d90d5abad151287dc16db58dfc6", + # 2026-08-17, final: no labels at all. The picker page speaks for + # itself, so the figure is the render and nothing else. + "929e3289e14ae3313d3e2ce39c5155b5a782a947bfc0103af2ad641f4e098ef8", + # 2026-08-17: the picker as it actually appears on screen, no window + # chrome, no traffic lights, terminal padding only. The framed version + # looked like a product shot of a terminal rather than the terminal. + "c3e0f3c7a3feeab6a3deb59accb3f32bea2666abecf683a94fb47d7c06d66882", + # 2026-08-17 final: a terminal window with the picker in it, frame, + # neutral ~ title, and a cursor on the prompt so the command line + # reads as one. Matches what the picker looks like on screen. + "3f437edeb57aa016fbd465f3b146b4e9ec53482174e065e352cbf2d1d1f747d8", + # 2026-08-17, re-rendered for v0.4.2 after pinned projects stepped + # out of the release: neither the command bar nor the key panel + # shows a `p` the shipped version does not have. Reviewed before + # approval, the same neutral demo data (personal/work aliases, + # demo session numbers, a "session kit" title bar), nothing private. + "11967030cc802fac34a0288e630f13bc14d6ff0780ee7ce5654339dae7956de1", + ), + # The bare picker render, identical to the docs/assets/session-kit-picker + # blob above because both come from the same deterministic tool. + "docs/assets/readme/picker-home.png": ( + "5cb56b210644526e5cc54534248a922ae2e2dca36c0ef717c211caa9f5a83ca8", + # 2026-08-17 re-render: keeps the prompt line, and the ↵ and ❯ glyphs + # resolve now that the render host has a font carrying them. Both used + # to reach the README as missing-glyph boxes. + "f41b7567f281bf4d0548769442673c4e2908ba762ae7edd92f5b840a895d9c95", + # 2026-08-17: the picker as it actually appears on screen, no window + # chrome, no traffic lights, terminal padding only. The framed version + # looked like a product shot of a terminal rather than the terminal. + "c3e0f3c7a3feeab6a3deb59accb3f32bea2666abecf683a94fb47d7c06d66882", + # 2026-08-17 final: a terminal window with the picker in it, frame, + # neutral ~ title, and a cursor on the prompt so the command line + # reads as one. Matches what the picker looks like on screen. + "3f437edeb57aa016fbd465f3b146b4e9ec53482174e065e352cbf2d1d1f747d8", + ), + # ---------------------------------------------------------------- current + # The README's four figures, 2026-08-18. Every terminal region in them is + # tools/render-readme-picker output, captured from the real picker through + # the login fixture, whose demo data is neutral by construction: aliases + # "personal" and "work", demo session ids, and a "session kit" title bar + # rather than a real user@host. The render is byte-deterministic, two runs + # of the tools produce identical files, so a digest here changes only when + # the artwork or the picker does. Reviewed at 3x zoom: nothing private. + "docs/assets/readme/picker.png": ( + "ecd92cfbb801252cc6b6a8036484e617bbb6f4eb9c25d48ceee035ef2bb1cca8", + ), + # The needs-you screen behind `a`, captured in the same picker run as the + # list above so the two cannot describe different inventories. + "docs/assets/readme/needs-you.png": ( + "3df51a0db5f8fb1f095b5e21e788f299ac320d95ab7aab4b54894fdb75286afe", + ), + # Laid-out diagrams with no capture in them; text only, all of it written + # for the page. + "docs/assets/readme/how-it-works.png": ( + # Advertised shell sessions as a third kind of managed session. + "05e0ec07f0c0ebbfeac344811aad70ad4ad04ca7fe19f95fb8fb28da97b7b009", + # 2026-08-18 render, reviewed: panel three now reads "Claude Code or + # Codex runs inside it", matching a page that no longer offers shells + # as a session type. Panel one still says "an ordinary shell", which is + # the person's own terminal and a different thing entirely. Text only, + # no capture, nothing private; layout unchanged. + "09531a327b2f22f2d29f5db9e408cf0bb4433a7baa702fe916d9812c39a9a5a8", + ), + "docs/assets/readme/safety-model.png": ( + "872acd2a47dfe8a238eddf389c3efcab50d970a2c5496084f6b6477264ab3293", + ), + # GitHub social preview: 1280x640, uploaded through repository settings, + # versioned here so the share artwork travels with the rest. + "docs/assets/readme/social-preview.png": ( + # 2026-08-17 card, superseded: it led with the old positioning. + "562bf99a1b7728b670092b7a6d0bd26016d28c4ecba07f3bc8ea18d0538e3349", + # 2026-08-18: tools/render-readme-social, headline plus the real + # needs-you screen. + "d16d0c508e109e5a7a6efc5d186e7039696e2c2f0d5495acb76317fc331f217e", + # 2026-08-18 re-render, reviewed: the supported line no longer offers + # "shells". This card is what every shared link shows, so it was the + # last place still advertising a session type the page has dropped. + # Same layout, same captured needs-you screen, nothing private. + "b89ffe3efb183e1fcb71fe70f77c96c9a3a112b0a940c742ecb8818cced2941a", + ), +} def scan_text( @@ -61,13 +202,38 @@ def scan_text( ] if private_markers: for token in re.findall(r"[A-Za-z0-9_]+", text.casefold()): - digest = hashlib.sha256(token.encode("utf-8")).hexdigest() - if digest in PRIVATE_TOKEN_DIGESTS: - failures.append(f"{label}: private marker") + # A snake_case identifier must not smuggle a private word past the + # check, so every underscore-separated part is tested as well as + # the whole token. + parts = {token, *token.split("_")} + if any( + hashlib.sha256(part.encode("utf-8")).hexdigest() + in PRIVATE_TOKEN_DIGESTS + for part in parts + if part + ): + failures.append(f"{label}{MARKER_FAILURE}") break return failures +def scan_payload( + label: str, + path: str, + payload: bytes, + patterns: dict[str, re.Pattern[str]], + *, + private_markers: bool, +) -> list[str]: + expected_digests = APPROVED_BINARY_SHA256.get(path) + if expected_digests is not None: + actual_digest = hashlib.sha256(payload).hexdigest() + if actual_digest not in expected_digests: + return [f"{label}: approved binary digest mismatch"] + return [] + return scan_text(label, payload, patterns, private_markers=private_markers) + + def scan_tree( root: Path, patterns: dict[str, re.Pattern[str]], @@ -89,8 +255,9 @@ def scan_tree( if path.is_dir(): continue failures.extend( - scan_text( + scan_payload( str(relative), + relative.as_posix(), path.read_bytes(), patterns, private_markers=private_markers, @@ -99,6 +266,49 @@ def scan_tree( return failures +MARKER_FAILURE = ": private marker" +BASELINE_ENTRY = re.compile(r"^([0-9a-f]{40})(?:\s+(\S.*))?$") + + +class BaselineError(Exception): + """The grandfathered-history list could not be read exactly.""" + + +def load_baseline(path: Path) -> frozenset[str]: + """Read the reviewed history objects the private-marker rule may skip. + + An entry is a blob id or a commit id; both are 40 hex digits and both are + a digest of the thing they exempt, so neither can be stretched to cover + anything but the bytes that were reviewed. + + Every failure mode here is fatal on purpose. A baseline that silently + parses to nothing would turn the history scan back into a pass with no + evidence behind it, which is the one outcome a fail-closed check must + never produce. + """ + try: + payload = path.read_text(encoding="utf-8") + except OSError as exc: + raise BaselineError(f"baseline is unreadable: {exc}") from exc + objects: set[str] = set() + for number, line in enumerate(payload.splitlines(), 1): + entry = line.split("#", 1)[0].strip() + if not entry: + continue + match = BASELINE_ENTRY.fullmatch(entry) + if match is None: + raise BaselineError(f"malformed baseline line {number}: {line!r}") + object_id = match.group(1) + if object_id in objects: + raise BaselineError( + f"duplicate baseline object on line {number}: {object_id}" + ) + objects.add(object_id) + if not objects: + raise BaselineError("baseline lists no objects") + return frozenset(objects) + + def git(root: Path, *args: str, text: bool = True) -> subprocess.CompletedProcess: return subprocess.run( ["git", "-C", str(root), *args], @@ -114,6 +324,8 @@ def scan_history( patterns: dict[str, re.Pattern[str]], *, private_markers: bool, + baseline: frozenset[str] = frozenset(), + applied: set[str] | None = None, ) -> list[str]: try: rows = git(root, "rev-list", "--objects", "--all").stdout.splitlines() @@ -138,16 +350,86 @@ def scan_history( failures.append(f"{label}: file exceeds scan limit") continue payload = git(root, "cat-file", "blob", object_id, text=False).stdout - if b"\0" in payload: + found = scan_payload( + label, + path, + payload, + patterns, + private_markers=private_markers, + ) + if object_id in baseline: + # A baselined blob is exempt from the private-marker rule and from + # nothing else. Secrets still fail, so listing a blob can never be + # a way to publish a leaked credential: the exemption is keyed to + # the exact content, and edited content is a different object. + excused = [item for item in found if item.endswith(MARKER_FAILURE)] + if excused and applied is not None: + applied.add(object_id) + found = [item for item in found if not item.endswith(MARKER_FAILURE)] + failures.extend(found) + failures.extend( + scan_commit_messages( + root, + patterns, + private_markers=private_markers, + baseline=baseline, + applied=applied, + ) + ) + return failures + + +def scan_commit_messages( + root: Path, + patterns: dict[str, re.Pattern[str]], + *, + private_markers: bool, + baseline: frozenset[str] = frozenset(), + applied: set[str] | None = None, +) -> list[str]: + """A commit message is published text, and it was not being read. + + The object walk above opens blobs and nothing else, so every rule this + scanner enforces stopped at the file boundary. A name, a token, or a + private path written into a commit message shipped with nothing here + objecting, even where the same word in a tracked file would have failed. + + A message cannot be corrected without rewriting history, and rewriting a + published history breaks every provenance chain that points into it. So + this is a gate on what is about to be published rather than a cleanup of + what already was: reviewed messages that predate the gate are baselined by + commit id, exactly as reviewed blobs are. + """ + # Record and field separators, not NUL: scan_text treats a NUL byte as + # proof of a binary file and would refuse every message it was handed. + try: + raw = git(root, "log", "--format=%H%x1f%B%x1e", "--all").stdout + except subprocess.CalledProcessError as exc: + detail = exc.stderr.strip() or "not a Git repository" + return [f"Git history unavailable: {detail}"] + + failures: list[str] = [] + for record in raw.split("\x1e"): + record = record.strip("\n") + if not record: continue - failures.extend( - scan_text( - label, - payload, - patterns, - private_markers=private_markers, - ) + commit, _, message = record.partition("\x1f") + found = scan_text( + f"commit {commit[:12]} message", + message.encode("utf-8"), + patterns, + private_markers=private_markers, ) + if commit in baseline: + # Same bargain the blob baseline strikes: exempt from the + # private-marker rule and from nothing else. A credential or key + # in a listed message still fails, and the exemption is keyed to + # the commit id, which no later message can inherit. + excused = [item for item in found if item.endswith(MARKER_FAILURE)] + if excused and applied is not None: + applied.add(commit) + found = [item for item in found if not item.endswith(MARKER_FAILURE)] + failures.extend(found) return failures @@ -157,29 +439,49 @@ def main() -> int: parser.add_argument( "--git-history", action="store_true", - help="scan every blob reachable from local Git refs", + help="scan every blob and commit message reachable from local Git refs", ) parser.add_argument( "--private-markers", action="store_true", help="also reject repository-specific private markers", ) + parser.add_argument( + "--baseline", + type=Path, + help=( + "file listing reviewed blobs and commit messages that predate a " + "private marker; exempts those exact objects from the " + "private-marker rule only" + ), + ) args = parser.parse_args() root = args.root.resolve() if not root.is_dir(): parser.error("root must be a directory") + baseline: frozenset[str] = frozenset() + if args.baseline is not None: + if not args.git_history: + parser.error("--baseline applies to --git-history scans") + try: + baseline = load_baseline(args.baseline) + except BaselineError as exc: + parser.error(str(exc)) failures = scan_tree( root, SECRETS, private_markers=args.private_markers, ) + applied: set[str] = set() if args.git_history: failures.extend( scan_history( root, SECRETS, private_markers=args.private_markers, + baseline=baseline, + applied=applied, ) ) failures = sorted(set(failures)) @@ -189,6 +491,12 @@ def main() -> int: print(f" {failure}") return 1 scope = "tree and Git history" if args.git_history else "tree" + if baseline: + # Say what the baseline actually did here, not how long the list is. + # A gap between the two is how a stale entry becomes visible. + scope += ( + f", {len(applied)} of {len(baseline)} baselined history object(s) excused" + ) print(f"public scan passed ({scope})") return 0 diff --git a/tools/public-scan-history-baseline b/tools/public-scan-history-baseline new file mode 100644 index 0000000..8b58fb9 --- /dev/null +++ b/tools/public-scan-history-baseline @@ -0,0 +1,86 @@ +# Reviewed history objects that predate the private-marker rule. +# +# What this is +# tools/public-scan --git-history --private-markers rejects any blob or +# commit message holding a repository-private word. The rule was added after +# those words had already been published, so it also condemns commits that +# shipped before it existed. The published history cannot be rewritten: the +# repository is public, clones and forks already carry these objects, release +# provenance is anchored to these exact commit ids, and rewriting would break +# every reference to them while removing nothing from anyone's copy. Each +# object below was read, classified, and accepted as history that stays. +# +# What it is not +# An object listed here is exempt from the private-marker rule and from +# nothing else. Credential and key patterns still fail for every object, +# listed or not. The exemption is keyed to the exact object id, and an object +# id is a digest of the thing it names, so it cannot be stretched to cover +# anything but what was reviewed. Editing a listed file, or writing a new +# message, produces a different id that fails again. +# +# What was reviewed (2026-08-11) +# 33 blobs a maintainer's personal name, written before shipped files were +# required to name roles rather than people. The working tree was +# cleaned; these are the pre-cleanup copies. +# 4 blobs a private word appearing only inside a longer underscore-joined +# identifier, which the rule tests part by part. +# +# What was reviewed (2026-08-18) +# 1 commit a maintainer's personal name in the message body, describing who +# an upgrade was certified against. Messages were not scanned until +# now, so this one predates its gate exactly as the blobs above do. +# Six release tags name this commit and their provenance files bind +# it by id, so a rewrite would invalidate all six. +# Nothing in this list holds a credential, key, token, or host secret. +# +# The rule for changing this file +# Additive only, and only after review. Append an entry when something already +# published is confirmed to hold nothing worse than the classes above, and say +# so in the commit. Never add an entry to make a new failure go away: a failure +# on an unpublished blob means the working tree still has to be fixed, and a +# failure on an unpushed commit means the message has to be rewritten before it +# leaves this machine. Removing an entry is safe at any time and only ever +# makes the scan stricter. +# +# Format: <40-character blob or commit id> <where it was found> + +05f1bd57302b0860397688195407f8b0b11fad9d bashrc/shpool.bashrc +51a70d2e38050dd8681844d9562beb848368cd6c bashrc/shpool.bashrc +6d80b18e5e6946cdf0cc0d85891e5ffa2751b7b9 bashrc/shpool.bashrc +da83b3cdfec89ae9585ad977e2756411ff303253 bashrc/shpool.bashrc +dbae8f92dde394d60085694985baf4909fec4af7 bashrc/shpool.bashrc +25a9dc712a6a174bb88cdf10c427a063d5b635f5 bin/session_kit_common +2f7f50f4a7947c0a088ab87b86628698892ac422 bin/session_kit_common +3a195f5604f26328907c470ba0d6d1b1f1c09ed6 bin/session_kit_common +560cb67ded811ccd867a4fb0d3ab2c3f8c7bc381 bin/session_kit_common +cb23301b122a7c30d25f7f5ed5df18d65be836da bin/session_kit_common +cb7e063333e2e1039b44a8cc4b952ef370c06dd0 bin/session_kit_common +e5e496fd61cd3bd5ab5fdd75a016bc4b8d813f39 bin/session_kit_common +376e4030962e68cf4d0c0c25d56791a67e09ec40 config/claude/statusline.sh +11f1616a89daa4f48a63609fe54ff315188924f5 lib/session_inventory.py +442583cb3cfad5843d18123780a731d64074b7e7 lib/session_inventory.py +6fb74a3dc8b19d2dcbb8dfe4c1885b2c48340431 lib/session_inventory.py +74224027810d01702750fc8cc25efa5d5819c563 lib/session_inventory.py +a45221591cc16ac2c56302a31620c75ad75ccc9c lib/session_inventory.py +a8832a4ab160a3cdfbbf3e125742ef581e6e3976 lib/session_inventory.py +b61a8c24b5d1a39122fc5fc8e2882496a0926de6 lib/session_inventory.py +b97ffc53bee08c753397c304adcb567101ed67b7 lib/session_inventory.py +d18a280938d938a0de62f0a6fbda1bdc82cd2b9f lib/session_inventory.py +d3fe2f2b103a7eb637e37bbd6a037053e118f942 lib/sessionkit_inventory/terminal.py +5d2dd2cbed4e1c01b6bec688bf353eff2d5ab52a tests/test_commands.py +bcb9a89458b56b34bf22efd4867a804cd4925e9b tests/test_commands.py +c84aaef09fabe0c24d4ad47e83e075cdcf9055d3 tests/test_commands.py +d58ebbfe24f6e712512814a28c03ef68841d3ca3 tests/test_commands.py +e8d1702726252b14a4fd2223c132162600cc0550 tests/test_commands.py +49785d456e41cf00205d756241200e3e0e41f349 tests/test_inventory.py +513516d56508d8b638c98c5ab7ddcaa372979ad4 tests/test_inventory.py +927d4efa6fbd9e77dd625a024eaf2d69bad16787 tests/test_inventory.py +ae57de10c78b00429926cded071942a4b5765cdc tests/test_inventory.py +0b11f8c44064679f4637087b67e56036ca59d04e tests/test_provider_title_push.py +17be116429f9364bb8c2edf2ec6fa57df82b3475 tests/test_provider_title_push.py +85a068d5f516125b31fb9c3e3b627e27787c879c tests/test_provider_title_push.py +ba79a2d4ee38274299ba8d7255787661dd2f9287 tests/test_provider_title_push.py +ef52617b1295576a54da447ffa570f3bb9db01fa tests/test_provider_title_push.py + +# Commit messages +8c90b75dd20d9522d1b312b4baf222079c3c3832 message of v0.2.0..v0.4.2 diff --git a/tools/readme_art_lib.py b/tools/readme_art_lib.py new file mode 100644 index 0000000..1797aaa --- /dev/null +++ b/tools/readme_art_lib.py @@ -0,0 +1,356 @@ +"""Shared rendering for the README's designed figures. + +Every figure here is laid out by a real browser rather than by hand-placed +coordinates. The previous SVG figures positioned each line of text at a fixed +x/y in a font (Inter) that almost no reader has installed, so the layout only +held while the fallback font happened to be narrow enough; three lines in the +safety figure crossed their own card borders. Letting the browser size boxes to +their content removes that entire class of bug, and `assert_nothing_overflows` +fails the render if any element ever exceeds its box again. + +The font is embedded from disk, so a render does not depend on a webfont host +being reachable and produces the same bytes every time. +""" + +from __future__ import annotations + +import base64 +from pathlib import Path +import sys + +REPO = Path(__file__).resolve().parents[1] +ASSETS = REPO / "docs" / "assets" / "readme" + +# Chromium ships out of band from the Python package, so the revision the +# package expects and the revision on disk drift. Find whatever is here. +CHROMIUM_CANDIDATES = ( + "chromium/chrome-linux/chrome", + "chromium_headless_shell/chrome-linux/headless_shell", +) + +INTER = Path.home() / ".local/share/fonts/InterVariable.ttf" +MONO = Path("/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf") +MONO_FALLBACKS = ( + MONO, + Path.home() / ".local/share/fonts/DejaVuSansMono.ttf", + Path("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"), +) + +# The picker's own palette, so a figure never disagrees with the screenshots. +INK = { + "bg": "#0d1117", + "panel": "#161b22", + "raised": "#21262d", + "line": "#30363d", + "text": "#f0f6fc", + "dim": "#8b949e", + "green": "#9ece6a", + "cyan": "#2ac3de", + "amber": "#e0af68", + "purple": "#bb9af7", + "red": "#f7768e", +} + + +def chromium_path() -> Path | None: + """The chromium binary Playwright downloaded, whatever revision it is.""" + + root = Path.home() / ".cache" / "ms-playwright" + if not root.is_dir(): + return None + for entry in sorted(root.iterdir(), reverse=True): + for suffix in CHROMIUM_CANDIDATES: + stem = suffix.split("/", 1)[0] + if not entry.name.startswith(stem): + continue + candidate = entry / suffix.split("/", 1)[1] + if candidate.is_file(): + return candidate + return None + + +def _mono_font() -> Path | None: + for candidate in MONO_FALLBACKS: + if candidate.is_file(): + return candidate + return None + + +def _face(path: Path, family: str, extra: str = "") -> str: + payload = base64.b64encode(path.read_bytes()).decode("ascii") + return ( + f"@font-face{{font-family:'{family}';{extra}" + f"src:url(data:font/ttf;base64,{payload}) format('truetype');}}" + ) + + +def font_faces() -> str: + """Embed the fonts so a figure never depends on a network font host. + + A missing font is not fatal, the browser falls back and the figure still + lays out, but it does change every pixel, which the approved-digest gate + then rejects with a message about digests rather than about fonts. Say + which font is missing while that is still the useful thing to know. + """ + + faces = [] + missing = [] + if INTER.is_file(): + faces.append(_face(INTER, "Inter", "font-weight:100 900;")) + else: + missing.append(str(INTER)) + mono = _mono_font() + if mono is not None: + faces.append(_face(mono, "KitMono")) + else: + missing.append(" or ".join(str(path) for path in MONO_FALLBACKS)) + if missing: + print( + "warning: rendering without an embedded font, so these bytes will " + "not match the approved digests. Missing: " + "; ".join(missing), + file=sys.stderr, + ) + return "".join(faces) + + +BASE_CSS = """ +*{{box-sizing:border-box;margin:0;padding:0}} +body{{ + font-family:Inter,system-ui,sans-serif; + background:{bg};color:{text}; + -webkit-font-smoothing:antialiased; + font-feature-settings:'cv05' 1,'ss03' 1; +}} +.mono{{font-family:KitMono,ui-monospace,monospace}} +""" + + +def page(body: str, css: str, *, width: int, height: int) -> str: + """One self-contained document, sized exactly to the figure.""" + + shell = BASE_CSS.format(**INK) + return ( + "<!doctype html><html><head><meta charset='utf-8'><style>" + + font_faces() + + shell + + f"body{{width:{width}px;height:{height}px;overflow:hidden}}" + + css + + "</style></head><body>" + + body + + "</body></html>" + ) + + +# Every element must fit inside itself. A browser will happily let text spill +# out of a fixed box rather than complain, which is exactly how the old figure +# shipped broken, so the render asks the page directly before saving. +OVERFLOW_PROBE = """ +() => { + const bad = []; + for (const el of document.querySelectorAll('*')) { + if (el.tagName === 'BODY' || el.tagName === 'HTML') continue; + const style = getComputedStyle(el); + if (style.overflow === 'visible' && !el.dataset.fixed) continue; + if (el.scrollWidth > el.clientWidth + 1 || + el.scrollHeight > el.clientHeight + 1) { + bad.push(`${el.tagName}.${el.className||'-'} ` + + `content ${el.scrollWidth}x${el.scrollHeight} ` + + `box ${el.clientWidth}x${el.clientHeight}`); + } + } + return bad; +} +""" + +# Text that leaves the page entirely is the failure the old figures actually +# shipped, and a clipped ancestor hides it from the probe above. Measure every +# text-bearing element against the viewport as well. +ESCAPE_PROBE = """ +(size) => { + const bad = []; + for (const el of document.querySelectorAll('*')) { + if (!el.textContent || !el.textContent.trim()) continue; + if (el.children.length) continue; + const r = el.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) continue; + if (r.left < -0.5 || r.top < -0.5 || + r.right > size.w + 0.5 || r.bottom > size.h + 0.5) { + bad.push(`"${el.textContent.trim().slice(0, 46)}" at ` + + `${r.left.toFixed(0)},${r.top.toFixed(0)} ` + + `${r.right.toFixed(0)}x${r.bottom.toFixed(0)}`); + } + } + return bad; +} +""" + + +class RenderError(RuntimeError): + pass + + +# How tall the content actually is, so a figure is not padded out with dead +# space and does not need a hand-maintained height constant that drifts every +# time a sentence changes. +CONTENT_HEIGHT = """ +() => { + let bottom = 0; + for (const el of document.body.children) { + bottom = Math.max(bottom, el.getBoundingClientRect().bottom); + } + const pad = parseFloat(getComputedStyle(document.body).paddingBottom) || 0; + return Math.ceil(bottom + pad); +} +""" + + +def measure_height(html: str, *, width: int, probe_height: int = 4000) -> int: + """Lay the figure out once and report the height its content needs.""" + + from playwright.sync_api import sync_playwright + + binary = chromium_path() + if binary is None: + raise RenderError("no chromium available") + with sync_playwright() as play: + browser = play.chromium.launch(executable_path=str(binary)) + try: + page_ = browser.new_page(viewport={"width": width, "height": probe_height}) + page_.set_content( + html.replace(f"height:{probe_height}px", "height:auto"), + wait_until="load", + ) + page_.wait_for_timeout(200) + return int(page_.evaluate(CONTENT_HEIGHT)) + finally: + browser.close() + + +def render( + html: str, + output: Path, + *, + width: int, + height: int, + scale: int = 2, +) -> Path: + """Screenshot one figure, refusing to save a layout that does not fit.""" + + from playwright.sync_api import sync_playwright + + binary = chromium_path() + if binary is None: + raise RenderError( + "no chromium available; install one with `python3 -m playwright " + "install chromium`" + ) + output.parent.mkdir(parents=True, exist_ok=True) + with sync_playwright() as play: + browser = play.chromium.launch(executable_path=str(binary)) + try: + page_ = browser.new_page( + viewport={"width": width, "height": height}, + device_scale_factor=scale, + ) + page_.set_content(html, wait_until="load") + page_.wait_for_timeout(250) + overflow = page_.evaluate(OVERFLOW_PROBE) + escaped = page_.evaluate(ESCAPE_PROBE, {"w": width, "h": height}) + problems = list(overflow) + list(escaped) + if problems: + raise RenderError( + f"{output.name}: content does not fit its layout:\n " + + "\n ".join(problems) + ) + page_.screenshot(path=str(output)) + finally: + browser.close() + return output + + +# --------------------------------------------------------------------------- +# The terminal window every picker picture is drawn inside. +# +# The contents come from the real picker; this only supplies the chrome. It +# lives here rather than in one of the render tools so the still and the +# animation cannot drift apart, and so neither has to import an extensionless +# script by path. + +COLUMNS = 104 +TYPE_SIZE = 26 +CELL = TYPE_SIZE * 0.60227 +LINE_HEIGHT = 1.42 +PAD_X = 34 +PAD_Y = 26 +TITLE_BAR = 54 + +WINDOW_CSS = """ +body{{background:{outer};display:flex;align-items:center;justify-content:center}} +.win{{ + width:{win}px;border-radius:14px;overflow:hidden; + background:{bg};border:1px solid {line}; + box-shadow:0 18px 50px rgba(0,0,0,.55); +}} +.bar{{ + height:{bar}px;background:{raised};border-bottom:1px solid {line}; + display:flex;align-items:center;padding:0 18px;position:relative; +}} +.lights{{display:flex;gap:9px}} +.lights i{{width:13px;height:13px;border-radius:50%;display:block}} +.title{{ + position:absolute;left:0;right:0;text-align:center; + font-family:KitMono,monospace;font-size:17px;color:{dim}; + letter-spacing:.08em;pointer-events:none; +}} +.screen{{ + padding:{pady}px {padx}px; + font-family:KitMono,monospace;font-size:{size}px;line-height:{lh}; + color:#c9d1d9;white-space:pre; +}} +.caret{{ + display:inline-block;width:{cell}px;height:{caret}px; + background:{text};vertical-align:-3px;margin-left:2px; +}} +""" + + +def window_css() -> str: + return WINDOW_CSS.format( + outer=INK["bg"], + bg=INK["bg"], + raised=INK["raised"], + line=INK["line"], + dim=INK["dim"], + text=INK["text"], + win=round(COLUMNS * CELL) + PAD_X * 2, + bar=TITLE_BAR, + padx=PAD_X, + pady=PAD_Y, + size=TYPE_SIZE, + lh=LINE_HEIGHT, + cell=round(CELL), + caret=round(TYPE_SIZE * 1.05), + ) + + +def window_html(body: str, rows: int, *, caret: bool = True) -> tuple[str, int, int]: + """A captured screen inside a terminal window, sized to its own text.""" + + win = round(COLUMNS * CELL) + PAD_X * 2 + height = TITLE_BAR + PAD_Y * 2 + round(rows * TYPE_SIZE * LINE_HEIGHT) + tail = '<span class="caret"></span>' if caret else "" + markup = ( + '<div class="win">' + '<div class="bar">' + '<div class="lights">' + '<i style="background:#ff5f57"></i>' + '<i style="background:#febc2e"></i>' + '<i style="background:#28c840"></i>' + "</div>" + '<div class="title">session kit</div>' + "</div>" + f'<div class="screen">{body}{tail}</div>' + "</div>" + ) + # A little room around the window so the drop shadow is not clipped. + return markup, win + 40, height + 40 diff --git a/tools/readme_capture.py b/tools/readme_capture.py new file mode 100644 index 0000000..ccc8324 --- /dev/null +++ b/tools/readme_capture.py @@ -0,0 +1,364 @@ +"""Capture real picker screens for the README artwork. + +Nothing here draws a picker. Every frame is what Session Kit's own picker wrote +to a pty, driven through the login test fixture, so a picture can never promise +a screen the code does not produce. The fixture's inventory is neutral demo +data: no real session titles, account aliases, host names, or projects. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +import html as html_mod +from pathlib import Path +import re +import sys +import time + +REPO = Path(__file__).resolve().parents[1] +if str(REPO) not in sys.path: + sys.path.insert(0, str(REPO)) + +# The frames come from the real picker through the login fixture, so the +# repository has to be importable before this resolves. +from tests.test_login import LoginFixture, inventory, row, run_pty # noqa: E402 + +CLEAR = "\x1b[2J\x1b[H" +# The prompt the needs-you screen reads on, and therefore the first thing the +# picker writes when it repaints the list behind it. The repaint lands in the +# same captured frame as the screen it replaces, and it does not start on a +# line of its own, so a line-anchored pattern never sees it. +REPAINT = "needs ❯" + +SGR = re.compile(r"\x1b\[([0-9;]*)m") +# Cursor moves, bracketed-paste toggles and the rest carry no colour and would +# otherwise land in the text as stray characters. +OTHER_ESCAPES = re.compile( + r"\x1b\[[0-9;?]*[a-ln-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" +) + +BASE_COLOURS = { + 30: "#484f58", + 31: "#f85149", + 32: "#3fb950", + 33: "#d29922", + 34: "#58a6ff", + 35: "#bc8cff", + 36: "#39c5cf", + 37: "#c9d1d9", +} +DEFAULT_INK = "#c9d1d9" + + +@dataclass(frozen=True) +class Style: + colour: str = DEFAULT_INK + bold: bool = False + + +def demo_row( + session_id: str, + number: int, + provider: str, + title: str, + *, + availability: str = "ready", + status: str = "idle", + needs_you: bool = False, + blocking_question: bool = False, + transcript_idle: bool = False, + color: str, + subagents: int = 0, +) -> dict: + item = row( + session_id, + number=number, + provider=provider, + availability=availability, + needs_you=needs_you, + ) + item.update( + { + "account_alias": "personal" if provider == "claude" else "work", + "model": "claude-opus-5" if provider == "claude" else "gpt-5-codex", + "display_model": "Opus 5" if provider == "claude" else "GPT-5-Codex", + "model_source": "transcript", + "recent_output_at_unix_ms": int( + (time.time() - 60 * (3 + number % 9)) * 1000 + ), + "recent_output_age_seconds": 60 * (3 + number % 9), + "title": title, + "native_title": title, + "display_title": title, + "agent_status": status, + "blocking_question": blocking_question, + "transcript_idle": transcript_idle, + "display_color": color, + "subagents": [ + {"provider": "codex", "status": "idle"} for _ in range(subagents) + ], + } + ) + return item + + +def demo_rows() -> list[dict]: + """Seven neutral sessions that exercise every state word the README names.""" + + return [ + demo_row( + "demo53", + 53, + "claude", + "Review Release Notes", + status="needs your reply", + needs_you=True, + blocking_question=True, + color="purple", + ), + demo_row( + "demo57", 57, "claude", "Fix Login Timeout", needs_you=True, color="cyan" + ), + demo_row( + "demo59", + 59, + "claude", + "Plan Database Upgrade", + needs_you=True, + transcript_idle=True, + color="green", + ), + demo_row( + "demo60", + 60, + "claude", + "Document Release Process", + status="working", + color="orange", + ), + demo_row( + "demo61", + 61, + "codex", + "Improve Session Picker", + status="running", + color="pink", + subagents=2, + ), + demo_row( + "demo62", + 62, + "codex", + "Draft API Guide", + availability="attached", + status="needs your reply", + needs_you=True, + color="blue", + ), + demo_row( + "demo64", + 64, + "codex", + "Update Installation Docs", + availability="attached", + status="working", + color="yellow", + ), + ] + + +def capture( + keystrokes: bytes = b"q\n", *, lines: int = 30, columns: int = 110 +) -> list[str]: + """Every screen the real picker painted, in order, ANSI colour intact.""" + + document = inventory(*demo_rows()) + # Five neutral display-only roots keep the More counter honest. + document["outside_agents"] = [{"provider": "codex"} for _ in range(5)] + fixture = LoginFixture(document) + try: + code, output = run_pty( + fixture, + keystrokes, + lines=lines, + columns=columns, + env_updates={"SESSION_KIT_NO_COLOR": None, "NO_COLOR": None}, + ) + finally: + fixture.close() + if CLEAR not in output: + raise RuntimeError( + f"the real picker painted no screen (exit {code}); refusing to invent one" + ) + return [part for part in output.split(CLEAR) if part.strip()] + + +def list_frame(frames: list[str]) -> str: + """The last complete session-list screen, prompt line kept. + + The prompt is part of the picture on purpose: a terminal screenshot with no + command line on it does not read as a terminal. + + Leaving the needs-you screen repaints the list into the frame that screen + already occupies, so one captured frame can hold both. Take the half after + the repaint, and require the command bar the list has and the needs-you + screen does not, so neither screen can ever be mistaken for the other. + """ + + complete = [] + for part in frames: + candidate = part.rsplit(REPAINT, 1)[-1] + readable = plain(candidate) + if "sessions" in readable and "kill" in readable and "quit" in readable: + complete.append(candidate) + if not complete: + raise RuntimeError("the real picker produced no complete list screen") + return complete[-1].rstrip("\n") + + +def plain(text: str) -> str: + """The text a person reads, with every escape sequence removed.""" + + return OTHER_ESCAPES.sub("", SGR.sub("", text)) + + +def _apply(style: Style, codes: list[int]) -> Style: + index = 0 + while index < len(codes): + code = codes[index] + if code == 0: + style = Style() + elif code == 1: + style = replace(style, bold=True) + elif code == 22: + style = replace(style, bold=False) + elif code == 39: + style = replace(style, colour=DEFAULT_INK) + elif code in BASE_COLOURS: + style = replace(style, colour=BASE_COLOURS[code]) + elif code in (38, 48) and index + 1 < len(codes): + if codes[index + 1] == 2 and index + 4 < len(codes): + red, green, blue = codes[index + 2 : index + 5] + if code == 38: + style = replace(style, colour=f"#{red:02x}{green:02x}{blue:02x}") + index += 4 + elif codes[index + 1] == 5 and index + 2 < len(codes): + index += 2 + index += 1 + return style + + +def runs(line: str) -> list[tuple[str, Style]]: + """One line split into coloured runs, escapes consumed.""" + + style = Style() + out: list[tuple[str, Style]] = [] + position = 0 + for match in SGR.finditer(line): + chunk = line[position : match.start()] + if chunk: + out.append((OTHER_ESCAPES.sub("", chunk), style)) + codes = [int(value) if value else 0 for value in match.group(1).split(";")] + style = _apply(style, codes) + position = match.end() + tail = line[position:] + if tail: + out.append((OTHER_ESCAPES.sub("", tail), style)) + return [(text, style) for text, style in out if text] + + +def frame_html(frame: str) -> str: + """A captured screen as coloured HTML, ready to drop into a terminal shell.""" + + rendered = [] + for line in frame.splitlines(): + pieces = [] + for text, style in runs(line): + weight = ";font-weight:700" if style.bold else "" + pieces.append( + f'<span style="color:{style.colour}{weight}">' + f"{html_mod.escape(text)}</span>" + ) + rendered.append("".join(pieces) or " ") + return "\n".join(rendered) + + +# --------------------------------------------------------------- needs you + +# The state words that mean a person is being waited on. `idle` is one of them: +# it is a needs-you session whose transcript has stopped moving, not a fifth +# state. Keep this identical to the README's state table. +ATTENTION_WORDS = ("question", "needs you", "idle") + + +def needs_frame(frames: list[str]) -> str: + """The needs-you screen alone, with the list repaint behind it removed.""" + + found = [ + part for part in frames if "needs you:" in plain(part) and "back" in plain(part) + ] + if not found: + raise RuntimeError("the real picker produced no needs-you screen") + head = found[-1].split(REPAINT, 1)[0] + if "needs you:" not in plain(head): + raise RuntimeError("the needs-you screen was lost when the repaint was cut") + # The prompt is the first thing the repaint overwrote, so putting it back is + # restoring the screen rather than decorating it: a terminal picture with no + # command line on it does not read as a terminal. + return (head + REPAINT + " ").strip("\n") + + +def attention_total(needs_screen: str) -> int: + """The number the needs-you screen puts at the top of itself.""" + + match = re.search(r"needs you:\s*(\d+)", plain(needs_screen)) + if match is None: + raise RuntimeError("the needs-you screen printed no total") + return int(match.group(1)) + + +def attention_rows(list_screen: str) -> list[str]: + """Session rows on the list screen whose state word is an attention word.""" + + rows = [] + for line in plain(list_screen).splitlines(): + if not re.match(r"\s+\d+\s{2}", line): + continue + fields = [field.strip() for field in line.split("|")] + # Both screens number their rows the same way and both can carry a + # bare state word, so shape is what separates them: a list row spends + # a column each on provider, account, model, state and age, while a + # needs-you row names only the provider and the wait. Without this a + # frame holding both screens counts some sessions twice. + if len(fields) < 5: + continue + if any(field in ATTENTION_WORDS for field in fields): + rows.append(line.split()[0]) + return rows + + +def refuse_disagreeing_screens(list_screen: str, needs_screen: str) -> None: + """The two screens must count the same sessions as waiting on a person. + + They are built from different evidence, the list prints the one state word + `session_state()` derives, and the needs-you screen counts the rows whose + raw `needs_you`/`blocking_question` flags are set, so they can disagree, + and a release where they do must not be photographed for the front page. + The first capture taken for this figure disagreed: an ordinary finished + Codex turn reports `agent_status: idle` with `needs_you: false`, the list + printed `needs you` for it because `STATE_WORDS` maps idle to that word, + and the needs-you screen left it out. Two pictures on one page, one saying + four sessions are waiting and the other three. + """ + + listed = attention_rows(list_screen) + total = attention_total(needs_screen) + numbered = re.findall(r"^\s+(\d+)\s", plain(needs_screen), re.MULTILINE) + if total != len(listed) or sorted(numbered) != sorted(listed): + raise SystemExit( + "the two picker screens disagree about who is waiting:\n" + f" list screen says {len(listed)}: {', '.join(listed) or 'none'}\n" + f" needs-you screen says {total}: {', '.join(numbered) or 'none'}\n" + "Shipping both pictures would put the contradiction on the front " + "page. Fix the disagreement before regenerating the figures." + ) diff --git a/tools/readme_keys.py b/tools/readme_keys.py new file mode 100644 index 0000000..ec68b0e --- /dev/null +++ b/tools/readme_keys.py @@ -0,0 +1,62 @@ +"""The README's key table and the picker's command bar have to agree. + +The picker in the README's screenshot is rendered from live code, so a key +merged privately and not yet released appears in the picture the moment someone +regenerates it, advertising a key on the project's front page that the shipped +release does not have. This refuses to render instead. + +The checks used to live inside `tools/render-readme-anatomy`, which drew an +annotated four-panel figure the README no longer carries. The figure went; the +guard is the part worth keeping, so it moved here and now runs against the +screenshot that actually ships. +""" + +from __future__ import annotations + +from pathlib import Path +import re + +REPO = Path(__file__).resolve().parents[1] + + +def documented_keys(readme: Path | None = None) -> set[str]: + """The single-character keys the README's key table currently promises.""" + + table = (readme or REPO / "README.md").read_text(encoding="utf-8") + keys = set() + for line in re.findall(r"^\|.*\|\s*$", table, re.MULTILINE): + # A row may name more than one key, as "`b` or `q`" does. + for span in re.findall(r"`([^`]+)`", line): + head = span.split()[0] + if len(head) == 1 and (head.isalpha() or head == "?"): + keys.add(head) + if not keys: + raise RuntimeError("the README key table named no keys") + return keys + + +def offered_keys(frame: str) -> set[str]: + """The single-character keys the rendered command bar actually offers.""" + + footer = next( + (line for line in frame.splitlines() if "kill" in line and "leave" in line), "" + ) + if not footer: + raise RuntimeError("the capture had no command bar to check") + # The frame carries its colour escapes; they sit flush against the keys. + plain = re.sub(r"\x1b\[[0-9;]*m", "", footer) + return {token for token in re.findall(r"(?<!\S)([a-z?])(?!\S)", plain)} + + +def refuse_undocumented_keys(frame: str, readme: Path | None = None) -> None: + """A key the picker offers but the README does not document must not ship.""" + + extra = sorted(offered_keys(frame) - documented_keys(readme)) + if extra: + raise SystemExit( + "the picker offers keys the README does not document: " + + ", ".join(extra) + + "\nEither document them in the README key table, or render this " + "figure from a tree where the feature is not present. Shipping it " + "would advertise a key the released version does not have." + ) diff --git a/tools/render-readme-figures b/tools/render-readme-figures new file mode 100755 index 0000000..9a6b93c --- /dev/null +++ b/tools/render-readme-figures @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Render the README's explanatory figures. + +These replace two hand-written SVGs whose text sat at fixed x/y coordinates in +a font almost no reader has installed. Three lines in the safety figure crossed +their own card borders as a result, and nothing caught it. Here the browser +sizes every box to its content and `readme_art_lib.render` refuses to save a +layout where anything overflows, so that failure cannot ship again. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +TOOLS = Path(__file__).resolve().parent +if str(TOOLS) not in sys.path: + sys.path.insert(0, str(TOOLS)) + +import readme_art_lib as art # noqa: E402 + +WIDTH = 1600 + +SHARED_CSS = """ +body{{padding:56px 64px;display:flex;flex-direction:column}} +h1{{font-size:46px;font-weight:800;letter-spacing:-.02em;margin-bottom:10px}} +.lede{{font-size:21px;color:{dim};line-height:1.45;max-width:1180px}} +.row{{display:flex;align-items:stretch;gap:0;margin-top:44px}} +.card{{ + flex:1 1 0;min-width:0;background:{panel};border:2px solid {line}; + border-radius:16px;padding:26px 24px;display:flex;flex-direction:column; +}} +.card h2{{font-size:23px;font-weight:600;margin-bottom:14px;line-height:1.25}} +.card p{{font-size:17px;color:{dim};line-height:1.5}} +.card p + p{{margin-top:12px}} +.chip{{ + font-family:KitMono,monospace;font-size:15px;color:{text}; + background:{raised};border-radius:8px;padding:8px 11px; + margin:14px 0 10px;align-self:flex-start;max-width:100%; + overflow-wrap:anywhere; +}} +.arrow{{ + flex:0 0 54px;display:flex;align-items:center;justify-content:center; + color:{accent};font-size:30px; +}} +.band{{ + margin-top:40px;background:{raised};border:1px solid {line}; + border-radius:16px;padding:26px 32px; +}} +.band h3{{font-size:22px;font-weight:600;margin-bottom:10px}} +.band p{{font-size:17px;color:{dim};line-height:1.55}} +.good{{color:{green};font-weight:600;font-size:19px}} +.bad{{color:{red};font-weight:600;font-size:19px}} +""" + + +def shared_css(accent: str) -> str: + return SHARED_CSS.format(accent=accent, **art.INK) + + +def cards(items: list[dict], accent: str) -> str: + """Four cards with an arrow between each pair.""" + + blocks = [] + for index, item in enumerate(items): + if index: + blocks.append('<div class="arrow">→</div>') + inner = [ + f'<h2 style="color:{item.get("ink", art.INK["text"])}">{item["head"]}</h2>' + ] + inner.extend(item["body"]) + blocks.append( + f'<div class="card" style="border-color:{item["edge"]}">' + + "".join(inner) + + "</div>" + ) + return f'<div class="row">{"".join(blocks)}</div>' + + +def how_it_works() -> tuple[str, str, int]: + steps = [ + { + "edge": art.INK["line"], + "head": "1 · An ordinary shell", + "body": [ + '<div class="chip">kit</div>', + "<p>Open the picker only when you want it. Nothing runs in " + "your shell the rest of the time.</p>", + ], + }, + { + "edge": art.INK["green"], + "head": "2 · The picker", + "body": [ + "<p>Pick a stable session number, or start a new one.</p>", + "<p>Sessions ready here and sessions open in another window " + "stay separate.</p>", + ], + }, + { + "edge": art.INK["cyan"], + "head": "3 · The managed session", + "body": [ + "<p>The session runs on the host, inside shpool.</p>", + "<p>Claude Code or Codex runs inside it.</p>", + ], + }, + { + "edge": art.INK["purple"], + "head": "4 · Disconnect, return", + "body": [ + "<p>Close the terminal, or lose SSH.</p>", + "<p>Reconnect whenever, and pick the same session back up " + "where it was.</p>", + ], + }, + ] + body = ( + "<h1>How Session Kit works</h1>" + '<p class="lede">The work stays alive because the session lives on the ' + "host, not in your terminal window. Session Kit adds the picker, the " + "state words, and the guarded actions around it.</p>" + + cards(steps, art.INK["green"]) + + '<div class="band"><h3>What actually survives</h3>' + "<p>As long as the host stays powered on and awake, a running session " + "survives the terminal window closing and the SSH connection dropping. " + "A host reboot ends the running processes — recoverable Claude " + "Code and Codex conversations remain available through Closed " + "sessions.</p></div>" + ) + return body, shared_css(art.INK["green"]), 0 + + +def safety_model() -> tuple[str, str, int]: + steps = [ + { + "edge": art.INK["line"], + "head": "1 · A frozen snapshot", + "body": [ + "<p>The picker shows a stable view of what exists right now.</p>", + '<div class="chip">number · title · directory</div>', + "<p>Useful context. Not identity.</p>", + ], + }, + { + "edge": art.INK["cyan"], + "head": "2 · Bind an action proof", + "body": [ + "<p>The session you chose is bound to that snapshot and to " + "exact process data.</p>", + '<div class="chip">provider UUID + generation</div>', + "<p>Owner-only, and short-lived.</p>", + ], + }, + { + "edge": art.INK["amber"], + "head": "3 · Recheck, at the last moment", + "body": [ + "<p>Immediately before anything changes, Session Kit rechecks " + "the live chain: session manager, terminal, shell, provider " + "process, and conversation UUID.</p>", + ], + }, + { + "edge": art.INK["green"], + "head": "4 · Act, or refuse", + "body": [ + '<p><span class="good">Evidence still exact → act</span></p>', + '<p><span class="bad">Missing, stale, or conflicting ' + "→ refuse</span></p>", + "<p>A refusal changes nothing at all.</p>", + ], + }, + ] + body = ( + "<h1>The safety model</h1>" + '<p class="lede">What you see and what Session Kit trusts are ' + "deliberately different things. Display text helps you choose; only " + "exact live evidence decides whether it is allowed to act.</p>" + + cards(steps, art.INK["cyan"]) + + '<div class="band"><h3>Where the boundary is</h3>' + "<p>This is designed for one trusted Unix account. These checks stop " + "stale or ambiguous picker state from acting on a session you did not " + "mean. They are <strong>not</strong> isolation from a hostile process " + "already running with your own privileges.</p></div>" + ) + return body, shared_css(art.INK["cyan"]), 0 + + +FIGURES = { + "how-it-works": how_it_works, + "safety-model": safety_model, +} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--only", choices=sorted(FIGURES), action="append") + parser.add_argument("--out-dir", type=Path, default=art.ASSETS) + args = parser.parse_args() + + wanted = args.only or sorted(FIGURES) + for name in wanted: + body, css, _ = FIGURES[name]() + # Lay it out once to learn how tall the content is, then render at + # exactly that height. Nothing here carries a hand-tuned page size. + probe = art.page(body, css, width=WIDTH, height=4000) + height = art.measure_height(probe, width=WIDTH) + html = art.page(body, css, width=WIDTH, height=height) + target = args.out_dir / f"{name}.png" + art.render(html, target, width=WIDTH, height=height, scale=1) + print(f"wrote {target} ({WIDTH}x{height})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/render-readme-picker b/tools/render-readme-picker new file mode 100755 index 0000000..3fa32e2 --- /dev/null +++ b/tools/render-readme-picker @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Render the README's picker screenshots from Session Kit's real picker. + +Each picture is a terminal window with a picker screen inside it. The contents +are captured from the running picker through the login test fixture -- not +drawn, not mocked, not typeset by hand -- and the window chrome around it is +the only thing this tool adds. + +Two screens ship: the session list, and the needs-you screen behind `a`. They +are captured in one run of the picker so they cannot describe different +inventories, and the guards below refuse to save a pair that disagrees. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +TOOLS = Path(__file__).resolve().parent +if str(TOOLS) not in sys.path: + sys.path.insert(0, str(TOOLS)) + +import readme_art_lib as art # noqa: E402 +import readme_capture as capture # noqa: E402 +import readme_keys as keys # noqa: E402 + +COLUMNS = art.COLUMNS + +# The list screen has to show every state word the README's table defines, or +# the table is explaining something the picture does not contain. +REQUIRED = ("Open elsewhere", "Ready", "needs you", "question", "idle") + + +def window_html(frame: str) -> tuple[str, int, int]: + """The captured screen inside a terminal window.""" + + return art.window_html(capture.frame_html(frame), len(frame.splitlines())) + + +def check_list_screen(frame: str) -> None: + readable = capture.plain(frame) + missing = [wanted for wanted in REQUIRED if wanted not in readable] + if missing: + raise SystemExit( + "the captured screen is missing what the README describes " + f"({', '.join(missing)}); refusing to ship it" + ) + if "…" in readable: + raise SystemExit( + f"the picker elided a title at {COLUMNS} columns; widen the capture " + "rather than shipping a truncated screenshot" + ) + keys.refuse_undocumented_keys(frame) + + +def write(frame: str, output: Path) -> None: + markup, width, height = window_html(frame) + html = art.page(markup, art.window_css(), width=width, height=height) + art.render(html, output, width=width, height=height, scale=1) + print(f"wrote {output} ({width}x{height})") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--assets", type=Path, default=art.ASSETS) + args = parser.parse_args() + + # `a` opens the needs-you screen, `b` leaves it, `q` leaves the picker. + frames = capture.capture(b"a\nb\nq\n", columns=COLUMNS) + listing = capture.list_frame(frames) + needs = capture.needs_frame(frames) + + check_list_screen(listing) + capture.refuse_disagreeing_screens(listing, needs) + + write(listing, args.assets / "picker.png") + write(needs, args.assets / "needs-you.png") + print(f"both screens came from one run of {len(frames)} real captures") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/render-readme-social b/tools/render-readme-social new file mode 100755 index 0000000..049e57e --- /dev/null +++ b/tools/render-readme-social @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Render the GitHub social preview card. + +This is the picture GitHub shows when the repository is linked anywhere, so it +has one job: say what Session Kit is at a size where only the headline is +readable. The terminal under the headline is the real needs-you screen, come +from the same capture that produces the README's figures, because the sentence +above it is a claim about that screen. + +The card is built in a browser like every other figure here, so the same guard +applies: nothing may escape the canvas. +""" + +from __future__ import annotations + +import argparse +import base64 +from pathlib import Path +import sys + +TOOLS = Path(__file__).resolve().parent +if str(TOOLS) not in sys.path: + sys.path.insert(0, str(TOOLS)) + +import readme_art_lib as art # noqa: E402 +import readme_capture as capture # noqa: E402 + +MARK = art.REPO / "docs" / "assets" / "mark.svg" + +# GitHub's documented size for a social preview. +WIDTH, HEIGHT = 1280, 640 +PAD = 60 +# The headline needs air under it; the terminal is the floor of the card. +PAD_TOP, PAD_BOTTOM = 46, 50 +# The window is drawn at its native size and scaled, so the terminal keeps the +# exact proportions it has in the README rather than being re-typeset smaller. +WINDOW_WIDTH = WIDTH - PAD * 2 + +HEADLINE = ("Many AI coding sessions.", "One place to see which one needs you.") +SUPPORTS = "Claude Code · Codex · Linux and macOS" + +CSS = """ +body{{ + width:{w}px;height:{h}px;overflow:hidden;position:relative; + background: + repeating-linear-gradient(0deg,{line}22 0 1px,transparent 1px 64px), + repeating-linear-gradient(90deg,{line}22 0 1px,transparent 1px 64px), + radial-gradient(120% 90% at 12% 0%,{panel} 0%,{bg} 62%); +}} +.card{{position:absolute;left:{pad}px;right:{pad}px;top:{padtop}px}} +.top{{display:flex;align-items:baseline;justify-content:space-between;gap:24px}} +.mark{{ + display:flex;align-items:center;gap:14px; + font-family:KitMono,monospace;font-size:22px;color:{dim}; + letter-spacing:.1em;white-space:nowrap; +}} +.mark img{{display:block;width:34px;height:34px}} +.supports{{font-size:19px;color:{dim};white-space:nowrap}} +h1{{ + margin-top:24px;font-size:43px;line-height:1.16;font-weight:800; + letter-spacing:-.022em;color:{text}; +}} +h1 span{{color:{green};display:block}} +.stage{{ + position:absolute;left:{pad}px;top:{top}px; + width:{ww}px;height:{wh}px;overflow:visible; +}} +.stage .win{{transform:scale({scale});transform-origin:top left}} +""" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output", type=Path, default=art.ASSETS / "social-preview.png" + ) + parser.add_argument("--scale", type=int, default=2) + args = parser.parse_args() + + frames = capture.capture(b"a\nb\nq\n", columns=art.COLUMNS) + listing = capture.list_frame(frames) + needs = capture.needs_frame(frames) + # The card claims the needs-you screen answers the question. If that screen + # disagrees with the list it came from, the claim is not true of this build. + capture.refuse_disagreeing_screens(listing, needs) + + markup, native_width, native_height = art.window_html( + capture.frame_html(needs), len(needs.splitlines()) + ) + # window_html pads the box for the drop shadow; the card supplies its own + # space, so scale against the window rather than the padded box. + scale = WINDOW_WIDTH / (native_width - 40) + stage_height = round((native_height - 40) * scale) + top = HEIGHT - PAD_BOTTOM - stage_height + + mark = "data:image/svg+xml;base64," + base64.b64encode(MARK.read_bytes()).decode( + "ascii" + ) + body = ( + '<div class="card">' + '<div class="top">' + f'<div class="mark"><img src="{mark}" alt=""> session kit</div>' + f'<div class="supports">{SUPPORTS}</div>' + "</div>" + f"<h1>{HEADLINE[0]}<span>{HEADLINE[1]}</span></h1>" + "</div>" + f'<div class="stage">{markup}</div>' + ) + css = art.window_css() + CSS.format( + w=WIDTH, + h=HEIGHT, + pad=PAD, + padtop=PAD_TOP, + top=top, + ww=WINDOW_WIDTH, + wh=stage_height, + scale=f"{scale:.6f}", + bg=art.INK["bg"], + panel=art.INK["panel"], + line=art.INK["line"], + dim=art.INK["dim"], + text=art.INK["text"], + green=art.INK["green"], + ) + html = art.page(body, css, width=WIDTH, height=HEIGHT) + art.render(html, args.output, width=WIDTH, height=HEIGHT, scale=args.scale) + size = args.output.stat().st_size + print( + f"wrote {args.output} ({WIDTH}x{HEIGHT} at {args.scale}x, {size // 1024} KiB)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/title-ownership-drill b/tools/title-ownership-drill new file mode 100755 index 0000000..fcffb36 --- /dev/null +++ b/tools/title-ownership-drill @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +"""Who owns the terminal tab title in a Claude Code window? + +The kit and the provider both write OSC title sequences, and nobody had +measured what happens when the vendor's documented off-switch +(``CLAUDE_CODE_DISABLE_TERMINAL_TITLE``) is set while a kit hook is asking for +a title of its own. Two answers are possible and they lead to opposite +designs: if the switch silences only the provider's own writes, the kit turns +it on and owns the tab outright; if it silences the hook's ``sessionTitle`` +too, the kit has to leave the switch alone and assert its title after the +provider's. + +The drill answers that live, without touching anything the operator uses. It +builds a throwaway HOME (credentials copied in read-only fashion from the real +one so the window actually boots), registers a hook that asks for a marker +title, runs a real Claude Code TUI on a pty, captures every byte the program +writes, and quits. It never sends a prompt, so no model turn is spent. + +Usage:: + + tools/title-ownership-drill [--seconds 25] [--outdir DIR] [--keep] + +It prints one JSON object per variant plus a verdict line. Exit status is 0 +when both variants ran, 2 when the provider never booted far enough to write +any title at all (an inconclusive drill, never a silent pass). +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import pty +import re +import select +import shutil +import signal +import subprocess +import sys +import tempfile +import time + + +MARKER = "SKDRILLMARKER" +# OSC 0 (icon + title) and OSC 2 (title), terminated by BEL or ST. +OSC_TITLE = re.compile(rb"\x1b\](0|1|2);([^\x07\x1b]*)(?:\x07|\x1b\\)") + +HOOK = """#!/usr/bin/env bash +# Throwaway drill hook: ask for a marker title on every event that can carry +# one, and record what we were handed. Fails open like the real one. +set -uo pipefail +INPUT=$(cat 2>/dev/null) || exit 0 +EVENT=$(printf '%s' "$INPUT" | jq -r '.hook_event_name // ""' 2>/dev/null) +printf '%s\\n' "$EVENT" >> "$DRILL_LOG" 2>/dev/null +case "$EVENT" in + SessionStart|UserPromptSubmit) ;; + *) exit 0 ;; +esac +jq -nc --arg e "$EVENT" --arg t "$DRILL_TITLE" \\ + '{hookSpecificOutput:{hookEventName:$e,sessionTitle:$t}}' 2>/dev/null +exit 0 +""" + +HOOK_SEQUENCE = """#!/usr/bin/env bash +# Same shape, but exercising the other documented lever: a raw terminal +# sequence emitted by the hook rather than a sessionTitle field. +set -uo pipefail +INPUT=$(cat 2>/dev/null) || exit 0 +EVENT=$(printf '%s' "$INPUT" | jq -r '.hook_event_name // ""' 2>/dev/null) +printf '%s\\n' "$EVENT" >> "$DRILL_LOG" 2>/dev/null +case "$EVENT" in + SessionStart|UserPromptSubmit) ;; + *) exit 0 ;; +esac +jq -nc --arg e "$EVENT" --arg s "$(printf '\\033]0;%s\\007' "$DRILL_TITLE")" \\ + '{hookSpecificOutput:{hookEventName:$e,terminalSequence:$s}}' 2>/dev/null +exit 0 +""" + + +def build_sandbox(root: Path, *, mode: str, title: str) -> Path: + """A throwaway HOME whose only borrowed file is the OAuth credential.""" + home = root / f"home-{mode}" + config = home / ".claude" + (config / "hooks").mkdir(parents=True, exist_ok=True) + (home / "work").mkdir(parents=True, exist_ok=True) + + real_credentials = Path.home() / ".claude" / ".credentials.json" + if real_credentials.is_file(): + shutil.copyfile(real_credentials, config / ".credentials.json") + os.chmod(config / ".credentials.json", 0o600) + + hook = config / "hooks" / "drill_title.sh" + hook.write_text(HOOK_SEQUENCE if mode.endswith("sequence") else HOOK) + os.chmod(hook, 0o700) + + entry = [{"hooks": [{"type": "command", "command": os.fspath(hook)}]}] + (config / "settings.json").write_text( + json.dumps( + { + "hooks": { + "SessionStart": entry, + "UserPromptSubmit": entry, + } + }, + indent=2, + ) + ) + (config / ".claude.json").write_text( + json.dumps( + { + "hasCompletedOnboarding": True, + "installMethod": "native", + "autoUpdates": False, + "theme": "dark", + "projects": { + os.fspath(home / "work"): { + "hasTrustDialogAccepted": True, + "hasCompletedProjectOnboarding": True, + "allowedTools": [], + "history": [], + } + }, + }, + indent=2, + ) + ) + return home + + +def run_variant( + root: Path, *, mode: str, disable: bool, seconds: float, claude: str +) -> dict: + title = f"{MARKER}-{mode}" + home = build_sandbox(root, mode=mode, title=title) + log = root / f"hookevents-{mode}.txt" + capture = root / f"pty-{mode}.bin" + + env = { + "HOME": os.fspath(home), + "CLAUDE_CONFIG_DIR": os.fspath(home / ".claude"), + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "TERM": "xterm-256color", + "LANG": os.environ.get("LANG", "C.UTF-8"), + "DRILL_TITLE": title, + "DRILL_LOG": os.fspath(log), + "SHELL": "/bin/bash", + } + if disable: + env["CLAUDE_CODE_DISABLE_TERMINAL_TITLE"] = "1" + + # The sandbox borrows the real OAuth credential to boot a window. Whatever + # happens next -- a crash, an interrupt, a pty that never opens -- that copy + # is deleted before this function returns, so a second copy of a secret is + # never left in a scratch directory somebody will move around. + borrowed = home / ".claude" / ".credentials.json" + try: + return _run_variant_inner( + home, log, capture, env, mode=mode, disable=disable, seconds=seconds, + claude=claude, + ) + finally: + try: + borrowed.unlink() + except OSError: + pass + + +def _run_variant_inner( + home: Path, + log: Path, + capture: Path, + env: dict, + *, + mode: str, + disable: bool, + seconds: float, + claude: str, +) -> dict: + parent, child = pty.openpty() + process = subprocess.Popen( + [claude], + stdin=child, + stdout=child, + stderr=child, + cwd=os.fspath(home / "work"), + env=env, + preexec_fn=os.setsid, + ) + os.close(child) + data = bytearray() + deadline = time.time() + seconds + quit_sent = False + try: + while time.time() < deadline: + ready, _, _ = select.select([parent], [], [], 0.25) + if ready: + try: + chunk = os.read(parent, 65536) + except OSError: + break + if not chunk: + break + data.extend(chunk) + if not quit_sent and time.time() > deadline - seconds / 2: + # Leave without a prompt: no model turn is spent. + os.write(parent, b"/quit\r") + quit_sent = True + if process.poll() is not None: + # Drain whatever is left, then stop. + while True: + ready, _, _ = select.select([parent], [], [], 0.2) + if not ready: + break + try: + chunk = os.read(parent, 65536) + except OSError: + break + if not chunk: + break + data.extend(chunk) + break + finally: + if process.poll() is None: + with open(os.devnull): + pass + os.killpg(os.getpgid(process.pid), signal.SIGTERM) + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(os.getpgid(process.pid), signal.SIGKILL) + process.wait(timeout=10) + os.close(parent) + + capture.write_bytes(bytes(data)) + titles = [match.group(2).decode("utf-8", "replace") for match in OSC_TITLE.finditer(bytes(data))] + events = log.read_text().split() if log.is_file() else [] + return { + "mode": mode, + "disable_env": disable, + "hook_events_seen": events, + "titles_written": titles, + "marker_present": any(MARKER in one for one in titles), + "vendor_titles": [one for one in titles if MARKER not in one], + "capture": os.fspath(capture), + "capture_bytes": len(data), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--seconds", type=float, default=25.0) + parser.add_argument("--outdir", default="") + parser.add_argument("--keep", action="store_true") + parser.add_argument("--claude", default=shutil.which("claude") or "claude") + arguments = parser.parse_args() + + root = Path(arguments.outdir) if arguments.outdir else Path( + tempfile.mkdtemp(prefix="sk-title-drill.") + ) + root.mkdir(parents=True, exist_ok=True) + + results = [] + for mode, disable in ( + ("title-on", False), + ("title-off", True), + ("sequence-on", False), + ("sequence-off", True), + ): + result = run_variant( + root, + mode=mode, + disable=disable, + seconds=arguments.seconds, + claude=arguments.claude, + ) + results.append(result) + print(json.dumps(result, indent=2)) + + (root / "results.json").write_text(json.dumps(results, indent=2)) + return report(results, root) + + +def report(results: list, root: Path) -> int: + """Say what each run measured, from that run's own capture. + + Every line below names the run it came from. An earlier version read the + terminalSequence verdict out of the switch-UNSET run and printed the + opposite of what the switch-set run had recorded -- the drill exists to be + re-run after a vendor upgrade, so a summary line that does not match its own + evidence is worse than no summary at all. + """ + by_mode = {item["mode"]: item for item in results} + missing = {"title-on", "title-off", "sequence-on", "sequence-off"} - set(by_mode) + if missing: + print(f"VERDICT: inconclusive, runs missing: {', '.join(sorted(missing))}") + return 2 + for mode in ("title-on", "sequence-on"): + if not by_mode[mode]["marker_present"]: + print( + f"VERDICT: inconclusive, the control run {mode} wrote no kit " + "title, so the switch-set runs prove nothing" + ) + return 2 + for mechanism, control, switched in ( + ("sessionTitle", "title-on", "title-off"), + ("terminalSequence", "sequence-on", "sequence-off"), + ): + survives = by_mode[switched]["marker_present"] + print( + f"{mechanism}: control {control} wrote " + f"{len(by_mode[control]['titles_written'])} title(s) including the " + f"marker; with the switch set, {switched} wrote " + f"{len(by_mode[switched]['titles_written'])}, " + + ("marker survives" if survives else "marker suppressed") + ) + both_suppressed = not by_mode["title-off"]["marker_present"] and not ( + by_mode["sequence-off"]["marker_present"] + ) + if both_suppressed: + verdict = ( + "CLAUDE_CODE_DISABLE_TERMINAL_TITLE suppresses the kit hook title too " + "(both mechanisms); the kit must never set it" + ) + elif by_mode["title-off"]["marker_present"] and by_mode["sequence-off"][ + "marker_present" + ]: + verdict = "the switch suppresses ONLY vendor titles; both kit mechanisms survive" + else: + surviving = [ + name + for name, mode in ( + ("sessionTitle", "title-off"), + ("terminalSequence", "sequence-off"), + ) + if by_mode[mode]["marker_present"] + ] + verdict = ( + "the switch suppresses some kit titles but not all; surviving: " + + ", ".join(surviving) + ) + print(f"VERDICT: {verdict}") + print(f"evidence: {root}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())